Files
Plexum-gemini-provider/internal/runner/session_parse.go
hzhang a63f477463 feat(gemini): plexum-host MCP exposure + real consume mirror
Same architecture as the codex sister change:

1. mcp-host subcommand routes argv[1] to mcpbridge.Run, reading
   PLEXUM_MCP_SOCKET / PLEXUM_MCP_AGENT_ID from gemini-cli's
   `gemini mcp add --env` env baking.

2. EnsureGeminiMCPRegistered handles per-agent stable registration
   under `-s user --trust` so gemini auto-approves the tool surface.

3. Post-turn ParseGeminiToolCalls + EmitGeminiToolCalls scans the
   chat JSONL at ~/.gemini/tmp/<ws>/chats/session-*.jsonl. Pairs
   gemini's nested `toolCalls[].id` with the matching
   `functionResponse.id` from later user lines.

4. MutateGeminiSession rewrites the chat JSONL: toolCalls[].args
   → {} (heavy), functionResponse.response.output → marker.

E2E verified: gemini call exec via plexum-host → dynamic.jsonl
records the call → dynamic-tool-clear consumes → gemini session
mirrored → resume gemini sees consumed marker not the original.
2026-06-01 20:14:13 +01:00

162 lines
3.7 KiB
Go

// session_parse — after gemini exits, scan its chat JSONL for tool
// calls and matching function responses. Same shape across native
// gemini tools and MCP tools — id is gemini's full canonical id
// (e.g. "mcp_<server>_<tool>__<server>_<tool>_<ts>_<n>"), name is
// the namespaced tool name, args is a JSON map.
package runner
import (
"bufio"
"encoding/json"
"errors"
"os"
"git.hangman-lab.top/hzhang/Plexum-sdk-go/canonical"
)
// GeminiToolCall pairs a toolCalls entry with the matching
// functionResponse from a later user line.
type GeminiToolCall struct {
ID string
Name string
Args string // JSON
Output string // functionResponse.response.output
}
// ParseGeminiToolCalls walks the chat JSONL file once and returns
// every (id, name, args, output) tuple in input order. Pairs without
// a matching functionResponse still appear with Output == "".
func ParseGeminiToolCalls(path string) ([]GeminiToolCall, error) {
f, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
defer f.Close()
calls := map[string]*GeminiToolCall{}
var order []string
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var rec map[string]any
if err := json.Unmarshal(line, &rec); err != nil {
continue
}
// Type "gemini" lines carry toolCalls; type "user" lines may
// carry functionResponse objects inside content[].
switch rec["type"] {
case "gemini":
for _, tcAny := range asSlice(rec["toolCalls"]) {
tc, ok := tcAny.(map[string]any)
if !ok {
continue
}
id, _ := tc["id"].(string)
if id == "" {
continue
}
c, ok := calls[id]
if !ok {
c = &GeminiToolCall{ID: id}
calls[id] = c
order = append(order, id)
}
c.Name, _ = tc["name"].(string)
if args, ok := tc["args"]; ok {
if b, err := json.Marshal(args); err == nil {
c.Args = string(b)
}
}
}
case "user":
for _, ctAny := range asSlice(rec["content"]) {
ct, ok := ctAny.(map[string]any)
if !ok {
continue
}
fr, ok := ct["functionResponse"].(map[string]any)
if !ok {
continue
}
id, _ := fr["id"].(string)
if id == "" {
continue
}
c, ok := calls[id]
if !ok {
c = &GeminiToolCall{ID: id}
calls[id] = c
order = append(order, id)
}
if fr["name"] != nil {
c.Name, _ = fr["name"].(string)
}
resp, _ := fr["response"].(map[string]any)
if resp != nil {
if out, ok := resp["output"].(string); ok {
c.Output = out
} else if out, ok := resp["output"]; ok {
if b, err := json.Marshal(out); err == nil {
c.Output = string(b)
}
}
}
}
}
}
if err := sc.Err(); err != nil {
return nil, err
}
out := make([]GeminiToolCall, 0, len(order))
for _, id := range order {
out = append(out, *calls[id])
}
return out, nil
}
func asSlice(v any) []any {
if s, ok := v.([]any); ok {
return s
}
return nil
}
// EmitGeminiToolCalls translates the parsed slice into TurnEvents.
func EmitGeminiToolCalls(calls []GeminiToolCall, emit func(canonical.TurnEvent)) {
for _, c := range calls {
args := c.Args
if args == "" {
args = "{}"
}
emit(canonical.TurnEvent{
Type: canonical.EventToolCallStart,
ToolCallID: c.ID,
ToolName: c.Name,
PartialJSON: args,
})
emit(canonical.TurnEvent{
Type: canonical.EventToolCallEnd,
ToolCallID: c.ID,
})
emit(canonical.TurnEvent{
Type: canonical.EventToolResult,
ToolResult: &canonical.ToolResultBlock{
Type: canonical.BlockTypeToolResult,
ToolUseID: c.ID,
Content: []canonical.Block{
canonical.NewTextBlock(c.Output),
},
},
})
}
}