From dab22b137d7c2213e97b01b6da18ac79aca891d4 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 24 Nov 2025 16:47:40 -0500 Subject: [PATCH 1/7] feat: implement streaming for native tool calls This adds streaming support for native tool calls (OpenAI-style function calling) in both the Roo Code Cloud (roo.ts) and OpenRouter providers. Changes: - Add tool_call_start, tool_call_delta, and tool_call_end events to ApiStreamChunk - Implement streaming tool call tracking in roo.ts and openrouter.ts providers - Add NativeToolCallParser.processStreamingChunk() for incremental JSON parsing - Add NativeToolCallParser.startStreamingToolCall() and finalizeStreamingToolCall() - Use partial-json-parser to extract partial values from incomplete JSON - Handle streaming tool calls in Task.ts by calling tool.handlePartial() - Update BaseTool to handle partial native tool calls The streaming implementation allows the UI to show tool parameters as they stream in, providing the same experience as XML tools during LLM streaming. --- src/api/providers/__tests__/roo.spec.ts | 314 ++++++++++++++++-- src/api/providers/openrouter.ts | 113 +++++-- src/api/providers/roo.ts | 114 +++++-- src/api/transform/stream.ts | 20 ++ .../assistant-message/NativeToolCallParser.ts | 175 ++++++++++ src/core/task/Task.ts | 87 ++++- src/core/tools/ReadFileTool.ts | 7 + src/core/tools/WriteToFileTool.ts | 20 +- 8 files changed, 749 insertions(+), 101 deletions(-) diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index 9dc9aff3db8..6a77d82f0d7 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -636,7 +636,7 @@ describe("RooHandler", () => { handler = new RooHandler(mockOptions) }) - it("should yield tool calls when finish_reason is tool_calls", async () => { + it("should yield streaming tool call chunks when finish_reason is tool_calls", async () => { mockCreate.mockResolvedValueOnce({ [Symbol.asyncIterator]: async function* () { yield { @@ -689,14 +689,24 @@ describe("RooHandler", () => { chunks.push(chunk) } - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(1) - expect(toolCallChunks[0].id).toBe("call_123") - expect(toolCallChunks[0].name).toBe("read_file") - expect(toolCallChunks[0].arguments).toBe('{"path":"test.ts"}') + // Verify we get streaming chunks + const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start") + const deltaChunks = chunks.filter((chunk) => chunk.type === "tool_call_delta") + const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + + expect(startChunks).toHaveLength(1) + expect(startChunks[0].id).toBe("call_123") + expect(startChunks[0].name).toBe("read_file") + + expect(deltaChunks).toHaveLength(2) + expect(deltaChunks[0].delta).toBe('{"path":"') + expect(deltaChunks[1].delta).toBe('test.ts"}') + + expect(endChunks).toHaveLength(1) + expect(endChunks[0].id).toBe("call_123") }) - it("should yield tool calls even when finish_reason is not set (fallback behavior)", async () => { + it("should yield streaming tool calls even when finish_reason is not set (fallback behavior)", async () => { mockCreate.mockResolvedValueOnce({ [Symbol.asyncIterator]: async function* () { yield { @@ -738,15 +748,23 @@ describe("RooHandler", () => { chunks.push(chunk) } - // Tool calls should still be yielded via the fallback mechanism - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(1) - expect(toolCallChunks[0].id).toBe("call_456") - expect(toolCallChunks[0].name).toBe("write_to_file") - expect(toolCallChunks[0].arguments).toBe('{"path":"test.ts","content":"hello"}') + // Tool calls should still be yielded via the fallback mechanism as streaming chunks + const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start") + const deltaChunks = chunks.filter((chunk) => chunk.type === "tool_call_delta") + const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + + expect(startChunks).toHaveLength(1) + expect(startChunks[0].id).toBe("call_456") + expect(startChunks[0].name).toBe("write_to_file") + + expect(deltaChunks).toHaveLength(1) + expect(deltaChunks[0].delta).toBe('{"path":"test.ts","content":"hello"}') + + expect(endChunks).toHaveLength(1) + expect(endChunks[0].id).toBe("call_456") }) - it("should handle multiple tool calls", async () => { + it("should handle multiple streaming tool calls", async () => { mockCreate.mockResolvedValueOnce({ [Symbol.asyncIterator]: async function* () { yield { @@ -800,15 +818,21 @@ describe("RooHandler", () => { chunks.push(chunk) } - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(2) - expect(toolCallChunks[0].id).toBe("call_1") - expect(toolCallChunks[0].name).toBe("read_file") - expect(toolCallChunks[1].id).toBe("call_2") - expect(toolCallChunks[1].name).toBe("read_file") + const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start") + const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + + expect(startChunks).toHaveLength(2) + expect(startChunks[0].id).toBe("call_1") + expect(startChunks[0].name).toBe("read_file") + expect(startChunks[1].id).toBe("call_2") + expect(startChunks[1].name).toBe("read_file") + + expect(endChunks).toHaveLength(2) + expect(endChunks[0].id).toBe("call_1") + expect(endChunks[1].id).toBe("call_2") }) - it("should accumulate tool call arguments across multiple chunks", async () => { + it("should accumulate tool call arguments across multiple streaming chunks", async () => { mockCreate.mockResolvedValueOnce({ [Symbol.asyncIterator]: async function* () { yield { @@ -876,11 +900,21 @@ describe("RooHandler", () => { chunks.push(chunk) } - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(1) - expect(toolCallChunks[0].id).toBe("call_789") - expect(toolCallChunks[0].name).toBe("execute_command") - expect(toolCallChunks[0].arguments).toBe('{"command":"npm install"}') + const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start") + const deltaChunks = chunks.filter((chunk) => chunk.type === "tool_call_delta") + const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + + expect(startChunks).toHaveLength(1) + expect(startChunks[0].id).toBe("call_789") + expect(startChunks[0].name).toBe("execute_command") + + expect(deltaChunks).toHaveLength(3) + expect(deltaChunks[0].delta).toBe('{"command":"') + expect(deltaChunks[1].delta).toBe("npm install") + expect(deltaChunks[2].delta).toBe('"}') + + expect(endChunks).toHaveLength(1) + expect(endChunks[0].id).toBe("call_789") }) it("should not yield empty tool calls when no tool calls present", async () => { @@ -906,4 +940,232 @@ describe("RooHandler", () => { expect(toolCallChunks).toHaveLength(0) }) }) + + describe("streaming tool calls", () => { + beforeEach(() => { + handler = new RooHandler(mockOptions) + }) + + it("should emit tool_call_start, tool_call_delta, and tool_call_end chunks", async () => { + mockCreate.mockResolvedValueOnce({ + [Symbol.asyncIterator]: async function* () { + // First chunk: tool call starts with ID and name + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_streaming_123", + function: { name: "read_file", arguments: "" }, + }, + ], + }, + index: 0, + }, + ], + } + // Second chunk: first part of arguments + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + function: { arguments: '{"files":[{"p' }, + }, + ], + }, + index: 0, + }, + ], + } + // Third chunk: more arguments + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + function: { arguments: 'ath":"test.ts"}]}' }, + }, + ], + }, + index: 0, + }, + ], + } + // Final chunk: finish + yield { + choices: [ + { + delta: {}, + finish_reason: "tool_calls", + index: 0, + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + } + }, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify we get start, delta, and end chunks + const startChunks = chunks.filter((c) => c.type === "tool_call_start") + const deltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + const endChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(startChunks).toHaveLength(1) + expect(startChunks[0]).toEqual({ + type: "tool_call_start", + id: "call_streaming_123", + name: "read_file", + }) + + expect(deltaChunks).toHaveLength(2) + expect(deltaChunks[0]).toEqual({ + type: "tool_call_delta", + id: "call_streaming_123", + delta: '{"files":[{"p', + }) + expect(deltaChunks[1]).toEqual({ + type: "tool_call_delta", + id: "call_streaming_123", + delta: 'ath":"test.ts"}]}', + }) + + expect(endChunks).toHaveLength(1) + expect(endChunks[0]).toEqual({ + type: "tool_call_end", + id: "call_streaming_123", + }) + }) + + it("should handle multiple streaming tool calls", async () => { + mockCreate.mockResolvedValueOnce({ + [Symbol.asyncIterator]: async function* () { + // First tool call starts + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + function: { + name: "read_file", + arguments: '{"files":[{"path":"file1.ts"}]}', + }, + }, + ], + }, + index: 0, + }, + ], + } + // Second tool call starts + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 1, + id: "call_2", + function: { name: "list_files", arguments: '{"path":"src"}' }, + }, + ], + }, + index: 0, + }, + ], + } + // Finish + yield { + choices: [ + { + delta: {}, + finish_reason: "tool_calls", + index: 0, + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + } + }, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const startChunks = chunks.filter((c) => c.type === "tool_call_start") + const endChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(startChunks).toHaveLength(2) + expect(startChunks[0].id).toBe("call_1") + expect(startChunks[0].name).toBe("read_file") + expect(startChunks[1].id).toBe("call_2") + expect(startChunks[1].name).toBe("list_files") + + expect(endChunks).toHaveLength(2) + expect(endChunks[0].id).toBe("call_1") + expect(endChunks[1].id).toBe("call_2") + }) + + it("should emit end chunks even when finish_reason is not tool_calls (fallback)", async () => { + mockCreate.mockResolvedValueOnce({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_fallback", + function: { + name: "read_file", + arguments: '{"files":[{"path":"test.ts"}]}', + }, + }, + ], + }, + index: 0, + }, + ], + } + // Stream ends with different finish_reason + yield { + choices: [{ delta: {}, finish_reason: "stop", index: 0 }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + } + }, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const startChunks = chunks.filter((c) => c.type === "tool_call_start") + const endChunks = chunks.filter((c) => c.type === "tool_call_end") + + // Should still emit start/end chunks via fallback + expect(startChunks).toHaveLength(1) + expect(endChunks).toHaveLength(1) + expect(endChunks[0].id).toBe("call_fallback") + }) + }) }) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index f1ef0b56efb..09e8856f8be 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -248,7 +248,17 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } let lastUsage: CompletionUsage | undefined = undefined - const toolCallAccumulator = new Map() + // Track tool calls by index to emit streaming chunks (similar to roo.ts) + const toolCallTracker = new Map< + number, + { + id: string + name: string + argumentsAccumulator: string + hasStarted: boolean + deltaBuffer: string[] + } + >() // Accumulator for reasoning_details: accumulate text by type-index key const reasoningDetailsAccumulator = new Map< string, @@ -346,24 +356,66 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH yield { type: "reasoning", text: delta.reasoning } } - // Check for tool calls in delta + // Check for tool calls in delta - emit streaming chunks if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { for (const toolCall of delta.tool_calls) { const index = toolCall.index - const existing = toolCallAccumulator.get(index) + let tracked = toolCallTracker.get(index) - if (existing) { - // Accumulate arguments for existing tool call - if (toolCall.function?.arguments) { - existing.arguments += toolCall.function.arguments - } - } else { - // Start new tool call accumulation - toolCallAccumulator.set(index, { - id: toolCall.id || "", + // Initialize new tool call tracking + if (toolCall.id && !tracked) { + tracked = { + id: toolCall.id, name: toolCall.function?.name || "", - arguments: toolCall.function?.arguments || "", - }) + argumentsAccumulator: "", + hasStarted: false, + deltaBuffer: [], + } + toolCallTracker.set(index, tracked) + } + + if (!tracked) continue + + // Update name if present in delta and not yet set + if (toolCall.function?.name) { + tracked.name = toolCall.function.name + } + + // Emit start event when we have the name + if (!tracked.hasStarted && tracked.name) { + yield { + type: "tool_call_start", + id: tracked.id, + name: tracked.name, + } + tracked.hasStarted = true + + // Flush buffered deltas + if (tracked.deltaBuffer.length > 0) { + for (const bufferedDelta of tracked.deltaBuffer) { + yield { + type: "tool_call_delta", + id: tracked.id, + delta: bufferedDelta, + } + } + tracked.deltaBuffer = [] + } + } + + // Emit delta event for argument chunks + if (toolCall.function?.arguments) { + tracked.argumentsAccumulator += toolCall.function.arguments + + if (tracked.hasStarted) { + yield { + type: "tool_call_delta", + id: tracked.id, + delta: toolCall.function.arguments, + } + } else { + tracked.deltaBuffer.push(toolCall.function.arguments) + } } } } @@ -373,18 +425,15 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } } - // When finish_reason is 'tool_calls', yield all accumulated tool calls - if (finishReason === "tool_calls" && toolCallAccumulator.size > 0) { - for (const toolCall of toolCallAccumulator.values()) { + // When finish_reason is 'tool_calls', emit end events + if (finishReason === "tool_calls" && toolCallTracker.size > 0) { + for (const [, tracked] of toolCallTracker.entries()) { yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, + type: "tool_call_end", + id: tracked.id, } } - // Clear accumulator after yielding - toolCallAccumulator.clear() + toolCallTracker.clear() } if (chunk.usage) { @@ -392,18 +441,18 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } } - // Fallback: If stream ends with accumulated tool calls that weren't yielded + // Fallback: If stream ends with tracked tool calls that weren't finalized // (e.g., finish_reason was 'stop' or 'length' instead of 'tool_calls') - if (toolCallAccumulator.size > 0) { - for (const toolCall of toolCallAccumulator.values()) { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, + if (toolCallTracker.size > 0) { + for (const [, tracked] of toolCallTracker.entries()) { + if (tracked.hasStarted) { + yield { + type: "tool_call_end", + id: tracked.id, + } } } - toolCallAccumulator.clear() + toolCallTracker.clear() } // After streaming completes, store the accumulated reasoning_details diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index 393740d3bd4..303e121be11 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -126,8 +126,17 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { ) let lastUsage: RooUsage | undefined = undefined - // Accumulate tool calls by index - similar to how reasoning accumulates - const toolCallAccumulator = new Map() + // Track tool calls by index to emit streaming chunks + const toolCallTracker = new Map< + number, + { + id: string + name: string + argumentsAccumulator: string + hasStarted: boolean + deltaBuffer: string[] + } + >() for await (const chunk of stream) { const delta = chunk.choices[0]?.delta @@ -150,24 +159,66 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } } - // Check for tool calls in delta + // Check for tool calls in delta - emit streaming chunks if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { for (const toolCall of delta.tool_calls) { const index = toolCall.index - const existing = toolCallAccumulator.get(index) + let tracked = toolCallTracker.get(index) - if (existing) { - // Accumulate arguments for existing tool call - if (toolCall.function?.arguments) { - existing.arguments += toolCall.function.arguments - } - } else { - // Start new tool call accumulation - toolCallAccumulator.set(index, { - id: toolCall.id || "", + // Initialize new tool call tracking + if (toolCall.id && !tracked) { + tracked = { + id: toolCall.id, name: toolCall.function?.name || "", - arguments: toolCall.function?.arguments || "", - }) + argumentsAccumulator: "", + hasStarted: false, + deltaBuffer: [], + } + toolCallTracker.set(index, tracked) + } + + if (!tracked) continue + + // Update name if present in delta and not yet set + if (toolCall.function?.name) { + tracked.name = toolCall.function.name + } + + // Emit start event when we have the name + if (!tracked.hasStarted && tracked.name) { + yield { + type: "tool_call_start", + id: tracked.id, + name: tracked.name, + } + tracked.hasStarted = true + + // Flush buffered deltas + if (tracked.deltaBuffer.length > 0) { + for (const delta of tracked.deltaBuffer) { + yield { + type: "tool_call_delta", + id: tracked.id, + delta, + } + } + tracked.deltaBuffer = [] + } + } + + // Emit delta event for argument chunks + if (toolCall.function?.arguments) { + tracked.argumentsAccumulator += toolCall.function.arguments + + if (tracked.hasStarted) { + yield { + type: "tool_call_delta", + id: tracked.id, + delta: toolCall.function.arguments, + } + } else { + tracked.deltaBuffer.push(toolCall.function.arguments) + } } } } @@ -180,18 +231,15 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } } - // When finish_reason is 'tool_calls', yield all accumulated tool calls - if (finishReason === "tool_calls" && toolCallAccumulator.size > 0) { - for (const [index, toolCall] of toolCallAccumulator.entries()) { + // When finish_reason is 'tool_calls', emit end events + if (finishReason === "tool_calls" && toolCallTracker.size > 0) { + for (const [, tracked] of toolCallTracker.entries()) { yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, + type: "tool_call_end", + id: tracked.id, } } - // Clear accumulator after yielding - toolCallAccumulator.clear() + toolCallTracker.clear() } if (chunk.usage) { @@ -199,18 +247,18 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } } - // Fallback: If stream ends with accumulated tool calls that weren't yielded + // Fallback: If stream ends with tracked tool calls that weren't finalized // (e.g., finish_reason was 'stop' or 'length' instead of 'tool_calls') - if (toolCallAccumulator.size > 0) { - for (const [index, toolCall] of toolCallAccumulator.entries()) { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, + if (toolCallTracker.size > 0) { + for (const [, tracked] of toolCallTracker.entries()) { + if (tracked.hasStarted) { + yield { + type: "tool_call_end", + id: tracked.id, + } } } - toolCallAccumulator.clear() + toolCallTracker.clear() } if (lastUsage) { diff --git a/src/api/transform/stream.ts b/src/api/transform/stream.ts index cd6c3a56a72..13abd2a75ba 100644 --- a/src/api/transform/stream.ts +++ b/src/api/transform/stream.ts @@ -6,6 +6,9 @@ export type ApiStreamChunk = | ApiStreamReasoningChunk | ApiStreamGroundingChunk | ApiStreamToolCallChunk + | ApiStreamToolCallStartChunk + | ApiStreamToolCallDeltaChunk + | ApiStreamToolCallEndChunk | ApiStreamError export interface ApiStreamError { @@ -46,6 +49,23 @@ export interface ApiStreamToolCallChunk { arguments: string } +export interface ApiStreamToolCallStartChunk { + type: "tool_call_start" + id: string + name: string +} + +export interface ApiStreamToolCallDeltaChunk { + type: "tool_call_delta" + id: string + delta: string +} + +export interface ApiStreamToolCallEndChunk { + type: "tool_call_end" + id: string +} + export interface GroundingSource { title: string url: string diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index c463d4a5cd0..5eadeac47f8 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -1,5 +1,6 @@ import { type ToolName, toolNames, type FileEntry } from "@roo-code/types" import { type ToolUse, type ToolParamName, toolParamNames, type NativeToolArgs } from "../../shared/tools" +import { parseJSON } from "partial-json" /** * Helper type to extract properly typed native arguments for a given tool. @@ -17,6 +18,180 @@ type NativeArgsFor = TName extends keyof NativeToolArgs * nativeArgs directly rather than relying on synthesized legacy params. */ export class NativeToolCallParser { + // Streaming state management + private static streamingToolCalls = new Map< + string, + { + id: string + name: ToolName + argumentsAccumulator: string + } + >() + + /** + * Start streaming a new tool call. + * Initializes tracking for incremental argument parsing. + */ + public static startStreamingToolCall(id: string, name: ToolName): void { + this.streamingToolCalls.set(id, { + id, + name, + argumentsAccumulator: "", + }) + } + + /** + * Process a chunk of JSON arguments for a streaming tool call. + * Uses partial-json-parser to extract values from incomplete JSON immediately. + * Returns a partial ToolUse with currently parsed parameters. + */ + public static processStreamingChunk(id: string, chunk: string): ToolUse | null { + const toolCall = this.streamingToolCalls.get(id) + if (!toolCall) { + console.warn(`[NativeToolCallParser] Received chunk for unknown tool call: ${id}`) + return null + } + + // Accumulate the JSON string + toolCall.argumentsAccumulator += chunk + + // Parse whatever we can from the incomplete JSON! + // partial-json-parser extracts partial values (strings, arrays, objects) immediately + try { + const partialArgs = parseJSON(toolCall.argumentsAccumulator) + + // Create partial ToolUse with extracted values + return this.createPartialToolUse( + toolCall.id, + toolCall.name, + partialArgs || {}, + true, // partial + ) + } catch { + // Even partial-json-parser can fail on severely malformed JSON + // Return null and wait for next chunk + return null + } + } + + /** + * Finalize a streaming tool call. + * Parses the complete JSON and returns the final ToolUse. + */ + public static finalizeStreamingToolCall(id: string): ToolUse | null { + const toolCall = this.streamingToolCalls.get(id) + if (!toolCall) { + console.warn(`[NativeToolCallParser] Attempting to finalize unknown tool call: ${id}`) + return null + } + + // Parse the complete accumulated JSON + const finalToolUse = this.parseToolCall({ + id: toolCall.id, + name: toolCall.name, + arguments: toolCall.argumentsAccumulator, + }) + + // Clean up streaming state + this.streamingToolCalls.delete(id) + + return finalToolUse + } + + /** + * Create a partial ToolUse from currently parsed arguments. + * Used during streaming to show progress. + */ + private static createPartialToolUse( + id: string, + name: ToolName, + partialArgs: Record, + partial: boolean, + ): ToolUse | null { + // Build legacy params for display + // NOTE: For streaming partial updates, we MUST populate params even for complex types + // because tool.handlePartial() methods rely on params to show UI updates + const params: Partial> = {} + + for (const [key, value] of Object.entries(partialArgs)) { + if (toolParamNames.includes(key as ToolParamName)) { + params[key as ToolParamName] = typeof value === "string" ? value : JSON.stringify(value) + } + } + + // Build partial nativeArgs based on what we have so far + let nativeArgs: any = undefined + + switch (name) { + case "read_file": + if (partialArgs.files && Array.isArray(partialArgs.files)) { + nativeArgs = { files: partialArgs.files } + } + break + + case "attempt_completion": + if (partialArgs.result) { + nativeArgs = { result: partialArgs.result } + } + break + + case "execute_command": + if (partialArgs.command) { + nativeArgs = { + command: partialArgs.command, + cwd: partialArgs.cwd, + } + } + break + + case "insert_content": + if ( + partialArgs.path !== undefined || + partialArgs.line !== undefined || + partialArgs.content !== undefined + ) { + nativeArgs = { + path: partialArgs.path, + line: + typeof partialArgs.line === "number" + ? partialArgs.line + : partialArgs.line + ? parseInt(String(partialArgs.line), 10) + : undefined, + content: partialArgs.content, + } + } + break + + case "write_to_file": + if (partialArgs.path || partialArgs.content || partialArgs.line_count !== undefined) { + nativeArgs = { + path: partialArgs.path, + content: partialArgs.content, + line_count: + typeof partialArgs.line_count === "number" + ? partialArgs.line_count + : partialArgs.line_count + ? parseInt(String(partialArgs.line_count), 10) + : undefined, + } + } + break + + // Add other tools as needed + default: + break + } + + return { + type: "tool_use" as const, + name, + params, + partial, + nativeArgs, + } + } + /** * Convert a native tool call chunk to a ToolUse object. * diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 6c17e201210..68a3517d1d0 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -63,7 +63,7 @@ import { ClineApiReqCancelReason, ClineApiReqInfo } from "../../shared/Extension import { getApiMetrics, hasTokenUsageChanged } from "../../shared/getApiMetrics" import { ClineAskResponse } from "../../shared/WebviewMessage" import { defaultModeSlug, getModeBySlug, getGroupName } from "../../shared/modes" -import { DiffStrategy, type ToolUse } from "../../shared/tools" +import { DiffStrategy, type ToolUse, type ToolParamName, toolParamNames } from "../../shared/tools" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { getModelMaxOutputTokens } from "../../shared/api" @@ -306,6 +306,9 @@ export class Task extends EventEmitter implements TaskLike { assistantMessageParser?: AssistantMessageParser private providerProfileChangeListener?: (config: { name: string; provider?: string }) => void + // Native tool call streaming state (track which index each tool is at) + private streamingToolCallIndices: Map = new Map() + // Cached model info for current streaming session (set at start of each API request) // This prevents excessive getModel() calls during tool execution cachedStreamingModel?: { id: string; info: ModelInfo } @@ -2249,6 +2252,7 @@ export class Task extends EventEmitter implements TaskLike { this.presentAssistantMessageLocked = false this.presentAssistantMessageHasPendingUpdates = false this.assistantMessageParser?.reset() + this.streamingToolCallIndices.clear() await this.diffViewProvider.reset() @@ -2336,7 +2340,88 @@ export class Task extends EventEmitter implements TaskLike { pendingGroundingSources.push(...chunk.sources) } break + case "tool_call_start": { + // Initialize streaming in NativeToolCallParser + NativeToolCallParser.startStreamingToolCall(chunk.id, chunk.name as ToolName) + + // Before adding a new tool, finalize any preceding text block + // This prevents the text block from blocking tool presentation + const lastBlock = this.assistantMessageContent[this.assistantMessageContent.length - 1] + if (lastBlock?.type === "text" && lastBlock.partial) { + lastBlock.partial = false + } + + // Track the index where this tool will be stored + const toolUseIndex = this.assistantMessageContent.length + this.streamingToolCallIndices.set(chunk.id, toolUseIndex) + + // Create initial partial tool use + const partialToolUse: ToolUse = { + type: "tool_use", + name: chunk.name as ToolName, + params: {}, + partial: true, + } + + // Store the ID for native protocol + ;(partialToolUse as any).id = chunk.id + + // Add to content and present + this.assistantMessageContent.push(partialToolUse) + this.userMessageContentReady = false + presentAssistantMessage(this) + break + } + + case "tool_call_delta": { + // Process chunk using streaming JSON parser + const partialToolUse = NativeToolCallParser.processStreamingChunk(chunk.id, chunk.delta) + + if (partialToolUse) { + // Get the index for this tool call + const toolUseIndex = this.streamingToolCallIndices.get(chunk.id) + if (toolUseIndex !== undefined) { + // Store the ID for native protocol + ;(partialToolUse as any).id = chunk.id + + // Update the existing tool use with new partial data + this.assistantMessageContent[toolUseIndex] = partialToolUse + + // Present updated tool use + presentAssistantMessage(this) + } + } + break + } + + case "tool_call_end": { + // Finalize the streaming tool call + const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall(chunk.id) + + if (finalToolUse) { + // Store the tool call ID + ;(finalToolUse as any).id = chunk.id + + // Get the index and replace partial with final + const toolUseIndex = this.streamingToolCallIndices.get(chunk.id) + if (toolUseIndex !== undefined) { + this.assistantMessageContent[toolUseIndex] = finalToolUse + } + + // Clean up tracking + this.streamingToolCallIndices.delete(chunk.id) + + // Mark that we have new content to process + this.userMessageContentReady = false + + // Present the finalized tool call + presentAssistantMessage(this) + } + break + } + case "tool_call": { + // Legacy: Handle complete tool calls (for backward compatibility) // Convert native tool call to ToolUse format const toolUse = NativeToolCallParser.parseToolCall({ id: chunk.id, diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index d6989c103ef..9dff9502bd7 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -719,6 +719,13 @@ export class ReadFileTool extends BaseTool<"read_file"> { filePath = legacyPath } + if (!filePath && block.nativeArgs && "files" in block.nativeArgs && Array.isArray(block.nativeArgs.files)) { + const files = block.nativeArgs.files + if (files.length > 0 && files[0]?.path) { + filePath = files[0].path + } + } + const fullPath = filePath ? path.resolve(task.cwd, filePath) : "" const sharedMessageProps: ClineSayTool = { tool: "readFile", diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 4c355beb073..310d26b117d 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -290,7 +290,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const relPath: string | undefined = block.params.path let newContent: string | undefined = block.params.content - if (!relPath || newContent === undefined) { + if (!relPath) { return } @@ -321,7 +321,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const sharedMessageProps: ClineSayTool = { tool: fileExists ? "editedExistingFile" : "newFileCreated", path: getReadablePath(task.cwd, relPath), - content: newContent, + content: newContent || "", isOutsideWorkspace, isProtected: isWriteProtected, } @@ -329,14 +329,16 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const partialMessage = JSON.stringify(sharedMessageProps) await task.ask("tool", partialMessage, block.partial).catch(() => {}) - if (!task.diffViewProvider.isEditing) { - await task.diffViewProvider.open(relPath) - } + if (newContent) { + if (!task.diffViewProvider.isEditing) { + await task.diffViewProvider.open(relPath) + } - await task.diffViewProvider.update( - everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, - false, - ) + await task.diffViewProvider.update( + everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, + false, + ) + } } } From 67d8b8d33b6c9f3bf5ee7912373a6674218510bf Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 24 Nov 2025 16:57:36 -0500 Subject: [PATCH 2/7] chore: add partial-json dependency for streaming tool calls --- pnpm-lock.yaml | 12 ++++++++++-- src/package.json | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8476240608..971fcd4cc2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -753,6 +753,9 @@ importers: p-wait-for: specifier: ^5.0.2 version: 5.0.2 + partial-json: + specifier: ^0.1.7 + version: 0.1.7 pdf-parse: specifier: ^1.1.1 version: 1.1.1 @@ -8113,6 +8116,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -14048,7 +14054,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -17019,7 +17025,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.18.2 + ws: 8.18.3 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -18482,6 +18488,8 @@ snapshots: parseurl@1.3.3: {} + partial-json@0.1.7: {} + path-data-parser@0.1.0: {} path-exists@4.0.0: {} diff --git a/src/package.json b/src/package.json index fab82fa7caf..b22c206a61d 100644 --- a/src/package.json +++ b/src/package.json @@ -501,6 +501,7 @@ "os-name": "^6.0.0", "p-limit": "^6.2.0", "p-wait-for": "^5.0.2", + "partial-json": "^0.1.7", "pdf-parse": "^1.1.1", "pkce-challenge": "^5.0.0", "pretty-bytes": "^7.0.0", From 88c8930320f25e8bd217b66acd9d5547e9c97ca6 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 24 Nov 2025 17:40:56 -0500 Subject: [PATCH 3/7] fix: address PR review comments for streaming tool calls - Add clearAllStreamingToolCalls() to prevent memory leak when streams are interrupted - Add hasActiveStreamingToolCalls() for debugging/testing - Add comment clarifying the intentional difference between partial and complete validation logic for insert_content (partial uses OR to show progress incrementally) - Call clearAllStreamingToolCalls() in Task.ts when resetting streaming state --- .../assistant-message/NativeToolCallParser.ts | 22 ++++++++++++++++++- src/core/task/Task.ts | 2 ++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 5eadeac47f8..52d0c0de483 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -40,6 +40,23 @@ export class NativeToolCallParser { }) } + /** + * Clear all streaming tool call state. + * Should be called when a new API request starts to prevent memory leaks + * from interrupted streams. + */ + public static clearAllStreamingToolCalls(): void { + this.streamingToolCalls.clear() + } + + /** + * Check if there are any active streaming tool calls. + * Useful for debugging and testing. + */ + public static hasActiveStreamingToolCalls(): boolean { + return this.streamingToolCalls.size > 0 + } + /** * Process a chunk of JSON arguments for a streaming tool call. * Uses partial-json-parser to extract values from incomplete JSON immediately. @@ -145,6 +162,9 @@ export class NativeToolCallParser { break case "insert_content": + // For partial tool calls, we build nativeArgs incrementally as fields arrive. + // Unlike parseToolCall which validates all required fields, partial parsing + // needs to show progress as each field streams in. if ( partialArgs.path !== undefined || partialArgs.line !== undefined || @@ -155,7 +175,7 @@ export class NativeToolCallParser { line: typeof partialArgs.line === "number" ? partialArgs.line - : partialArgs.line + : partialArgs.line !== undefined ? parseInt(String(partialArgs.line), 10) : undefined, content: partialArgs.content, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 68a3517d1d0..d38edbe2805 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2253,6 +2253,8 @@ export class Task extends EventEmitter implements TaskLike { this.presentAssistantMessageHasPendingUpdates = false this.assistantMessageParser?.reset() this.streamingToolCallIndices.clear() + // Clear any leftover streaming tool call state from previous interrupted streams + NativeToolCallParser.clearAllStreamingToolCalls() await this.diffViewProvider.reset() From 31fc77c9dee8fcd41fcee0d0afc94df582a44ca3 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 24 Nov 2025 20:21:27 -0500 Subject: [PATCH 4/7] refactor: remove unused argumentsAccumulator from tool call tracking --- src/api/providers/openrouter.ts | 4 ---- src/api/providers/roo.ts | 4 ---- 2 files changed, 8 deletions(-) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 09e8856f8be..4cfed791029 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -254,7 +254,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH { id: string name: string - argumentsAccumulator: string hasStarted: boolean deltaBuffer: string[] } @@ -367,7 +366,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH tracked = { id: toolCall.id, name: toolCall.function?.name || "", - argumentsAccumulator: "", hasStarted: false, deltaBuffer: [], } @@ -405,8 +403,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH // Emit delta event for argument chunks if (toolCall.function?.arguments) { - tracked.argumentsAccumulator += toolCall.function.arguments - if (tracked.hasStarted) { yield { type: "tool_call_delta", diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index 303e121be11..cf52d9302f1 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -132,7 +132,6 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { { id: string name: string - argumentsAccumulator: string hasStarted: boolean deltaBuffer: string[] } @@ -170,7 +169,6 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { tracked = { id: toolCall.id, name: toolCall.function?.name || "", - argumentsAccumulator: "", hasStarted: false, deltaBuffer: [], } @@ -208,8 +206,6 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { // Emit delta event for argument chunks if (toolCall.function?.arguments) { - tracked.argumentsAccumulator += toolCall.function.arguments - if (tracked.hasStarted) { yield { type: "tool_call_delta", From 20c97d353a354e3d4cbfe7f1ba348ec9a86fc03e Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 24 Nov 2025 21:06:59 -0500 Subject: [PATCH 5/7] feat: refactor tool call handling to emit raw chunks and simplify state management --- src/api/providers/__tests__/roo.spec.ts | 334 +++--------------- src/api/providers/openrouter.ts | 97 +---- src/api/providers/roo.ts | 98 +---- src/api/transform/stream.ts | 14 + .../assistant-message/NativeToolCallParser.ts | 159 ++++++++- src/core/task/Task.ts | 152 ++++---- 6 files changed, 313 insertions(+), 541 deletions(-) diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index 6a77d82f0d7..ee8de4ee86e 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -636,7 +636,7 @@ describe("RooHandler", () => { handler = new RooHandler(mockOptions) }) - it("should yield streaming tool call chunks when finish_reason is tool_calls", async () => { + it("should yield raw tool call chunks when tool_calls present", async () => { mockCreate.mockResolvedValueOnce({ [Symbol.asyncIterator]: async function* () { yield { @@ -689,24 +689,27 @@ describe("RooHandler", () => { chunks.push(chunk) } - // Verify we get streaming chunks - const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start") - const deltaChunks = chunks.filter((chunk) => chunk.type === "tool_call_delta") - const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + // Verify we get raw tool call chunks + const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_raw") - expect(startChunks).toHaveLength(1) - expect(startChunks[0].id).toBe("call_123") - expect(startChunks[0].name).toBe("read_file") - - expect(deltaChunks).toHaveLength(2) - expect(deltaChunks[0].delta).toBe('{"path":"') - expect(deltaChunks[1].delta).toBe('test.ts"}') - - expect(endChunks).toHaveLength(1) - expect(endChunks[0].id).toBe("call_123") + expect(rawChunks).toHaveLength(2) + expect(rawChunks[0]).toEqual({ + type: "tool_call_raw", + index: 0, + id: "call_123", + name: "read_file", + arguments: '{"path":"', + }) + expect(rawChunks[1]).toEqual({ + type: "tool_call_raw", + index: 0, + id: undefined, + name: undefined, + arguments: 'test.ts"}', + }) }) - it("should yield streaming tool calls even when finish_reason is not set (fallback behavior)", async () => { + it("should yield raw tool call chunks even when finish_reason is not tool_calls", async () => { mockCreate.mockResolvedValueOnce({ [Symbol.asyncIterator]: async function* () { yield { @@ -728,12 +731,11 @@ describe("RooHandler", () => { }, ], } - // Stream ends without finish_reason being set to "tool_calls" yield { choices: [ { delta: {}, - finish_reason: "stop", // Different finish reason + finish_reason: "stop", index: 0, }, ], @@ -748,23 +750,19 @@ describe("RooHandler", () => { chunks.push(chunk) } - // Tool calls should still be yielded via the fallback mechanism as streaming chunks - const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start") - const deltaChunks = chunks.filter((chunk) => chunk.type === "tool_call_delta") - const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") - - expect(startChunks).toHaveLength(1) - expect(startChunks[0].id).toBe("call_456") - expect(startChunks[0].name).toBe("write_to_file") + const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_raw") - expect(deltaChunks).toHaveLength(1) - expect(deltaChunks[0].delta).toBe('{"path":"test.ts","content":"hello"}') - - expect(endChunks).toHaveLength(1) - expect(endChunks[0].id).toBe("call_456") + expect(rawChunks).toHaveLength(1) + expect(rawChunks[0]).toEqual({ + type: "tool_call_raw", + index: 0, + id: "call_456", + name: "write_to_file", + arguments: '{"path":"test.ts","content":"hello"}', + }) }) - it("should handle multiple streaming tool calls", async () => { + it("should handle multiple tool calls with different indices", async () => { mockCreate.mockResolvedValueOnce({ [Symbol.asyncIterator]: async function* () { yield { @@ -818,21 +816,16 @@ describe("RooHandler", () => { chunks.push(chunk) } - const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start") - const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") - - expect(startChunks).toHaveLength(2) - expect(startChunks[0].id).toBe("call_1") - expect(startChunks[0].name).toBe("read_file") - expect(startChunks[1].id).toBe("call_2") - expect(startChunks[1].name).toBe("read_file") + const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_raw") - expect(endChunks).toHaveLength(2) - expect(endChunks[0].id).toBe("call_1") - expect(endChunks[1].id).toBe("call_2") + expect(rawChunks).toHaveLength(2) + expect(rawChunks[0].index).toBe(0) + expect(rawChunks[0].id).toBe("call_1") + expect(rawChunks[1].index).toBe(1) + expect(rawChunks[1].id).toBe("call_2") }) - it("should accumulate tool call arguments across multiple streaming chunks", async () => { + it("should emit raw chunks for streaming arguments", async () => { mockCreate.mockResolvedValueOnce({ [Symbol.asyncIterator]: async function* () { yield { @@ -900,24 +893,15 @@ describe("RooHandler", () => { chunks.push(chunk) } - const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start") - const deltaChunks = chunks.filter((chunk) => chunk.type === "tool_call_delta") - const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_raw") - expect(startChunks).toHaveLength(1) - expect(startChunks[0].id).toBe("call_789") - expect(startChunks[0].name).toBe("execute_command") - - expect(deltaChunks).toHaveLength(3) - expect(deltaChunks[0].delta).toBe('{"command":"') - expect(deltaChunks[1].delta).toBe("npm install") - expect(deltaChunks[2].delta).toBe('"}') - - expect(endChunks).toHaveLength(1) - expect(endChunks[0].id).toBe("call_789") + expect(rawChunks).toHaveLength(3) + expect(rawChunks[0].arguments).toBe('{"command":"') + expect(rawChunks[1].arguments).toBe("npm install") + expect(rawChunks[2].arguments).toBe('"}') }) - it("should not yield empty tool calls when no tool calls present", async () => { + it("should not yield tool call chunks when no tool calls present", async () => { mockCreate.mockResolvedValueOnce({ [Symbol.asyncIterator]: async function* () { yield { @@ -936,236 +920,8 @@ describe("RooHandler", () => { chunks.push(chunk) } - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(0) - }) - }) - - describe("streaming tool calls", () => { - beforeEach(() => { - handler = new RooHandler(mockOptions) - }) - - it("should emit tool_call_start, tool_call_delta, and tool_call_end chunks", async () => { - mockCreate.mockResolvedValueOnce({ - [Symbol.asyncIterator]: async function* () { - // First chunk: tool call starts with ID and name - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_streaming_123", - function: { name: "read_file", arguments: "" }, - }, - ], - }, - index: 0, - }, - ], - } - // Second chunk: first part of arguments - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: '{"files":[{"p' }, - }, - ], - }, - index: 0, - }, - ], - } - // Third chunk: more arguments - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: 'ath":"test.ts"}]}' }, - }, - ], - }, - index: 0, - }, - ], - } - // Final chunk: finish - yield { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - index: 0, - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - } - }, - }) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Verify we get start, delta, and end chunks - const startChunks = chunks.filter((c) => c.type === "tool_call_start") - const deltaChunks = chunks.filter((c) => c.type === "tool_call_delta") - const endChunks = chunks.filter((c) => c.type === "tool_call_end") - - expect(startChunks).toHaveLength(1) - expect(startChunks[0]).toEqual({ - type: "tool_call_start", - id: "call_streaming_123", - name: "read_file", - }) - - expect(deltaChunks).toHaveLength(2) - expect(deltaChunks[0]).toEqual({ - type: "tool_call_delta", - id: "call_streaming_123", - delta: '{"files":[{"p', - }) - expect(deltaChunks[1]).toEqual({ - type: "tool_call_delta", - id: "call_streaming_123", - delta: 'ath":"test.ts"}]}', - }) - - expect(endChunks).toHaveLength(1) - expect(endChunks[0]).toEqual({ - type: "tool_call_end", - id: "call_streaming_123", - }) - }) - - it("should handle multiple streaming tool calls", async () => { - mockCreate.mockResolvedValueOnce({ - [Symbol.asyncIterator]: async function* () { - // First tool call starts - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_1", - function: { - name: "read_file", - arguments: '{"files":[{"path":"file1.ts"}]}', - }, - }, - ], - }, - index: 0, - }, - ], - } - // Second tool call starts - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 1, - id: "call_2", - function: { name: "list_files", arguments: '{"path":"src"}' }, - }, - ], - }, - index: 0, - }, - ], - } - // Finish - yield { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - index: 0, - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - } - }, - }) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const startChunks = chunks.filter((c) => c.type === "tool_call_start") - const endChunks = chunks.filter((c) => c.type === "tool_call_end") - - expect(startChunks).toHaveLength(2) - expect(startChunks[0].id).toBe("call_1") - expect(startChunks[0].name).toBe("read_file") - expect(startChunks[1].id).toBe("call_2") - expect(startChunks[1].name).toBe("list_files") - - expect(endChunks).toHaveLength(2) - expect(endChunks[0].id).toBe("call_1") - expect(endChunks[1].id).toBe("call_2") - }) - - it("should emit end chunks even when finish_reason is not tool_calls (fallback)", async () => { - mockCreate.mockResolvedValueOnce({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_fallback", - function: { - name: "read_file", - arguments: '{"files":[{"path":"test.ts"}]}', - }, - }, - ], - }, - index: 0, - }, - ], - } - // Stream ends with different finish_reason - yield { - choices: [{ delta: {}, finish_reason: "stop", index: 0 }], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - } - }, - }) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const startChunks = chunks.filter((c) => c.type === "tool_call_start") - const endChunks = chunks.filter((c) => c.type === "tool_call_end") - - // Should still emit start/end chunks via fallback - expect(startChunks).toHaveLength(1) - expect(endChunks).toHaveLength(1) - expect(endChunks[0].id).toBe("call_fallback") + const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_raw") + expect(rawChunks).toHaveLength(0) }) }) }) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 4cfed791029..1ea0abd9160 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -248,16 +248,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } let lastUsage: CompletionUsage | undefined = undefined - // Track tool calls by index to emit streaming chunks (similar to roo.ts) - const toolCallTracker = new Map< - number, - { - id: string - name: string - hasStarted: boolean - deltaBuffer: string[] - } - >() // Accumulator for reasoning_details: accumulate text by type-index key const reasoningDetailsAccumulator = new Map< string, @@ -355,63 +345,15 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH yield { type: "reasoning", text: delta.reasoning } } - // Check for tool calls in delta - emit streaming chunks + // Emit raw tool call chunks - NativeToolCallParser handles state management if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { for (const toolCall of delta.tool_calls) { - const index = toolCall.index - let tracked = toolCallTracker.get(index) - - // Initialize new tool call tracking - if (toolCall.id && !tracked) { - tracked = { - id: toolCall.id, - name: toolCall.function?.name || "", - hasStarted: false, - deltaBuffer: [], - } - toolCallTracker.set(index, tracked) - } - - if (!tracked) continue - - // Update name if present in delta and not yet set - if (toolCall.function?.name) { - tracked.name = toolCall.function.name - } - - // Emit start event when we have the name - if (!tracked.hasStarted && tracked.name) { - yield { - type: "tool_call_start", - id: tracked.id, - name: tracked.name, - } - tracked.hasStarted = true - - // Flush buffered deltas - if (tracked.deltaBuffer.length > 0) { - for (const bufferedDelta of tracked.deltaBuffer) { - yield { - type: "tool_call_delta", - id: tracked.id, - delta: bufferedDelta, - } - } - tracked.deltaBuffer = [] - } - } - - // Emit delta event for argument chunks - if (toolCall.function?.arguments) { - if (tracked.hasStarted) { - yield { - type: "tool_call_delta", - id: tracked.id, - delta: toolCall.function.arguments, - } - } else { - tracked.deltaBuffer.push(toolCall.function.arguments) - } + yield { + type: "tool_call_raw", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, } } } @@ -421,36 +363,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } } - // When finish_reason is 'tool_calls', emit end events - if (finishReason === "tool_calls" && toolCallTracker.size > 0) { - for (const [, tracked] of toolCallTracker.entries()) { - yield { - type: "tool_call_end", - id: tracked.id, - } - } - toolCallTracker.clear() - } - if (chunk.usage) { lastUsage = chunk.usage } } - // Fallback: If stream ends with tracked tool calls that weren't finalized - // (e.g., finish_reason was 'stop' or 'length' instead of 'tool_calls') - if (toolCallTracker.size > 0) { - for (const [, tracked] of toolCallTracker.entries()) { - if (tracked.hasStarted) { - yield { - type: "tool_call_end", - id: tracked.id, - } - } - } - toolCallTracker.clear() - } - // After streaming completes, store the accumulated reasoning_details if (reasoningDetailsAccumulator.size > 0) { this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values()) diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index cf52d9302f1..9dbceb6697a 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -126,20 +126,9 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { ) let lastUsage: RooUsage | undefined = undefined - // Track tool calls by index to emit streaming chunks - const toolCallTracker = new Map< - number, - { - id: string - name: string - hasStarted: boolean - deltaBuffer: string[] - } - >() for await (const chunk of stream) { const delta = chunk.choices[0]?.delta - const finishReason = chunk.choices[0]?.finish_reason if (delta) { // Check for reasoning content (similar to OpenRouter) @@ -158,63 +147,15 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } } - // Check for tool calls in delta - emit streaming chunks + // Emit raw tool call chunks - NativeToolCallParser handles state management if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { for (const toolCall of delta.tool_calls) { - const index = toolCall.index - let tracked = toolCallTracker.get(index) - - // Initialize new tool call tracking - if (toolCall.id && !tracked) { - tracked = { - id: toolCall.id, - name: toolCall.function?.name || "", - hasStarted: false, - deltaBuffer: [], - } - toolCallTracker.set(index, tracked) - } - - if (!tracked) continue - - // Update name if present in delta and not yet set - if (toolCall.function?.name) { - tracked.name = toolCall.function.name - } - - // Emit start event when we have the name - if (!tracked.hasStarted && tracked.name) { - yield { - type: "tool_call_start", - id: tracked.id, - name: tracked.name, - } - tracked.hasStarted = true - - // Flush buffered deltas - if (tracked.deltaBuffer.length > 0) { - for (const delta of tracked.deltaBuffer) { - yield { - type: "tool_call_delta", - id: tracked.id, - delta, - } - } - tracked.deltaBuffer = [] - } - } - - // Emit delta event for argument chunks - if (toolCall.function?.arguments) { - if (tracked.hasStarted) { - yield { - type: "tool_call_delta", - id: tracked.id, - delta: toolCall.function.arguments, - } - } else { - tracked.deltaBuffer.push(toolCall.function.arguments) - } + yield { + type: "tool_call_raw", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, } } } @@ -227,36 +168,11 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } } - // When finish_reason is 'tool_calls', emit end events - if (finishReason === "tool_calls" && toolCallTracker.size > 0) { - for (const [, tracked] of toolCallTracker.entries()) { - yield { - type: "tool_call_end", - id: tracked.id, - } - } - toolCallTracker.clear() - } - if (chunk.usage) { lastUsage = chunk.usage as RooUsage } } - // Fallback: If stream ends with tracked tool calls that weren't finalized - // (e.g., finish_reason was 'stop' or 'length' instead of 'tool_calls') - if (toolCallTracker.size > 0) { - for (const [, tracked] of toolCallTracker.entries()) { - if (tracked.hasStarted) { - yield { - type: "tool_call_end", - id: tracked.id, - } - } - } - toolCallTracker.clear() - } - if (lastUsage) { // Check if the current model is marked as free const model = this.getModel() diff --git a/src/api/transform/stream.ts b/src/api/transform/stream.ts index 13abd2a75ba..8f98c2ac1fd 100644 --- a/src/api/transform/stream.ts +++ b/src/api/transform/stream.ts @@ -9,6 +9,7 @@ export type ApiStreamChunk = | ApiStreamToolCallStartChunk | ApiStreamToolCallDeltaChunk | ApiStreamToolCallEndChunk + | ApiStreamToolCallRawChunk | ApiStreamError export interface ApiStreamError { @@ -66,6 +67,19 @@ export interface ApiStreamToolCallEndChunk { id: string } +/** + * Raw tool call chunk from the API stream. + * Providers emit this simple format; NativeToolCallParser handles all state management + * (tracking, buffering, emitting start/delta/end events). + */ +export interface ApiStreamToolCallRawChunk { + type: "tool_call_raw" + index: number + id?: string + name?: string + arguments?: string +} + export interface GroundingSource { title: string url: string diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 52d0c0de483..aa950d674cf 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -1,6 +1,11 @@ import { type ToolName, toolNames, type FileEntry } from "@roo-code/types" import { type ToolUse, type ToolParamName, toolParamNames, type NativeToolArgs } from "../../shared/tools" import { parseJSON } from "partial-json" +import type { + ApiStreamToolCallStartChunk, + ApiStreamToolCallDeltaChunk, + ApiStreamToolCallEndChunk, +} from "../../api/transform/stream" /** * Helper type to extract properly typed native arguments for a given tool. @@ -17,8 +22,25 @@ type NativeArgsFor = TName extends keyof NativeToolArgs * typed arguments via nativeArgs. Tool-specific handlers should consume * nativeArgs directly rather than relying on synthesized legacy params. */ +/** + * Event types returned from raw chunk processing. + */ +export type ToolCallStreamEvent = ApiStreamToolCallStartChunk | ApiStreamToolCallDeltaChunk | ApiStreamToolCallEndChunk + +/** + * Parser for native tool calls (OpenAI-style function calling). + * Converts native tool call format to ToolUse format for compatibility + * with existing tool execution infrastructure. + * + * For tools with refactored parsers (e.g., read_file), this parser provides + * typed arguments via nativeArgs. Tool-specific handlers should consume + * nativeArgs directly rather than relying on synthesized legacy params. + * + * This class also handles raw tool call chunk processing, converting + * provider-level raw chunks into start/delta/end events. + */ export class NativeToolCallParser { - // Streaming state management + // Streaming state management for argument accumulation (keyed by tool call id) private static streamingToolCalls = new Map< string, { @@ -28,6 +50,141 @@ export class NativeToolCallParser { } >() + // Raw chunk tracking state (keyed by index from API stream) + private static rawChunkTracker = new Map< + number, + { + id: string + name: string + hasStarted: boolean + deltaBuffer: string[] + } + >() + + /** + * Process a raw tool call chunk from the API stream. + * Handles tracking, buffering, and emits start/delta/end events. + * + * This is the entry point for providers that emit tool_call_raw chunks. + * Returns an array of events to be processed by the consumer. + */ + public static processRawChunk(chunk: { + index: number + id?: string + name?: string + arguments?: string + }): ToolCallStreamEvent[] { + const events: ToolCallStreamEvent[] = [] + const { index, id, name, arguments: args } = chunk + + let tracked = this.rawChunkTracker.get(index) + + // Initialize new tool call tracking when we receive an id + if (id && !tracked) { + tracked = { + id, + name: name || "", + hasStarted: false, + deltaBuffer: [], + } + this.rawChunkTracker.set(index, tracked) + } + + if (!tracked) { + return events + } + + // Update name if present in chunk and not yet set + if (name) { + tracked.name = name + } + + // Emit start event when we have the name + if (!tracked.hasStarted && tracked.name) { + events.push({ + type: "tool_call_start", + id: tracked.id, + name: tracked.name, + }) + tracked.hasStarted = true + + // Flush buffered deltas + for (const bufferedDelta of tracked.deltaBuffer) { + events.push({ + type: "tool_call_delta", + id: tracked.id, + delta: bufferedDelta, + }) + } + tracked.deltaBuffer = [] + } + + // Emit delta event for argument chunks + if (args) { + if (tracked.hasStarted) { + events.push({ + type: "tool_call_delta", + id: tracked.id, + delta: args, + }) + } else { + tracked.deltaBuffer.push(args) + } + } + + return events + } + + /** + * Process stream finish reason. + * Emits end events when finish_reason is 'tool_calls'. + */ + public static processFinishReason(finishReason: string | null | undefined): ToolCallStreamEvent[] { + const events: ToolCallStreamEvent[] = [] + + if (finishReason === "tool_calls" && this.rawChunkTracker.size > 0) { + for (const [, tracked] of this.rawChunkTracker.entries()) { + events.push({ + type: "tool_call_end", + id: tracked.id, + }) + } + this.rawChunkTracker.clear() + } + + return events + } + + /** + * Finalize any remaining tool calls that weren't explicitly ended. + * Should be called at the end of stream processing. + */ + public static finalizeRawChunks(): ToolCallStreamEvent[] { + const events: ToolCallStreamEvent[] = [] + + if (this.rawChunkTracker.size > 0) { + for (const [, tracked] of this.rawChunkTracker.entries()) { + if (tracked.hasStarted) { + events.push({ + type: "tool_call_end", + id: tracked.id, + }) + } + } + this.rawChunkTracker.clear() + } + + return events + } + + /** + * Clear all raw chunk tracking state. + * Should be called when a new API request starts. + */ + public static clearRawChunkState(): void { + this.rawChunkTracker.clear() + } + /** * Start streaming a new tool call. * Initializes tracking for incremental argument parsing. diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d38edbe2805..77fa63c1dcd 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2255,6 +2255,7 @@ export class Task extends EventEmitter implements TaskLike { this.streamingToolCallIndices.clear() // Clear any leftover streaming tool call state from previous interrupted streams NativeToolCallParser.clearAllStreamingToolCalls() + NativeToolCallParser.clearRawChunkState() await this.diffViewProvider.reset() @@ -2342,86 +2343,97 @@ export class Task extends EventEmitter implements TaskLike { pendingGroundingSources.push(...chunk.sources) } break - case "tool_call_start": { - // Initialize streaming in NativeToolCallParser - NativeToolCallParser.startStreamingToolCall(chunk.id, chunk.name as ToolName) - - // Before adding a new tool, finalize any preceding text block - // This prevents the text block from blocking tool presentation - const lastBlock = this.assistantMessageContent[this.assistantMessageContent.length - 1] - if (lastBlock?.type === "text" && lastBlock.partial) { - lastBlock.partial = false - } - - // Track the index where this tool will be stored - const toolUseIndex = this.assistantMessageContent.length - this.streamingToolCallIndices.set(chunk.id, toolUseIndex) - - // Create initial partial tool use - const partialToolUse: ToolUse = { - type: "tool_use", - name: chunk.name as ToolName, - params: {}, - partial: true, - } - - // Store the ID for native protocol - ;(partialToolUse as any).id = chunk.id - - // Add to content and present - this.assistantMessageContent.push(partialToolUse) - this.userMessageContentReady = false - presentAssistantMessage(this) - break - } + case "tool_call_raw": { + // Process raw tool call chunk through NativeToolCallParser + // which handles tracking, buffering, and emits events + const events = NativeToolCallParser.processRawChunk({ + index: chunk.index, + id: chunk.id, + name: chunk.name, + arguments: chunk.arguments, + }) - case "tool_call_delta": { - // Process chunk using streaming JSON parser - const partialToolUse = NativeToolCallParser.processStreamingChunk(chunk.id, chunk.delta) + for (const event of events) { + if (event.type === "tool_call_start") { + // Initialize streaming in NativeToolCallParser + NativeToolCallParser.startStreamingToolCall(event.id, event.name as ToolName) + + // Before adding a new tool, finalize any preceding text block + // This prevents the text block from blocking tool presentation + const lastBlock = + this.assistantMessageContent[this.assistantMessageContent.length - 1] + if (lastBlock?.type === "text" && lastBlock.partial) { + lastBlock.partial = false + } + + // Track the index where this tool will be stored + const toolUseIndex = this.assistantMessageContent.length + this.streamingToolCallIndices.set(event.id, toolUseIndex) + + // Create initial partial tool use + const partialToolUse: ToolUse = { + type: "tool_use", + name: event.name as ToolName, + params: {}, + partial: true, + } - if (partialToolUse) { - // Get the index for this tool call - const toolUseIndex = this.streamingToolCallIndices.get(chunk.id) - if (toolUseIndex !== undefined) { // Store the ID for native protocol - ;(partialToolUse as any).id = chunk.id - - // Update the existing tool use with new partial data - this.assistantMessageContent[toolUseIndex] = partialToolUse + ;(partialToolUse as any).id = event.id - // Present updated tool use + // Add to content and present + this.assistantMessageContent.push(partialToolUse) + this.userMessageContentReady = false presentAssistantMessage(this) + } else if (event.type === "tool_call_delta") { + // Process chunk using streaming JSON parser + const partialToolUse = NativeToolCallParser.processStreamingChunk( + event.id, + event.delta, + ) + + if (partialToolUse) { + // Get the index for this tool call + const toolUseIndex = this.streamingToolCallIndices.get(event.id) + if (toolUseIndex !== undefined) { + // Store the ID for native protocol + ;(partialToolUse as any).id = event.id + + // Update the existing tool use with new partial data + this.assistantMessageContent[toolUseIndex] = partialToolUse + + // Present updated tool use + presentAssistantMessage(this) + } + } + } else if (event.type === "tool_call_end") { + // Finalize the streaming tool call + const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall(event.id) + + if (finalToolUse) { + // Store the tool call ID + ;(finalToolUse as any).id = event.id + + // Get the index and replace partial with final + const toolUseIndex = this.streamingToolCallIndices.get(event.id) + if (toolUseIndex !== undefined) { + this.assistantMessageContent[toolUseIndex] = finalToolUse + } + + // Clean up tracking + this.streamingToolCallIndices.delete(event.id) + + // Mark that we have new content to process + this.userMessageContentReady = false + + // Present the finalized tool call + presentAssistantMessage(this) + } } } break } - case "tool_call_end": { - // Finalize the streaming tool call - const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall(chunk.id) - - if (finalToolUse) { - // Store the tool call ID - ;(finalToolUse as any).id = chunk.id - - // Get the index and replace partial with final - const toolUseIndex = this.streamingToolCallIndices.get(chunk.id) - if (toolUseIndex !== undefined) { - this.assistantMessageContent[toolUseIndex] = finalToolUse - } - - // Clean up tracking - this.streamingToolCallIndices.delete(chunk.id) - - // Mark that we have new content to process - this.userMessageContentReady = false - - // Present the finalized tool call - presentAssistantMessage(this) - } - break - } - case "tool_call": { // Legacy: Handle complete tool calls (for backward compatibility) // Convert native tool call to ToolUse format From 690d7453bd9f5c91a14a19446b4dbbfa777a0a2b Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 24 Nov 2025 22:02:28 -0500 Subject: [PATCH 6/7] feat: update tool call handling to emit partial chunks for consistent processing --- src/api/providers/__tests__/roo.spec.ts | 16 ++-- .../base-openai-compatible-provider.ts | 45 +-------- src/api/providers/gemini.ts | 23 ++++- src/api/providers/minimax.ts | 44 +++------ src/api/providers/openai-native.ts | 62 +++---------- src/api/providers/openai.ts | 92 +++---------------- src/api/providers/openrouter.ts | 2 +- src/api/providers/roo.ts | 2 +- src/api/transform/stream.ts | 6 +- .../assistant-message/NativeToolCallParser.ts | 2 +- src/core/task/Task.ts | 2 +- 11 files changed, 81 insertions(+), 215 deletions(-) diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index ee8de4ee86e..458fa228636 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -690,18 +690,18 @@ describe("RooHandler", () => { } // Verify we get raw tool call chunks - const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_raw") + const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") expect(rawChunks).toHaveLength(2) expect(rawChunks[0]).toEqual({ - type: "tool_call_raw", + type: "tool_call_partial", index: 0, id: "call_123", name: "read_file", arguments: '{"path":"', }) expect(rawChunks[1]).toEqual({ - type: "tool_call_raw", + type: "tool_call_partial", index: 0, id: undefined, name: undefined, @@ -750,11 +750,11 @@ describe("RooHandler", () => { chunks.push(chunk) } - const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_raw") + const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") expect(rawChunks).toHaveLength(1) expect(rawChunks[0]).toEqual({ - type: "tool_call_raw", + type: "tool_call_partial", index: 0, id: "call_456", name: "write_to_file", @@ -816,7 +816,7 @@ describe("RooHandler", () => { chunks.push(chunk) } - const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_raw") + const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") expect(rawChunks).toHaveLength(2) expect(rawChunks[0].index).toBe(0) @@ -893,7 +893,7 @@ describe("RooHandler", () => { chunks.push(chunk) } - const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_raw") + const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") expect(rawChunks).toHaveLength(3) expect(rawChunks[0].arguments).toBe('{"command":"') @@ -920,7 +920,7 @@ describe("RooHandler", () => { chunks.push(chunk) } - const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_raw") + const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") expect(rawChunks).toHaveLength(0) }) }) diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 3d78ef75d16..2c183b7fc8d 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -123,8 +123,6 @@ export abstract class BaseOpenAiCompatibleProvider }) as const, ) - const toolCallAccumulator = new Map() - let lastUsage: OpenAI.CompletionUsage | undefined for await (const chunk of stream) { @@ -137,7 +135,6 @@ export abstract class BaseOpenAiCompatibleProvider } const delta = chunk.choices?.[0]?.delta - const finishReason = chunk.choices?.[0]?.finish_reason if (delta?.content) { for (const processedChunk of matcher.update(delta.content)) { @@ -157,35 +154,17 @@ export abstract class BaseOpenAiCompatibleProvider } } + // Emit raw tool call chunks - NativeToolCallParser handles state management if (delta?.tool_calls) { for (const toolCall of delta.tool_calls) { - const index = toolCall.index - const existing = toolCallAccumulator.get(index) - - if (existing) { - if (toolCall.function?.arguments) { - existing.arguments += toolCall.function.arguments - } - } else { - toolCallAccumulator.set(index, { - id: toolCall.id || "", - name: toolCall.function?.name || "", - arguments: toolCall.function?.arguments || "", - }) - } - } - } - - if (finishReason === "tool_calls") { - for (const toolCall of toolCallAccumulator.values()) { yield { - type: "tool_call", + type: "tool_call_partial", + index: toolCall.index, id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, } } - toolCallAccumulator.clear() } if (chunk.usage) { @@ -193,20 +172,6 @@ export abstract class BaseOpenAiCompatibleProvider } } - // Fallback: If stream ends with accumulated tool calls that weren't yielded - // (e.g., finish_reason was 'stop' or 'length' instead of 'tool_calls') - if (toolCallAccumulator.size > 0) { - for (const toolCall of toolCallAccumulator.values()) { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - } - } - toolCallAccumulator.clear() - } - if (lastUsage) { yield this.processUsageMetrics(lastUsage, this.getModel().info) } diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 89c816e8157..545b7f7f17d 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -236,13 +236,30 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl yield { type: "reasoning", text: part.text } } } else if (part.functionCall) { - const callId = `${part.functionCall.name}-${toolCallCounter++}` + // Gemini sends complete function calls in a single chunk + // Emit as partial chunks for consistent handling with NativeToolCallParser + const callId = `${part.functionCall.name}-${toolCallCounter}` + const args = JSON.stringify(part.functionCall.args) + + // Emit name first yield { - type: "tool_call", + type: "tool_call_partial", + index: toolCallCounter, id: callId, name: part.functionCall.name, - arguments: JSON.stringify(part.functionCall.args), + arguments: undefined, + } + + // Then emit arguments + yield { + type: "tool_call_partial", + index: toolCallCounter, + id: callId, + name: undefined, + arguments: args, } + + toolCallCounter++ } else { // This is regular content if (part.text) { diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index 03767644c66..023a9780074 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -120,9 +120,6 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand let cacheWriteTokens = 0 let cacheReadTokens = 0 - // Track tool calls being accumulated via streaming - const toolCallAccumulator = new Map() - for await (const chunk of stream) { switch (chunk.type) { case "message_start": { @@ -180,16 +177,14 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand yield { type: "text", text: chunk.content_block.text } break case "tool_use": { - // Tool use block started - store initial data - // If input is empty ({}), start with empty string as deltas will build it - // Otherwise, stringify the initial input as a base for potential deltas - const initialInput = chunk.content_block.input || {} - const hasInitialContent = Object.keys(initialInput).length > 0 - toolCallAccumulator.set(chunk.index, { + // Emit initial tool call partial with id and name + yield { + type: "tool_call_partial", + index: chunk.index, id: chunk.content_block.id, name: chunk.content_block.name, - input: hasInitialContent ? JSON.stringify(initialInput) : "", - }) + arguments: undefined, + } break } } @@ -203,31 +198,22 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand yield { type: "text", text: chunk.delta.text } break case "input_json_delta": { - // Accumulate tool input JSON as it streams - const existingToolCall = toolCallAccumulator.get(chunk.index) - if (existingToolCall) { - existingToolCall.input += chunk.delta.partial_json + // Emit tool call partial chunks as arguments stream in + yield { + type: "tool_call_partial", + index: chunk.index, + id: undefined, + name: undefined, + arguments: chunk.delta.partial_json, } break } } break - case "content_block_stop": { - // Block is complete - yield tool call if this was a tool_use block - const completedToolCall = toolCallAccumulator.get(chunk.index) - if (completedToolCall) { - yield { - type: "tool_call", - id: completedToolCall.id, - name: completedToolCall.name, - arguments: completedToolCall.input, - } - // Remove from accumulator after yielding - toolCallAccumulator.delete(chunk.index) - } + case "content_block_stop": + // Block is complete - no action needed, NativeToolCallParser handles completion break - } } } diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 74ba621c43d..34829d0810f 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -34,8 +34,6 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio private lastResponseOutput: any[] | undefined // Last top-level response id from Responses API (for troubleshooting) private lastResponseId: string | undefined - // Accumulate partial tool calls: call_id -> { name, arguments } - private currentToolCalls: Map = new Map() // Abort controller for cancelling ongoing requests private abortController?: AbortController @@ -153,8 +151,6 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio this.lastResponseOutput = undefined // Reset last response id for this request this.lastResponseId = undefined - // Reset tool call accumulator - this.currentToolCalls.clear() // Use Responses API for ALL models const { verbosity, reasoning } = this.getModel() @@ -1070,48 +1066,32 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio return } - // Handle tool/function call deltas and completion + // Handle tool/function call deltas - emit as partial chunks if ( event?.type === "response.tool_call_arguments.delta" || event?.type === "response.function_call_arguments.delta" ) { + // Emit partial chunks directly - NativeToolCallParser handles state management const callId = event.call_id || event.tool_call_id || event.id - if (callId) { - if (!this.currentToolCalls.has(callId)) { - this.currentToolCalls.set(callId, { name: "", arguments: "" }) - } - const toolCall = this.currentToolCalls.get(callId)! - - // Update name if present (usually in the first delta) - if (event.name || event.function_name) { - toolCall.name = event.name || event.function_name - } - - // Append arguments delta - if (event.delta || event.arguments) { - toolCall.arguments += event.delta || event.arguments - } + const name = event.name || event.function_name + const args = event.delta || event.arguments + + yield { + type: "tool_call_partial", + index: event.index ?? 0, + id: callId, + name, + arguments: args, } return } + // Handle tool/function call completion events if ( event?.type === "response.tool_call_arguments.done" || event?.type === "response.function_call_arguments.done" ) { - const callId = event.call_id || event.tool_call_id || event.id - if (callId && this.currentToolCalls.has(callId)) { - const toolCall = this.currentToolCalls.get(callId)! - // Yield the complete tool call - yield { - type: "tool_call", - id: callId, - name: toolCall.name, - arguments: toolCall.arguments, - } - // Remove from accumulator - this.currentToolCalls.delete(callId) - } + // Tool call complete - no action needed, NativeToolCallParser handles completion return } @@ -1135,8 +1115,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio event.type === "response.output_item.done" // Only handle done events for tool calls to ensure arguments are complete ) { // Handle complete tool/function call item + // Emit as tool_call for backward compatibility with non-streaming tool handling const callId = item.call_id || item.tool_call_id || item.id - if (callId && !this.currentToolCalls.has(callId)) { + if (callId) { const args = item.arguments || item.function?.arguments || item.function_arguments yield { type: "tool_call", @@ -1152,19 +1133,6 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Completion events that may carry usage if (event?.type === "response.done" || event?.type === "response.completed") { - // Yield any pending tool calls that didn't get a 'done' event (fallback) - if (this.currentToolCalls.size > 0) { - for (const [callId, toolCall] of this.currentToolCalls) { - yield { - type: "tool_call", - id: callId, - name: toolCall.name, - arguments: toolCall.arguments || "{}", - } - } - this.currentToolCalls.clear() - } - const usage = event?.response?.usage || event?.usage || undefined const usageData = this.normalizeUsage(usage, model) if (usageData) { diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 1c8d3c7d9d1..e9109b0d7f2 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -191,11 +191,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ) let lastUsage - const toolCallAccumulator = new Map() for await (const chunk of stream) { const delta = chunk.choices?.[0]?.delta ?? {} - const finishReason = chunk.choices?.[0]?.finish_reason if (delta.content) { for (const chunk of matcher.update(delta.content)) { @@ -212,33 +210,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl if (delta.tool_calls) { for (const toolCall of delta.tool_calls) { - const index = toolCall.index - const existing = toolCallAccumulator.get(index) - - if (existing) { - if (toolCall.function?.arguments) { - existing.arguments += toolCall.function.arguments - } - } else { - toolCallAccumulator.set(index, { - id: toolCall.id || "", - name: toolCall.function?.name || "", - arguments: toolCall.function?.arguments || "", - }) - } - } - } - - if (finishReason === "tool_calls") { - for (const toolCall of toolCallAccumulator.values()) { yield { - type: "tool_call", + type: "tool_call_partial", + index: toolCall.index, id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, } } - toolCallAccumulator.clear() } if (chunk.usage) { @@ -246,20 +225,6 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } - // Fallback: If stream ends with accumulated tool calls that weren't yielded - // (e.g., finish_reason was 'stop' or 'length' instead of 'tool_calls') - if (toolCallAccumulator.size > 0) { - for (const toolCall of toolCallAccumulator.values()) { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - } - } - toolCallAccumulator.clear() - } - for (const chunk of matcher.final()) { yield chunk } @@ -466,11 +431,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } private async *handleStreamResponse(stream: AsyncIterable): ApiStream { - const toolCallAccumulator = new Map() - for await (const chunk of stream) { const delta = chunk.choices?.[0]?.delta - const finishReason = chunk.choices?.[0]?.finish_reason if (delta) { if (delta.content) { @@ -480,38 +442,20 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } + // Emit raw tool call chunks - NativeToolCallParser handles state management if (delta.tool_calls) { for (const toolCall of delta.tool_calls) { - const index = toolCall.index - const existing = toolCallAccumulator.get(index) - - if (existing) { - if (toolCall.function?.arguments) { - existing.arguments += toolCall.function.arguments - } - } else { - toolCallAccumulator.set(index, { - id: toolCall.id || "", - name: toolCall.function?.name || "", - arguments: toolCall.function?.arguments || "", - }) + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, } } } } - if (finishReason === "tool_calls") { - for (const toolCall of toolCallAccumulator.values()) { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - } - } - toolCallAccumulator.clear() - } - if (chunk.usage) { yield { type: "usage", @@ -520,20 +464,6 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } } - - // Fallback: If stream ends with accumulated tool calls that weren't yielded - // (e.g., finish_reason was 'stop' or 'length' instead of 'tool_calls') - if (toolCallAccumulator.size > 0) { - for (const toolCall of toolCallAccumulator.values()) { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.name, - arguments: toolCall.arguments, - } - } - toolCallAccumulator.clear() - } } private _getUrlHost(baseUrl?: string): string { diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 1ea0abd9160..34329af16c6 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -349,7 +349,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { for (const toolCall of delta.tool_calls) { yield { - type: "tool_call_raw", + type: "tool_call_partial", index: toolCall.index, id: toolCall.id, name: toolCall.function?.name, diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index 9dbceb6697a..ce643e27cee 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -151,7 +151,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { for (const toolCall of delta.tool_calls) { yield { - type: "tool_call_raw", + type: "tool_call_partial", index: toolCall.index, id: toolCall.id, name: toolCall.function?.name, diff --git a/src/api/transform/stream.ts b/src/api/transform/stream.ts index 8f98c2ac1fd..a4a0fe4a9a7 100644 --- a/src/api/transform/stream.ts +++ b/src/api/transform/stream.ts @@ -9,7 +9,7 @@ export type ApiStreamChunk = | ApiStreamToolCallStartChunk | ApiStreamToolCallDeltaChunk | ApiStreamToolCallEndChunk - | ApiStreamToolCallRawChunk + | ApiStreamToolCallPartialChunk | ApiStreamError export interface ApiStreamError { @@ -72,8 +72,8 @@ export interface ApiStreamToolCallEndChunk { * Providers emit this simple format; NativeToolCallParser handles all state management * (tracking, buffering, emitting start/delta/end events). */ -export interface ApiStreamToolCallRawChunk { - type: "tool_call_raw" +export interface ApiStreamToolCallPartialChunk { + type: "tool_call_partial" index: number id?: string name?: string diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index aa950d674cf..c27ecbcc72a 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -65,7 +65,7 @@ export class NativeToolCallParser { * Process a raw tool call chunk from the API stream. * Handles tracking, buffering, and emits start/delta/end events. * - * This is the entry point for providers that emit tool_call_raw chunks. + * This is the entry point for providers that emit tool_call_partial chunks. * Returns an array of events to be processed by the consumer. */ public static processRawChunk(chunk: { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 77fa63c1dcd..f7b0adc28ab 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2343,7 +2343,7 @@ export class Task extends EventEmitter implements TaskLike { pendingGroundingSources.push(...chunk.sources) } break - case "tool_call_raw": { + case "tool_call_partial": { // Process raw tool call chunk through NativeToolCallParser // which handles tracking, buffering, and emits events const events = NativeToolCallParser.processRawChunk({ From 5a5ff9375a63b1917ae547127720c554ad867009 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 24 Nov 2025 22:26:08 -0500 Subject: [PATCH 7/7] fix: update tests to expect tool_call_partial chunks for streaming The providers now yield tool_call_partial chunks during streaming, and the NativeToolCallParser is responsible for reassembling them into complete tool_call chunks. Updated tests to match this new behavior. --- src/api/providers/__tests__/minimax.spec.ts | 6 +- src/api/providers/__tests__/openai.spec.ts | 67 +++++++++++++++------ 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index 4a993286ea5..45058fb4ffc 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -372,11 +372,13 @@ describe("MiniMaxHandler", () => { const firstChunk = await stream.next() expect(firstChunk.done).toBe(false) + // Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly expect(firstChunk.value).toEqual({ - type: "tool_call", + type: "tool_call_partial", + index: 0, id: "tool-123", name: "get_weather", - arguments: JSON.stringify({ city: "London" }), + arguments: undefined, }) }) }) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 05b3e405f2e..31fdaa23899 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -269,13 +269,31 @@ describe("OpenAiHandler", () => { chunks.push(chunk) } - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(1) - expect(toolCallChunks[0]).toEqual({ - type: "tool_call", + // Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly + const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + expect(toolCallPartialChunks).toHaveLength(3) + // First chunk has id and name + expect(toolCallPartialChunks[0]).toEqual({ + type: "tool_call_partial", + index: 0, id: "call_1", name: "test_tool", - arguments: '{"arg":"value"}', + arguments: "", + }) + // Subsequent chunks have arguments + expect(toolCallPartialChunks[1]).toEqual({ + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: '{"arg":', + }) + expect(toolCallPartialChunks[2]).toEqual({ + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: '"value"}', }) }) @@ -318,11 +336,12 @@ describe("OpenAiHandler", () => { chunks.push(chunk) } - // Tool calls should still be yielded via the fallback mechanism - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(1) - expect(toolCallChunks[0]).toEqual({ - type: "tool_call", + // Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly + const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + expect(toolCallPartialChunks).toHaveLength(1) + expect(toolCallPartialChunks[0]).toEqual({ + type: "tool_call_partial", + index: 0, id: "call_fallback", name: "fallback_tool", arguments: '{"test":"fallback"}', @@ -819,12 +838,21 @@ describe("OpenAiHandler", () => { chunks.push(chunk) } - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(1) - expect(toolCallChunks[0]).toEqual({ - type: "tool_call", + // Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly + const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + expect(toolCallPartialChunks).toHaveLength(2) + expect(toolCallPartialChunks[0]).toEqual({ + type: "tool_call_partial", + index: 0, id: "call_1", name: "test_tool", + arguments: "", + }) + expect(toolCallPartialChunks[1]).toEqual({ + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, arguments: "{}", }) }) @@ -870,11 +898,12 @@ describe("OpenAiHandler", () => { chunks.push(chunk) } - // Tool calls should still be yielded via the fallback mechanism - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(1) - expect(toolCallChunks[0]).toEqual({ - type: "tool_call", + // Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly + const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + expect(toolCallPartialChunks).toHaveLength(1) + expect(toolCallPartialChunks[0]).toEqual({ + type: "tool_call_partial", + index: 0, id: "call_o3_fallback", name: "o3_fallback_tool", arguments: '{"o3":"test"}',