diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index 91418e07b1f..bf64b3ab9c2 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -292,6 +292,64 @@ describe('OpenAIContentConverter', () => { expect(fnB?.args).toEqual({ file_path: '/b/y.ts' }); expect(fnB?.id).toBe('call_B'); }); + + it('emits no-argument tool calls that stream an empty arguments string', () => { + // Providers may finish a no-argument tool call with `arguments: ""` + // and no follow-up fragment (e.g. llama.cpp-style servers). The call + // must reach the caller with empty args instead of being dropped, + // which would make the whole turn look empty and trigger retries. + const stream = withStreamParser(new StreamingToolCallParser()); + + const opener = { + object: 'chat.completion.chunk', + id: 'noargs-open', + created: 1, + model: 'test', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_noargs', + type: 'function' as const, + function: { name: 'list_sessions', arguments: '' }, + }, + ], + }, + finish_reason: null, + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk; + + const finisher = { + object: 'chat.completion.chunk', + id: 'noargs-finish', + created: 2, + model: 'test', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'tool_calls', + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk; + + converter.convertOpenAIChunkToGemini(opener, stream); + const result = converter.convertOpenAIChunkToGemini(finisher, stream); + + const fn = result.candidates?.[0]?.content?.parts?.find( + (p: Part) => p.functionCall, + )?.functionCall; + + expect(fn?.name).toBe('list_sessions'); + expect(fn?.args).toEqual({}); + expect(fn?.id).toBe('call_noargs'); + }); }); describe('convertGeminiRequestToOpenAI', () => { diff --git a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts index 2e5ac32b51f..e7dc1bec2e7 100644 --- a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts +++ b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts @@ -391,11 +391,124 @@ describe('StreamingToolCallParser', () => { expect(completed).toHaveLength(0); }); - it('should not return tool calls with empty buffer', () => { - parser.addChunk(0, '', 'call_1', 'function1'); // Empty buffer + it('should return no-argument tool calls with empty args when buffer is empty', () => { + // For tools without parameters, some providers stream + // `arguments: ""` (or omit the field) and never send an argument + // fragment. The call must survive with empty args, matching the + // non-streaming path. + parser.addChunk(0, '', 'call_1', 'function1'); const completed = parser.getCompletedToolCalls(); - expect(completed).toHaveLength(0); + expect(completed).toEqual([ + { id: 'call_1', name: 'function1', args: {}, index: 0 }, + ]); + }); + + it('should return empty args for whitespace-only argument buffers', () => { + parser.addChunk(0, ' ', 'call_1', 'function1'); + + const completed = parser.getCompletedToolCalls(); + expect(completed).toHaveLength(1); + expect(completed[0].args).toEqual({}); + }); + + it('should not overwrite a completed no-argument tool call when a new call reuses its index', () => { + // First tool call: no arguments, provider never sends a fragment + parser.addChunk(0, '', 'call_1', 'no_arg_function'); + + // Second tool call arrives at the same index with a different ID + parser.addChunk(0, '{"param": "value"}', 'call_2', 'function2'); + + // Both calls must survive: the second is relocated to a new index + const completed = parser.getCompletedToolCalls(); + expect(completed).toEqual([ + { id: 'call_1', name: 'no_arg_function', args: {}, index: 0 }, + { + id: 'call_2', + name: 'function2', + args: { param: 'value' }, + index: 1, + }, + ]); + }); + + it('should route ID-less argument fragments to a call whose opener streamed empty arguments', () => { + // Canonical OpenAI-compatible streaming shape: the opener carries + // id + name + `arguments: ""`, then argument fragments follow at the + // same index without an ID. Mid-stream, an empty buffer with name + // metadata must therefore stay continuable at its own index — it is + // indistinguishable from a completed no-argument call until stream end. + parser.addChunk(0, '', 'call_1', 'function1'); + parser.addChunk(0, '{"x":'); + parser.addChunk(0, '1}'); + + const completed = parser.getCompletedToolCalls(); + expect(completed).toEqual([ + { id: 'call_1', name: 'function1', args: { x: 1 }, index: 0 }, + ]); + }); + + it('should emit empty args for a no-argument call polluted by a stray fragment at its index', () => { + // If a misbehaving provider reuses a completed no-argument call's + // index for another call's ID-less fragment, the fragment cannot be + // re-routed (see canonical-shape test above). The damage must stay + // bounded: the polluted buffer repairs to a non-object value, which + // collapses to {} at emit time. + parser.addChunk(0, '{"key":', 'call_1', 'function1'); + parser.addChunk(1, '', 'call_2', 'no_arg_function'); + parser.addChunk(1, '"value"}'); + + const completed = parser.getCompletedToolCalls(); + const noArg = completed.find((c) => c.id === 'call_2'); + expect(noArg?.args).toEqual({}); + }); + + it('should collapse null argument buffers to empty args', () => { + parser.addChunk(0, 'null', 'call_1', 'function1'); + + const completed = parser.getCompletedToolCalls(); + expect(completed[0].args).toEqual({}); + }); + + it('should collapse array argument buffers to empty args', () => { + parser.addChunk(0, '[1,2,3]', 'call_1', 'function1'); + + const completed = parser.getCompletedToolCalls(); + expect(completed[0].args).toEqual({}); + }); + + it('should scan past occupied no-argument slots when relocating a colliding call', () => { + parser.addChunk(0, '', 'call_a', 'no_arg_a'); + parser.addChunk(1, '', 'call_b', 'no_arg_b'); + // Collision at index 0 must relocate past both occupied no-arg slots + parser.addChunk(0, '{"x": 1}', 'call_c', 'fn_c'); + + const completed = parser.getCompletedToolCalls(); + expect(completed).toEqual([ + { id: 'call_a', name: 'no_arg_a', args: {}, index: 0 }, + { id: 'call_b', name: 'no_arg_b', args: {}, index: 1 }, + { id: 'call_c', name: 'fn_c', args: { x: 1 }, index: 2 }, + ]); + }); + + it('should not route continuation chunks to a completed no-argument tool call', () => { + // Incomplete tool call accumulating arguments at index 0 + parser.addChunk(0, '{"key":', 'call_1', 'function1'); + // Completed no-argument tool call at the higher index 1 + parser.addChunk(1, '', 'call_2', 'no_arg_function'); + // Completed tool call at index 2 + parser.addChunk(2, '{"x": 1}', 'call_3', 'function3'); + + // Continuation chunk without an ID arriving at a completed index must + // be routed to the incomplete call_1, not to the no-argument call_2 + parser.addChunk(2, '"value"}'); + + const completed = parser.getCompletedToolCalls(); + expect(completed).toEqual([ + { id: 'call_1', name: 'function1', args: { key: 'value' }, index: 0 }, + { id: 'call_2', name: 'no_arg_function', args: {}, index: 1 }, + { id: 'call_3', name: 'function3', args: { x: 1 }, index: 2 }, + ]); }); }); @@ -568,6 +681,31 @@ describe('StreamingToolCallParser', () => { expect(parser.getBuffer(0)).toBe('{"param1": "value1"}'); }); + it('should ignore replayed openers for a completed no-argument tool call', () => { + parser.addChunk(0, '', 'call_1', 'list_sessions'); + // Provider replays the same ID's opener with a different name; the + // surviving call must not be mutated + parser.addChunk(0, '', 'call_1', 'different_function'); + + const completed = parser.getCompletedToolCalls(); + expect(completed).toHaveLength(1); + expect(completed[0].name).toBe('list_sessions'); + expect(completed[0].args).toEqual({}); + }); + + it('should append ID-bearing argument fragments after an empty opener', () => { + // Some providers repeat the tool call ID on argument fragments. A + // known-ID chunk carrying argument content is a continuation, not a + // replay, and must not be swallowed by the replay guard. + parser.addChunk(0, '', 'call_1', 'function1'); + const result = parser.addChunk(0, '{"x":1}', 'call_1'); + + expect(result.complete).toBe(true); + expect(parser.getCompletedToolCalls()).toEqual([ + { id: 'call_1', name: 'function1', args: { x: 1 }, index: 0 }, + ]); + }); + it('should ignore metadata-only replay chunks after a tool call ID completes', () => { parser.addChunk(0, '{"file_path": "a.ts"}', 'call_1', 'read_file'); diff --git a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts index c4f52ca4d42..3379e5f5a97 100644 --- a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts +++ b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.ts @@ -88,20 +88,28 @@ export class StreamingToolCallParser { const existingDepth = this.depths.get(index)!; const existingMeta = this.toolCallMeta.get(index); - // Check if we have a complete tool call at this index + // Check if we have a complete tool call at this index. Occupancy + // is signaled by the name metadata, not the buffer: an empty + // buffer with a name is a complete no-argument call. if ( - existingBuffer.trim() && + existingMeta?.name && existingDepth === 0 && existingMeta?.id && existingMeta.id !== id ) { - try { - JSON.parse(existingBuffer); + let existingComplete = true; + if (existingBuffer.trim()) { + try { + JSON.parse(existingBuffer); + } catch { + // Existing buffer is not complete JSON, we can reuse this index + existingComplete = false; + } + } + if (existingComplete) { // We have a complete tool call with a different ID at this index // Find a new index for this tool call actualIndex = this.findNextAvailableIndex(); - } catch { - // Existing buffer is not complete JSON, we can reuse this index } } } @@ -146,15 +154,31 @@ export class StreamingToolCallParser { const currentBuffer = this.buffers.get(actualIndex)!; const currentDepth = this.depths.get(actualIndex)!; - if (isKnownId && currentBuffer.trim() && currentDepth === 0) { - try { - JSON.parse(currentBuffer); + if (isKnownId && currentDepth === 0) { + if (currentBuffer.trim()) { + try { + JSON.parse(currentBuffer); + debugLogger.debug( + `Ignoring replay chunk for completed toolCall id=${id}`, + ); + return { complete: false }; + } catch { + // Not complete yet; append the incoming chunk below. + } + } else if ( + this.toolCallMeta.get(actualIndex)?.name && + name && + !chunk.trim() + ) { + // The call at this index may be a completed no-argument call. An + // incoming chunk that carries a name but no argument content is an + // opener-shaped replay (duplicate ID) and must not mutate the + // surviving call; a chunk with argument content is a continuation + // for a call whose opener streamed empty arguments and must append. debugLogger.debug( - `Ignoring replay chunk for completed toolCall id=${id}`, + `Ignoring replayed opener for no-argument toolCall id=${id}`, ); return { complete: false }; - } catch { - // Not complete yet; append the incoming chunk below. } } @@ -244,8 +268,10 @@ export class StreamingToolCallParser { * 2. Auto-close unclosed strings and retry * 3. Fallback to safeJsonParse for malformed data * - * Only returns tool calls with both name metadata and non-empty buffers. - * Should be called when streaming is complete (finish_reason is present). + * Only returns tool calls with name metadata. An empty buffer yields + * empty arguments ({}), matching the non-streaming path for no-argument + * tools. Should be called when streaming is complete (finish_reason is + * present). * * @returns Array of completed tool calls with their metadata and parsed arguments */ @@ -265,7 +291,7 @@ export class StreamingToolCallParser { for (const [index, buffer] of this.buffers.entries()) { const meta = this.toolCallMeta.get(index); - if (meta?.name && buffer.trim()) { + if (meta?.name) { if (meta.id) { if (emittedIds.has(meta.id)) { continue; @@ -275,22 +301,45 @@ export class StreamingToolCallParser { let args: Record = {}; - // Try to parse the final buffer - try { - args = JSON.parse(buffer); - } catch { - // Try with repair (auto-close strings) - const inString = this.inStrings.get(index); - if (inString) { - try { - args = JSON.parse(buffer + '"'); - } catch { - // If all parsing fails, use safeJsonParse as fallback + // An empty buffer is a legal end state: for no-argument tools some + // providers stream `arguments: ""` (or omit the field entirely) and + // never send an argument fragment. Keep args as {} to match the + // non-streaming path in converter.ts. + if (buffer.trim()) { + // Try to parse the final buffer + try { + args = JSON.parse(buffer); + } catch { + // Try with repair (auto-close strings) + const inString = this.inStrings.get(index); + if (inString) { + try { + args = JSON.parse(buffer + '"'); + } catch { + // If all parsing fails, use safeJsonParse as fallback + args = safeJsonParse(buffer, {}); + } + } else { args = safeJsonParse(buffer, {}); } - } else { - args = safeJsonParse(buffer, {}); } + // Tool arguments are always JSON objects; a corrupted buffer can + // parse or repair to a non-object value (e.g. a bare string), so + // collapse anything else to {}. + if ( + typeof args !== 'object' || + args === null || + Array.isArray(args) + ) { + debugLogger.debug( + `Collapsing non-object arguments for tool call ${meta.name} (id=${meta.id}) at index ${index}; buffer likely polluted by a misrouted fragment`, + ); + args = {}; + } + } else { + debugLogger.debug( + `Emitting no-argument tool call ${meta.name} (id=${meta.id}) at index ${index} with empty buffer`, + ); } completed.push({ @@ -309,8 +358,9 @@ export class StreamingToolCallParser { * Finds the next available index for a new tool call * * Scans indices starting from nextAvailableIndex to find one that's safe to use. - * Reuses indices with empty buffers or incomplete parsing states. - * Skips indices with complete, parseable tool call data to prevent overwriting. + * Reuses indices never claimed by a named tool call or with incomplete + * parsing states. Skips indices with complete tool call data — including + * no-argument calls with empty buffers — to prevent overwriting. * * @returns The next available index safe for storing a new tool call */ @@ -321,22 +371,23 @@ export class StreamingToolCallParser { const depth = this.depths.get(this.nextAvailableIndex)!; const meta = this.toolCallMeta.get(this.nextAvailableIndex); - // If buffer is empty or incomplete (depth > 0), this index is available - if (!buffer.trim() || depth > 0 || !meta?.id) { + // If the slot was never claimed by a named call or is incomplete + // (depth > 0), this index is available. An empty buffer alone does + // not mean available: with name metadata it is a complete + // no-argument call. + if (!meta?.name || depth > 0 || !meta?.id) { return this.nextAvailableIndex; } - // Try to parse the buffer to see if it's complete - try { - JSON.parse(buffer); - // If parsing succeeds and depth is 0, this index has a complete tool call - if (depth === 0) { - this.nextAvailableIndex++; - continue; + // A non-empty buffer must parse as complete JSON for the slot to be + // occupied; an empty buffer is already complete (no-argument call) + if (buffer.trim()) { + try { + JSON.parse(buffer); + } catch { + // If parsing fails, this index is available for reuse + return this.nextAvailableIndex; } - } catch { - // If parsing fails, this index is available for reuse - return this.nextAvailableIndex; } this.nextAvailableIndex++; @@ -348,8 +399,9 @@ export class StreamingToolCallParser { * Finds the most recent incomplete tool call index * * Used when continuation chunks arrive without IDs. Scans existing tool calls - * to find the highest index with incomplete parsing state (depth > 0, empty buffer, - * or unparseable JSON). Falls back to creating a new index if none found. + * to find the highest index with incomplete parsing state (depth > 0, empty + * buffer with no name metadata yet, or unparseable JSON). Falls back to + * creating a new index if none found. * * @returns The index of the most recent incomplete tool call, or a new available index */ @@ -360,8 +412,10 @@ export class StreamingToolCallParser { const depth = this.depths.get(index)!; const meta = this.toolCallMeta.get(index); - // Check if this tool call is incomplete - if (meta?.id && (depth > 0 || !buffer.trim())) { + // Check if this tool call is incomplete. An empty buffer counts only + // while no name has arrived: with name metadata it is a complete + // no-argument call, and stray chunks must not be appended to it + if (meta?.id && (depth > 0 || (!buffer.trim() && !meta?.name))) { maxIndex = Math.max(maxIndex, index); } else if (buffer.trim()) { // Check if buffer is parseable (complete)