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.
162 lines
3.7 KiB
Go
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),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
}
|