// 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_______"), 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), }, }, }) } }