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.
This commit is contained in:
161
internal/runner/session_parse.go
Normal file
161
internal/runner/session_parse.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// 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),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user