mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
feat: implement streaming for native tool calls (#9542)
This commit is contained in:
parent
cad6145241
commit
0327f12751
17 changed files with 695 additions and 355 deletions
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"}',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue