feat: implement streaming for native tool calls (#9542)

This commit is contained in:
Daniel 2025-11-24 23:12:39 -05:00 committed by GitHub
parent cad6145241
commit 0327f12751
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 695 additions and 355 deletions

12
pnpm-lock.yaml generated
View file

@ -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: {}

View file

@ -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,
})
})
})

View file

@ -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"}',

View file

@ -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 raw tool call chunks when tool_calls present", async () => {
mockCreate.mockResolvedValueOnce({
[Symbol.asyncIterator]: async function* () {
yield {
@ -689,14 +689,27 @@ 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 raw tool call chunks
const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
expect(rawChunks).toHaveLength(2)
expect(rawChunks[0]).toEqual({
type: "tool_call_partial",
index: 0,
id: "call_123",
name: "read_file",
arguments: '{"path":"',
})
expect(rawChunks[1]).toEqual({
type: "tool_call_partial",
index: 0,
id: undefined,
name: undefined,
arguments: 'test.ts"}',
})
})
it("should yield 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 {
@ -718,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,
},
],
@ -738,15 +750,19 @@ 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"}')
const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
expect(rawChunks).toHaveLength(1)
expect(rawChunks[0]).toEqual({
type: "tool_call_partial",
index: 0,
id: "call_456",
name: "write_to_file",
arguments: '{"path":"test.ts","content":"hello"}',
})
})
it("should handle multiple tool calls", async () => {
it("should handle multiple tool calls with different indices", async () => {
mockCreate.mockResolvedValueOnce({
[Symbol.asyncIterator]: async function* () {
yield {
@ -800,15 +816,16 @@ 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 rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
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 chunks", async () => {
it("should emit raw chunks for streaming arguments", async () => {
mockCreate.mockResolvedValueOnce({
[Symbol.asyncIterator]: async function* () {
yield {
@ -876,14 +893,15 @@ 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 rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
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 {
@ -902,8 +920,8 @@ describe("RooHandler", () => {
chunks.push(chunk)
}
const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call")
expect(toolCallChunks).toHaveLength(0)
const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
expect(rawChunks).toHaveLength(0)
})
})
})

View file

@ -123,8 +123,6 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
}) as const,
)
const toolCallAccumulator = new Map<number, { id: string; name: string; arguments: string }>()
let lastUsage: OpenAI.CompletionUsage | undefined
for await (const chunk of stream) {
@ -137,7 +135,6 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
}
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<ModelName extends string>
}
}
// 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<ModelName extends string>
}
}
// 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)
}

View file

@ -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) {

View file

@ -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<number, { id: string; name: string; input: string }>()
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
}
}
}

View file

@ -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<string, { name: string; arguments: string }> = 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)!
const name = event.name || event.function_name
const args = event.delta || event.arguments
// 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
}
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) {

View file

@ -191,11 +191,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
)
let lastUsage
const toolCallAccumulator = new Map<number, { id: string; name: string; arguments: string }>()
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<OpenAI.Chat.Completions.ChatCompletionChunk>): ApiStream {
const toolCallAccumulator = new Map<number, { id: string; name: string; arguments: string }>()
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 {

View file

@ -222,7 +222,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
}
let lastUsage: CompletionUsage | undefined = undefined
const toolCallAccumulator = new Map<number, { id: string; name: string; arguments: string }>()
// Accumulator for reasoning_details: accumulate text by type-index key
const reasoningDetailsAccumulator = new Map<
string,
@ -320,24 +319,15 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
yield { type: "reasoning", text: delta.reasoning }
}
// Check for tool calls in delta
// 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
const existing = toolCallAccumulator.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 || "",
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,
}
}
}
@ -347,39 +337,11 @@ 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()) {
yield {
type: "tool_call",
id: toolCall.id,
name: toolCall.name,
arguments: toolCall.arguments,
}
}
// Clear accumulator after yielding
toolCallAccumulator.clear()
}
if (chunk.usage) {
lastUsage = chunk.usage
}
}
// 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()
}
// After streaming completes, store the accumulated reasoning_details
if (reasoningDetailsAccumulator.size > 0) {
this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values())

View file

@ -128,12 +128,9 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
)
let lastUsage: RooUsage | undefined = undefined
// Accumulate tool calls by index - similar to how reasoning accumulates
const toolCallAccumulator = new Map<number, { id: string; name: string; arguments: 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)
@ -152,24 +149,15 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
}
}
// Check for tool calls in delta
// 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
const existing = toolCallAccumulator.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 || "",
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,
}
}
}
@ -182,39 +170,11 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
}
}
// 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()) {
yield {
type: "tool_call",
id: toolCall.id,
name: toolCall.name,
arguments: toolCall.arguments,
}
}
// Clear accumulator after yielding
toolCallAccumulator.clear()
}
if (chunk.usage) {
lastUsage = chunk.usage as RooUsage
}
}
// 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 [index, toolCall] of toolCallAccumulator.entries()) {
yield {
type: "tool_call",
id: toolCall.id,
name: toolCall.name,
arguments: toolCall.arguments,
}
}
toolCallAccumulator.clear()
}
if (lastUsage) {
// Check if the current model is marked as free
const model = this.getModel()

View file

@ -6,6 +6,10 @@ export type ApiStreamChunk =
| ApiStreamReasoningChunk
| ApiStreamGroundingChunk
| ApiStreamToolCallChunk
| ApiStreamToolCallStartChunk
| ApiStreamToolCallDeltaChunk
| ApiStreamToolCallEndChunk
| ApiStreamToolCallPartialChunk
| ApiStreamError
export interface ApiStreamError {
@ -46,6 +50,36 @@ 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
}
/**
* 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 ApiStreamToolCallPartialChunk {
type: "tool_call_partial"
index: number
id?: string
name?: string
arguments?: string
}
export interface GroundingSource {
title: string
url: string

View file

@ -1,5 +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.
@ -16,7 +22,353 @@ type NativeArgsFor<TName extends ToolName> = 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 for argument accumulation (keyed by tool call id)
private static streamingToolCalls = new Map<
string,
{
id: string
name: ToolName
argumentsAccumulator: string
}
>()
// 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_partial 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.
*/
public static startStreamingToolCall(id: string, name: ToolName): void {
this.streamingToolCalls.set(id, {
id,
name,
argumentsAccumulator: "",
})
}
/**
* 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.
* 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<string, any>,
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<Record<ToolParamName, string>> = {}
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":
// 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 ||
partialArgs.content !== undefined
) {
nativeArgs = {
path: partialArgs.path,
line:
typeof partialArgs.line === "number"
? partialArgs.line
: partialArgs.line !== undefined
? 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.
*

View file

@ -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<TaskEvents> 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<string, number> = 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,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.presentAssistantMessageLocked = false
this.presentAssistantMessageHasPendingUpdates = false
this.assistantMessageParser?.reset()
this.streamingToolCallIndices.clear()
// Clear any leftover streaming tool call state from previous interrupted streams
NativeToolCallParser.clearAllStreamingToolCalls()
NativeToolCallParser.clearRawChunkState()
await this.diffViewProvider.reset()
@ -2336,7 +2343,99 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
pendingGroundingSources.push(...chunk.sources)
}
break
case "tool_call_partial": {
// 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,
})
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,
}
// Store the ID for native protocol
;(partialToolUse as any).id = event.id
// 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": {
// Legacy: Handle complete tool calls (for backward compatibility)
// Convert native tool call to ToolUse format
const toolUse = NativeToolCallParser.parseToolCall({
id: chunk.id,

View file

@ -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",

View file

@ -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,
)
}
}
}

View file

@ -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",