feat: Plexum-gemini-provider v0.1 — Google Gemini via local CLI binary
Plexum ProviderPlugin that wraps the local `gemini` CLI binary
(Google Gemini CLI ≥0.37). Same CLI-driven approach as
Plexum-anthropic-provider's contractor mode: each Plexum turn forks
one `gemini -p` subprocess in the agent's workspace dir.
Reference: openclaw/extensions/google/cli-backend.ts.
internal/runner/ (~190 LOC):
- Run() spawns: gemini --skip-trust --output-format json [--model X]
[--resume <sid>] -p <prompt> in agent workspace
- Parses the single JSON blob gemini emits at exit
({"session_id":"...", "response":"...", "stats":{...}})
- session_id persisted at <workspace>/.plexum-gemini-session so the
next turn passes --resume — multi-turn context continuity through
the CLI's own session state
- Emits synthetic message_start + text_delta + message_end events to
match Plexum's streaming agentic-loop contract
- Tolerates the "Ripgrep is not available." stderr preamble (strips
leading non-{ bytes before json.Unmarshal)
cmd/plexum-gemini-provider-plugin/ implements ProviderPluginWithAgent
(receives AgentContext.Workspace from the host).
Models advertised in provider.models:
gemini (no --model flag; CLI default = flash-preview)
gemini-pro → CLI alias "pro" → gemini-3.1-pro-preview
gemini-flash → CLI alias "flash" → gemini-3.1-flash-preview
gemini-flash-lite → CLI alias "flash-lite" → gemini-3.1-flash-lite-preview
Unrecognized model id passes through as --model <id> so operator can
pin a specific gemini-3.x id.
HostConfig (all optional):
binary (default "gemini" — operator should set absolute path
if running under systemd; PATH won't include nvm dirs)
extra_args (appended before -p)
No api_key field — gemini CLI handles auth via ~/.gemini/ state
(OAuth or GEMINI_API_KEY env).
End-to-end verified against local install:
1. CLI embedded turn 1: "Hi, I'm Gemini, your autonomous CLI
agent for software engineering tasks."
2. CLI embedded turn 2: "I said hi as Gemini." (multi-turn ✓ via
--resume)
3. Gateway socket: {"outcome":"text","text":"pong"}
4. Fabric channel e2e: alice → bt2-clean → gem agent → gemini
CLI → outbound REST → seq=19:
"A shrimp's heart is located in its head,
which is quite an unusual biological
arrangement."
Known: when daemon runs under systemd, PATH doesn't include nvm
dirs by default. Operator must set the absolute binary path in
config.json (README notes this).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
147
cmd/plexum-gemini-provider-plugin/main.go
Normal file
147
cmd/plexum-gemini-provider-plugin/main.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// plexum-gemini-provider-plugin is a Plexum ProviderPlugin that wraps
|
||||
// the local `gemini` CLI binary (Google Gemini CLI). Each Plexum turn
|
||||
// forks one `gemini -p` subprocess in the agent's workspace, captures
|
||||
// JSON output, and emits canonical TurnEvents. Multi-turn continuity
|
||||
// via `--resume <session-id>` (session id stashed at
|
||||
// <workspace>/.plexum-gemini-session).
|
||||
//
|
||||
// Reference: openclaw/extensions/google/cli-backend.ts. Same CLI-driven
|
||||
// approach as Plexum-anthropic-provider's contractor mode.
|
||||
//
|
||||
// Models — Plexum agent.json.model can be any of:
|
||||
// gemini-pro → CLI alias "pro" → gemini-3.1-pro-preview
|
||||
// gemini-flash → CLI alias "flash" → gemini-3.1-flash-preview (default)
|
||||
// gemini-flash-lite → CLI alias "flash-lite" → gemini-3.1-flash-lite-preview
|
||||
// gemini → no --model flag (gemini-cli default)
|
||||
// or any raw model id the local gemini CLI accepts (passed through).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.hangman-lab.top/hzhang/Plexum-sdk-go/canonical"
|
||||
plugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin"
|
||||
|
||||
"git.hangman-lab.top/hzhang/Plexum-gemini-provider/internal/runner"
|
||||
)
|
||||
|
||||
const pluginName = "plexum-gemini-provider"
|
||||
|
||||
// Aliases the plugin translates before passing --model to the CLI.
|
||||
// CLI accepts the aliases directly too, but normalizing keeps logs
|
||||
// consistent across operator + provider sides.
|
||||
var modelAliases = map[string]string{
|
||||
"gemini-pro": "pro",
|
||||
"gemini-flash": "flash",
|
||||
"gemini-flash-lite": "flash-lite",
|
||||
}
|
||||
|
||||
// supportedModels: what the manifest's provider.models advertises.
|
||||
// Operator agent.json.model must match one of these; we route the
|
||||
// "gemini" plain id to the CLI's default (no --model flag).
|
||||
var supportedModels = []string{
|
||||
"gemini",
|
||||
"gemini-pro",
|
||||
"gemini-flash",
|
||||
"gemini-flash-lite",
|
||||
}
|
||||
|
||||
// HostConfig is per-profile plugin config at
|
||||
// <profile>/plugins/plexum-gemini-provider/config.json. All fields
|
||||
// optional — sensible defaults if file is missing.
|
||||
//
|
||||
// {
|
||||
// "binary": "gemini", // executable name or absolute path
|
||||
// "extra_args": ["--yolo"] // appended after the standard flags
|
||||
// }
|
||||
//
|
||||
// No api_key field: gemini CLI auth lives in ~/.gemini (OAuth or
|
||||
// GEMINI_API_KEY env), already set up by the operator.
|
||||
type HostConfig struct {
|
||||
Binary string `json:"binary"`
|
||||
ExtraArgs []string `json:"extra_args"`
|
||||
}
|
||||
|
||||
type geminiPlugin struct {
|
||||
host plugin.HostAPI
|
||||
cfg HostConfig
|
||||
}
|
||||
|
||||
func (p *geminiPlugin) Manifest() plugin.Manifest {
|
||||
return plugin.Manifest{
|
||||
Name: pluginName,
|
||||
Version: "0.1.0",
|
||||
Activation: plugin.ActivationLazy,
|
||||
Executable: "plexum-gemini-provider-plugin",
|
||||
Contracts: plugin.Contracts{
|
||||
Provider: &plugin.ProviderContract{Models: supportedModels},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *geminiPlugin) Init(ctx context.Context, host plugin.HostAPI) error {
|
||||
p.host = host
|
||||
|
||||
profileRoot := os.Getenv("PLEXUM_PROFILE_ROOT")
|
||||
if profileRoot == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
profileRoot = filepath.Join(home, ".plexum")
|
||||
}
|
||||
cfgPath := filepath.Join(profileRoot, "plugins", pluginName, "config.json")
|
||||
raw, err := os.ReadFile(cfgPath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("read %s: %w", cfgPath, err)
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &p.cfg); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", cfgPath, err)
|
||||
}
|
||||
}
|
||||
if p.cfg.Binary == "" {
|
||||
p.cfg.Binary = "gemini"
|
||||
}
|
||||
host.Log("info", "gemini provider initialized", map[string]any{
|
||||
"binary": p.cfg.Binary, "extra_args": p.cfg.ExtraArgs, "models": supportedModels,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stream is the bare ProviderPlugin entrypoint; called when the SDK
|
||||
// can't see StreamWithAgent. We use the agent-aware variant because
|
||||
// the CLI needs the workspace cwd.
|
||||
func (p *geminiPlugin) Stream(ctx context.Context, modelID string, req canonical.TurnRequest) (<-chan canonical.TurnEvent, error) {
|
||||
return p.StreamWithAgent(ctx, modelID, plugin.AgentContext{}, req)
|
||||
}
|
||||
|
||||
// StreamWithAgent receives the AgentContext (workspace path) Plexum's
|
||||
// host wires in for this kind of plugin (see ProviderPluginWithAgent).
|
||||
func (p *geminiPlugin) StreamWithAgent(ctx context.Context, modelID string, agent plugin.AgentContext, req canonical.TurnRequest) (<-chan canonical.TurnEvent, error) {
|
||||
cliModel := mapModel(modelID)
|
||||
return runner.Run(ctx, p.host, agent, cliModel, p.cfg.Binary, p.cfg.ExtraArgs, req)
|
||||
}
|
||||
|
||||
// mapModel converts a Plexum-advertised model id into the CLI's
|
||||
// --model value. Returns "" for "gemini" (use CLI default) and
|
||||
// passes any other unrecognized id through verbatim (operator may
|
||||
// have configured a specific gemini-3.x model id directly).
|
||||
func mapModel(plexumModel string) string {
|
||||
if plexumModel == "" || plexumModel == "gemini" {
|
||||
return ""
|
||||
}
|
||||
if alias, ok := modelAliases[plexumModel]; ok {
|
||||
return alias
|
||||
}
|
||||
return plexumModel
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := plugin.Serve(&geminiPlugin{}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "plexum-gemini-provider-plugin: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user