Files
Plexum-openai-provider/cmd/plexum-openai-provider-plugin/main.go
hzhang a27f6bd067 feat(codex): plexum-host MCP exposure + real consume mirror
Three-part wiring so codex agents get full dynamic-tool consume
support:

1. mcp-host subcommand in the plugin binary (routes argv[1] to
   mcpbridge.Run). Reads PLEXUM_MCP_SOCKET / PLEXUM_MCP_AGENT_ID
   from env, baked into codex's `mcp add --env` registration.

2. EnsureCodexMCPRegistered registers a per-agent stable server
   name (plexum-host-<sanitised>) lazily on first turn. Stable
   per-agent socket path via StableSocketPath so codex's
   registration entry and the per-turn bridge listener agree.

3. Post-turn ParseRolloutToolCalls + EmitCodexToolCalls walks the
   matching rollout-*-<thread_id>.jsonl and emits canonical events
   for every function_call + function_call_output pair using
   codex's REAL call_X ids. dynamic.jsonl now has block_ids that
   match codex's session, so consume mirror has real targets.

4. MutateCodexSession rewrites those response_item entries on
   consume: function_call.arguments → "{}" (heavy), output → marker.
   E2E verified: codex resume sees "...(tool called)" instead of
   the original output.
2026-06-01 20:14:06 +01:00

232 lines
7.6 KiB
Go

