diff --git a/.changeset/ollama-parallel-tool-call-ids.md b/.changeset/ollama-parallel-tool-call-ids.md new file mode 100644 index 0000000000..4e19f02cc7 --- /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 merged into the first instead of running. The fallback id is now unique per call. diff --git a/packages/ai-ollama/src/adapters/text.ts b/packages/ai-ollama/src/adapters/text.ts index 67a456aba6..3fd3e764f6 100644 --- a/packages/ai-ollama/src/adapters/text.ts +++ b/packages/ai-ollama/src/adapters/text.ts @@ -329,11 +329,11 @@ export class OllamaTextAdapter extends BaseTextAdapter< const handleToolCall = (toolCall: ToolCall): Array => { const actualToolCall = toolCall as ToolCall & { - id: string - function: { index: number } + id?: string + function: { index?: number } } const toolCallId = - actualToolCall.id || `${actualToolCall.function.name}_${Date.now()}` + actualToolCall.id || generateId(actualToolCall.function.name) 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..3516969e7b 100644 --- a/packages/ai-ollama/tests/text-adapter.test.ts +++ b/packages/ai-ollama/tests/text-adapter.test.ts @@ -54,6 +54,7 @@ beforeEach(() => { afterEach(() => { vi.unstubAllEnvs() + vi.restoreAllMocks() }) describe('OllamaTextAdapter construction', () => { @@ -277,7 +278,46 @@ describe('OllamaTextAdapter.chatStream (tool calls)', () => { const startChunk = chunks.find((c) => c.type === 'TOOL_CALL_START') as | { toolCallId: string; toolCallName: string } | undefined - expect(startChunk!.toolCallId).toMatch(/^search_\d+/) + expect(startChunk!.toolCallId).toMatch(/^search-\d+-/) + }) + + it('keeps two parallel calls to the same tool apart when Ollama omits ids', async () => { + // Same millisecond for both calls: the old `name_Date.now()` id collided here + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) + 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 startIds = chunks.flatMap((c) => + c.type === 'TOOL_CALL_START' ? [c.toolCallId] : [], + ) + const ends = chunks.flatMap((c) => (c.type === 'TOOL_CALL_END' ? [c] : [])) + expect(new Set(startIds).size).toBe(2) + expect(ends.map((e) => e.toolCallId)).toEqual(startIds) + expect(ends.map((e) => e.args)).toEqual(['{"q":"cats"}', '{"q":"dogs"}']) }) async function toolCallEnd(args: unknown) { diff --git a/testing/e2e/fixtures/tool-calling/parallel-same-tool.json b/testing/e2e/fixtures/tool-calling/parallel-same-tool.json new file mode 100644 index 0000000000..7e3381f4f8 --- /dev/null +++ b/testing/e2e/fixtures/tool-calling/parallel-same-tool.json @@ -0,0 +1,31 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "[parallel-same] compare guitars 1 and 2, then 3 and 4", + "sequenceIndex": 0 + }, + "response": { + "toolCalls": [ + { + "name": "compareGuitars", + "arguments": "{\"guitarIds\":[1,2]}" + }, + { + "name": "compareGuitars", + "arguments": "{\"guitarIds\":[3,4]}" + } + ] + } + }, + { + "match": { + "userMessage": "[parallel-same] compare guitars 1 and 2, then 3 and 4", + "sequenceIndex": 1 + }, + "response": { + "content": "Both comparisons are done. The Stratocaster is the cheapest of the first pair." + } + } + ] +} diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index 8bf6f8fa98..7c28351025 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -154,6 +154,7 @@ export const matrix: Record> = { 'cloudflare', 'neon', 'neon-anthropic', + 'ollama', ]), // Gemini excluded: approval flow timing issues with Gemini's streaming format // Vertex uses the same Gemini stream, so it is excluded for the same reason. diff --git a/testing/e2e/tests/parallel-tool-calls.spec.ts b/testing/e2e/tests/parallel-tool-calls.spec.ts index aa20742a0c..f67b7748ea 100644 --- a/testing/e2e/tests/parallel-tool-calls.spec.ts +++ b/testing/e2e/tests/parallel-tool-calls.spec.ts @@ -33,5 +33,29 @@ for (const provider of providersFor('parallel-tool-calls')) { // Wait for text response after tool execution await waitForAssistantText(page, 'Stratocaster') }) + + // Ollama sends no tool-call ids, so this guards the fallback id + test('keeps parallel calls to the same tool apart', async ({ + page, + testId, + aimockPort, + }) => { + await page.goto( + featureUrl(provider, 'parallel-tool-calls', testId, aimockPort), + ) + + await sendMessage( + page, + '[parallel-same] compare guitars 1 and 2, then 3 and 4', + ) + await waitForResponse(page) + + const toolNames = (await getToolCalls(page)).map((t) => t.name) + expect( + toolNames.filter((name) => name === 'compareGuitars'), + ).toHaveLength(2) + + await waitForAssistantText(page, 'Both comparisons are done') + }) }) }