diff --git a/.changeset/ollama-parallel-tool-call-ids.md b/.changeset/ollama-parallel-tool-call-ids.md new file mode 100644 index 0000000000..a326d44012 --- /dev/null +++ b/.changeset/ollama-parallel-tool-call-ids.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-ollama': patch +--- + +Keep parallel calls to the same tool apart when Ollama sends no tool-call id. The fallback id was `_`, so two calls to one tool in the same millisecond shared an id and the second call was dropped. The fallback id now also carries the call's index. diff --git a/packages/ai-ollama/src/adapters/text.ts b/packages/ai-ollama/src/adapters/text.ts index 67a456aba6..2b16553bb8 100644 --- a/packages/ai-ollama/src/adapters/text.ts +++ b/packages/ai-ollama/src/adapters/text.ts @@ -332,8 +332,10 @@ export class OllamaTextAdapter extends BaseTextAdapter< id: string function: { index: number } } + // Ollama sends no id: tell parallel calls to the same tool apart by index const toolCallId = - actualToolCall.id || `${actualToolCall.function.name}_${Date.now()}` + actualToolCall.id || + `${actualToolCall.function.name}_${Date.now()}_${actualToolCall.function.index ?? toolCallsEmitted.size}` const events: Array = [] // Emit TOOL_CALL_START if not already emitted for this tool call diff --git a/packages/ai-ollama/tests/text-adapter.test.ts b/packages/ai-ollama/tests/text-adapter.test.ts index 6ed3ff47c1..ade4888e63 100644 --- a/packages/ai-ollama/tests/text-adapter.test.ts +++ b/packages/ai-ollama/tests/text-adapter.test.ts @@ -280,6 +280,51 @@ describe('OllamaTextAdapter.chatStream (tool calls)', () => { expect(startChunk!.toolCallId).toMatch(/^search_\d+/) }) + it('keeps two parallel calls to the same tool apart when Ollama omits ids', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) + try { + chatMock.mockResolvedValueOnce( + asyncIterable([ + { + message: { + role: 'assistant', + content: '', + tool_calls: [ + { function: { name: 'search', arguments: { q: 'cats' } } }, + { function: { name: 'search', arguments: { q: 'dogs' } } }, + ], + }, + done: true, + done_reason: 'stop', + }, + ]), + ) + + const chunks = await collectStream( + createOllamaChat('llama3.2').chatStream({ + threadId: 'thread-1', + logger: testLogger, + model: 'llama3.2', + messages: [{ role: 'user', content: 'find cats and dogs' }], + tools: [searchTool], + }), + ) + + const starts = chunks.filter((c) => c.type === 'TOOL_CALL_START') as Array<{ + toolCallId: string + }> + const ends = chunks.filter((c) => c.type === 'TOOL_CALL_END') as Array<{ + toolCallId: string + args: string + }> + expect(starts).toHaveLength(2) + expect(new Set(starts.map((s) => s.toolCallId)).size).toBe(2) + expect(ends.map((e) => e.args)).toEqual(['{"q":"cats"}', '{"q":"dogs"}']) + } finally { + vi.restoreAllMocks() + } + }) + async function toolCallEnd(args: unknown) { chatMock.mockResolvedValueOnce( asyncIterable([