// plexum-openai-provider-plugin wraps the local `codex` CLI binary
// (OpenAI Codex CLI ≥ 0.135). Same CLI-driven approach as
// Plexum-gemini-provider — each Plexum turn forks one `codex exec`
// subprocess in the agent's workspace, captures JSONL output, and
// emits canonical TurnEvents.
//
// Auth: codex CLI's own ~/.codex/ state (ChatGPT login or
// OPENAI_API_KEY env). No api_key field in this plugin's config.
//
// Models: by default only "codex" (use whatever the CLI's bound
// account supports — ChatGPT login restricts model selection). If
// running on an API key, operator can add other ids via config.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"git.hangman-lab.top/hzhang/Plexum-sdk-go/canonical"
"git.hangman-lab.top/hzhang/Plexum-sdk-go/mcpbridge"
plugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin"
"git.hangman-lab.top/hzhang/Plexum-openai-provider/internal/runner"
)
const pluginName = "plexum-openai-provider"
// Default model surface. "codex" → no --model flag (CLI default).
// Operator can extend via config.SupportedModels override (e.g.
// "gpt-5", "o3" — only valid on API-key accounts).
var defaultModels = []string{"codex"}
// HostConfig at <profile>/plugins/plexum-openai-provider/config.json.
// All fields optional.
//
// {
// "binary": "codex",
// "extra_args": ["--enable", "some-feature"],
// "supported_models": ["codex","gpt-5"], // override manifest models list
// "model_args": { // map plexum model id → --model value
// "gpt-5": "gpt-5"
// }
// }
type HostConfig struct {
Binary string `json:"binary"`
ExtraArgs []string `json:"extra_args"`
SupportedModels []string `json:"supported_models"`
ModelArgs map[string]string `json:"model_args"`
}
type openaiPlugin struct {
host plugin.HostAPI
cfg HostConfig
// modelsAdvertised is the final list used for both the manifest
// AND admission ("does the agent's model match one we serve?").
modelsAdvertised []string
}
func (p *openaiPlugin) Manifest() plugin.Manifest {
// Manifest is built dynamically per init so an operator's
// supported_models override surfaces correctly.
loaded := p.loadConfigBareForManifest()
models := loaded.SupportedModels
if len(models) == 0 {
models = defaultModels
}
return plugin.Manifest{
Name: pluginName,
Version: "0.1.0",
Activation: plugin.ActivationLazy,
Executable: "plexum-openai-provider-plugin",
Contracts: plugin.Contracts{
Provider: &plugin.ProviderContract{Models: models},
},
}
}
// loadConfigBareForManifest reads the config file once during
// Manifest() (before Init runs) so the advertised model list reflects
// operator overrides without requiring a second restart cycle. Errors
// are silent — Init will surface them.
func (p *openaiPlugin) loadConfigBareForManifest() HostConfig {
profileRoot := os.Getenv("PLEXUM_PROFILE_ROOT")
if profileRoot == "" {
home, _ := os.UserHomeDir()
profileRoot = filepath.Join(home, ".plexum")
}
raw, _ := os.ReadFile(filepath.Join(profileRoot, "plugins", pluginName, "config.json"))
var c HostConfig
_ = json.Unmarshal(raw, &c)
return c
}
func (p *openaiPlugin) 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 = "codex"
}
if len(p.cfg.SupportedModels) > 0 {
p.modelsAdvertised = p.cfg.SupportedModels
} else {
p.modelsAdvertised = defaultModels
}
host.Log("info", "openai (codex) provider initialized", map[string]any{
"binary": p.cfg.Binary,
"extra_args": p.cfg.ExtraArgs,
"models": p.modelsAdvertised,
})
return nil
}
// Stream is the bare ProviderPlugin entrypoint; SDK uses
// StreamWithAgent below when AgentContext is needed.
func (p *openaiPlugin) Stream(ctx context.Context, modelID string, req canonical.TurnRequest) (<-chan canonical.TurnEvent, error) {
return p.StreamWithAgent(ctx, modelID, plugin.AgentContext{}, req)
}
func (p *openaiPlugin) StreamWithAgent(ctx context.Context, modelID string, agent plugin.AgentContext, req canonical.TurnRequest) (<-chan canonical.TurnEvent, error) {
cliModel := p.mapModel(modelID)
return runner.Run(ctx, p.host, agent, cliModel, p.cfg.Binary, p.cfg.ExtraArgs, req)
}
// MutateSession is the session-mirror hook (decision #29x). codex
// rollout JSONL at ~/.codex/sessions/.../rollout-*-<thread_id>.jsonl
// is rewritten line-by-line: function_call_output entries matching
// the request's call_ids get their output replaced with the consume
// marker; heavy ops also stub function_call.arguments to "{}".
//
// Tool-call round-trip is provided by the runner package's
// ParseRolloutToolCalls + EmitCodexToolCalls (which run at end of
// each turn) — they push the codex call_X ids into Plexum's
// dynamic.jsonl, so the BlockMutations the host fires here all
// resolve.
func (p *openaiPlugin) MutateSession(ctx context.Context, req plugin.SessionMutateRequest) error {
if len(req.Mutations) == 0 {
return nil
}
mu := make([]runner.CodexMutation, 0, len(req.Mutations))
for _, m := range req.Mutations {
mu = append(mu, runner.CodexMutation{BlockID: m.BlockID, Op: m.Op})
}
touched, err := runner.MutateCodexSession(req.Workspace, mu)
if err != nil {
p.host.Log("warn", "codex session mutate failed", map[string]any{
"agent": req.AgentID, "err": err.Error(),
})
return err
}
p.host.Log("info", "codex session mutate complete", map[string]any{
"agent": req.AgentID, "touched": touched, "requested": len(req.Mutations),
})
return nil
}
// mapModel resolves the Plexum-advertised model id into the CLI's
// --model value. "" / "codex" → no --model flag (CLI default, only
// thing ChatGPT-account installs support).
func (p *openaiPlugin) mapModel(plexumModel string) string {
if plexumModel == "" || plexumModel == "codex" {
return ""
}
if v, ok := p.cfg.ModelArgs[plexumModel]; ok {
return v
}
// Unknown id — pass through verbatim. Codex will accept or 400.
return plexumModel
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "mcp-host" {
if err := runMCPHost(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "mcp-host: %v\n", err)
os.Exit(1)
}
return
}
if err := plugin.Serve(&openaiPlugin{}); err != nil {
fmt.Fprintf(os.Stderr, "plexum-openai-provider-plugin: %v\n", err)
os.Exit(1)
}
}
// runMCPHost: codex registers our binary's mcp-host subcommand via
// `codex mcp add --env PLEXUM_MCP_SOCKET=...`. The mcp-host process
// reads those env vars + serves stdio MCP to codex while dialing the
// plugin-side unix socket.
func runMCPHost(argv []string) error {
opts := mcpbridge.Opts{
SocketPath: os.Getenv("PLEXUM_MCP_SOCKET"),
AgentID: os.Getenv("PLEXUM_MCP_AGENT_ID"),
}
for i := 0; i < len(argv); i++ {
switch argv[i] {
case "--socket":
if i+1 >= len(argv) {
return errors.New("--socket needs value")
}
opts.SocketPath = argv[i+1]
i++
case "--agent-id":
if i+1 >= len(argv) {
return errors.New("--agent-id needs value")
}
opts.AgentID = argv[i+1]
i++
default:
return fmt.Errorf("unknown mcp-host flag: %s", argv[i])
}
}
return mcpbridge.Run(context.Background(), opts)
}