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:
h z
2026-06-01 20:14:06 +01:00
parent d075fc408a
commit a27f6bd067
5 changed files with 488 additions and 30 deletions

View File

@@ -32,6 +32,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"
)
@@ -62,6 +63,28 @@ func Run(
return nil, errors.New("codex: no user text in request messages")
}
// Per-turn MCP bridge so codex can call Plexum host tools the
// host advertised in req.Tools. Best-effort: setup failure logs
// + continues; codex still runs with its built-in tools.
var br *mcpbridge.Bridge
if len(req.Tools) > 0 {
exe, err := os.Executable()
if err == nil {
if _, regErr := EnsureCodexMCPRegistered(ctx, binary, exe, agent.AgentID); regErr != nil {
host.Log("warn", "codex: mcp registration failed", map[string]any{"err": regErr.Error()})
} else {
// Open the listener on the stable per-agent socket
// path; codex's mcp-host subprocess will dial it.
b, err := mcpbridge.SetupOnPath(ctx, host, agent.AgentID, req.Tools, StableSocketPath(agent.AgentID))
if err != nil {
host.Log("warn", "codex: bridge setup failed", map[string]any{"err": err.Error()})
} else {
br = b
}
}
}
}
resumeID := loadSessionID(workspace)
// Build the argv. `codex exec [resume <id>]` then flags then prompt.
@@ -105,7 +128,10 @@ func Run(
}
out := make(chan canonical.TurnEvent, 16)
go pumpEvents(ctx, host, cmd, stdout, stderr, workspace, out)
go func() {
pumpEvents(ctx, host, cmd, stdout, stderr, workspace, out)
br.Close() // nil-safe
}()
return out, nil
}
@@ -249,6 +275,23 @@ func pumpEvents(
if hadError || werr != nil {
stopReason = canonical.StopReason("error")
}
// Read the session rollout once codex has finished and replay
// every recorded function_call / function_call_output pair as
// tool_call_start/end + EventToolResult — with codex's REAL
// call_X ids. The agentic loop attaches these to the iteration
// so dynamic-* consume can target the same ids SessionMutator
// later mirrors into the session JSONL.
if sessionPath, perr := FindCodexSessionFile(workspace); perr == nil {
calls, err := ParseRolloutToolCalls(sessionPath)
if err != nil {
host.Log("warn", "codex: parse rollout failed",
map[string]any{"err": err.Error(), "path": sessionPath})
} else {
EmitCodexToolCalls(calls, emit)
host.Log("debug", "codex: emitted tool calls from rollout",
map[string]any{"count": len(calls), "path": sessionPath})
}
}
emit(canonical.TurnEvent{
Type: canonical.EventMessageEnd, StopReason: stopReason, Usage: &usage,
})