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.
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
||||
"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"
|
||||
@@ -140,29 +141,33 @@ func (p *openaiPlugin) StreamWithAgent(ctx context.Context, modelID string, agen
|
||||
}
|
||||
|
||||
// MutateSession is the session-mirror hook (decision #29x). codex
|
||||
// stores transcripts at ~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*
|
||||
// -<thread_id>.jsonl as response_item lines whose payloads carry
|
||||
// function_call / function_call_output entries keyed by `call_id`
|
||||
// (not toolu_*) — so a Plexum tool_use_id like "toolu_X" won't ever
|
||||
// match in there.
|
||||
// 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 "{}".
|
||||
//
|
||||
// v1 ships as a logged no-op: we resolve the file path so operator
|
||||
// log can confirm wiring, but we don't rewrite. When Plexum's tool
|
||||
// dispatch eventually routes through codex's native tool surface
|
||||
// (preserving call_id round-trip), the rewrite logic plugs in here.
|
||||
// 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 {
|
||||
path, err := runner.FindCodexSessionFile(req.Workspace)
|
||||
if err != nil {
|
||||
p.host.Log("debug", "codex session mutate: find failed", map[string]any{
|
||||
"agent": req.AgentID, "err": err.Error(),
|
||||
})
|
||||
if len(req.Mutations) == 0 {
|
||||
return nil
|
||||
}
|
||||
p.host.Log("info", "codex session mutate (no-op v1)", map[string]any{
|
||||
"agent": req.AgentID,
|
||||
"session_path": path,
|
||||
"requested": len(req.Mutations),
|
||||
"reason": "codex uses call_* ids; tool-call round-trip not yet wired",
|
||||
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
|
||||
}
|
||||
@@ -182,8 +187,45 @@ func (p *openaiPlugin) mapModel(plexumModel string) string {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user