From 7b23c35db7e2b728693e6d6b993e7c76a5cac89e Mon Sep 17 00:00:00 2001 From: MarcTCruz <58499846+MarcTCruz@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:33:37 +0000 Subject: [PATCH] fix(ollama-native): key response tool calls by native id before index Ollama Cloud's mistral-large-4 streams several distinct tool calls in one frame, each with its own id but all with function.index 0. The parser keyed calls by index, so the second call collided with the first and the stream failed with "changed a tool-call id for an existing index" (or "reused a tool-call index for another function" when the names differ). A valid native id is now the call's identity. Index, then position, is the fallback only for entries without an id, and a later id adopts the call that was first seen without one at the same index. Two entries in one frame share a call only when they share an id. Budget keys follow first-seen order instead of the index, so distinct calls at one index keep separate argument and metadata accounting. The parallelToolCalls:false guard is unchanged: the captured shape now reaches it and fails closed with the parallel-call error. Co-Authored-By: Claude Opus 5.5 --- .../src/content/docs/reference/adapters.md | 4 + src/adapters/ollama-native.ts | 32 +-- structure/providers/chat-compat.md | 2 +- .../ollama/ollama-native-parser.test.ts | 212 ++++++++++++++++++ 4 files changed, 235 insertions(+), 15 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index b94a0044fad..802a546e12b 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -122,6 +122,10 @@ be configured on a separately named custom or self-hosted Ollama provider with is refused rather than mis-sent, and remote image URLs are not fetched. - **Tools:** declared in Ollama's native shape, streamed tool calls are whole-call records with object-valued `arguments`, and tool-result replay is paired strictly by call id and tool name. + Valid native call IDs distinguish calls even when the provider repeats a function index. + Repeated IDs update the same call's arguments; ID-less entries fall back to index, then position. + Calls retain first-seen order. With `parallel_tool_calls: false`, a second distinct provider call + still fails the turn closed before any tool call is emitted. Codex may record assistant commentary before a pending call's results. Text/thinking with no new tool calls is deferred until the batch is settled, so genuine results remain beside their originating calls. A new tool-call batch still settles the preceding one; missing results retain diff --git a/src/adapters/ollama-native.ts b/src/adapters/ollama-native.ts index f51e5aea5f4..69bc5a6bae9 100644 --- a/src/adapters/ollama-native.ts +++ b/src/adapters/ollama-native.ts @@ -81,7 +81,7 @@ interface PendingToolBatch { } interface NativeStreamToolCall { - key: string; + fallbackKey: string; budgetKey: string; order: number; name: string; @@ -639,6 +639,7 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg } if (message.tool_calls !== undefined) { if (!Array.isArray(message.tool_calls)) throw new Error("ollama-native response tool_calls was not an array"); + const frameCalls = new Map(); for (let position = 0; position < message.tool_calls.length; position++) { const rawCall = message.tool_calls[position]; if (!isRecord(rawCall) || !isRecord(rawCall.function)) { @@ -653,12 +654,16 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg const args = assertObjectArguments(fn.arguments, "response tool call"); const index = isFiniteNonNegativeInteger(fn.index) ? fn.index : undefined; const nativeId = validNativeToolCallId(rawCall.id); - const key = index === undefined ? `position:${position}` : `index:${index}`; - // Tool-call identity is explicitly keyed by the provider index when supplied: a later - // frame for the same index updates that call's arguments, while a distinct index creates a - // second call. This narrow tool-call compatibility rule is independent from text/thinking - // semantics, where every non-empty native field is an appended partial delta. - const existing = state.toolCalls.get(key); + const fallbackKey = index === undefined ? `position:${position}` : `index:${index}`; + // Native ids distinguish calls even when Ollama repeats index zero. Index/position are + // fallback identities only; within one frame only entries sharing an id may update a call. + // An id arriving for a call first seen without one still adopts that call by its fallback. + const available = (call: NativeStreamToolCall) => + !frameCalls.has(call) || (nativeId !== undefined && frameCalls.get(call) === nativeId); + const calls = [...state.toolCalls.values()]; + const existing = (nativeId ? calls.find(call => call.nativeId === nativeId && available(call)) : undefined) + ?? calls.find(call => call.fallbackKey === fallbackKey + && (!nativeId || !call.nativeId) && available(call)); if (!existing && !state.allowParallelToolCalls && state.toolCalls.size > 0) { throw new Error("ollama-native provider emitted parallel tool calls while parallelToolCalls:false was requested"); } @@ -666,16 +671,14 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg throw new Error(`ollama-native response exceeded ${NATIVE_MAX_PENDING_TOOL_CALLS} pending tool calls`); } if (existing) { - if (existing.name !== fn.name) throw new Error("ollama-native response reused a tool-call index for another function"); - if (nativeId && existing.nativeId && nativeId !== existing.nativeId) { - throw new Error("ollama-native response changed a tool-call id for an existing index"); - } + if (existing.name !== fn.name) throw new Error("ollama-native response reused a tool-call identity for another function"); if (!existing.nativeId && nativeId) existing.nativeId = nativeId; replaceNativeToolArguments(existing, args, budget); + frameCalls.set(existing, nativeId); } else { const call: NativeStreamToolCall = { - key, - budgetKey: `ollama-native:${key}`, + fallbackKey, + budgetKey: `ollama-native:${state.nextToolOrder}`, order: state.nextToolOrder++, name: fn.name, ...(nativeId ? { nativeId } : {}), @@ -695,7 +698,8 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg }); call.metadataBytes = metadataBytes; replaceNativeToolArguments(call, args, budget); - state.toolCalls.set(key, call); + state.toolCalls.set(call.budgetKey, call); + frameCalls.set(call, nativeId); } catch (error) { if (call.metadataBytes > 0) { budget.releaseRetained(call.metadataBytes, { kind: "tool_args" }); diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index d90857d5a6b..89e9c7b0745 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -127,7 +127,7 @@ with no new tool calls is also deferred while results remain outstanding. Deferr A new tool-call batch settles its predecessor. Additional results in an open batch join in arrival order, preserving images and error markers; known late output uses an attributed user-text carrier after any pending batch. Unknown IDs and mismatched names/namespaces still throw; missing results -retain the unknown-status marker; the replay and tool-continuation tests under `tests/providers/ollama/` cover both paths. See the decision record below. +retain the unknown-status marker; the replay and tool-continuation tests under `tests/providers/ollama/` cover both paths. See the decision record below. Native response tool-call identity uses valid IDs before function-index/position fallbacks. Distinct IDs at index zero remain separate; repeated IDs replace arguments and cannot change function names. ID-less later frames can update an existing index (and an id may adopt a call first seen without one), but same-frame entries share a call only with the same ID. First-seen order, separate call budgets/retained metadata and the 128-call cap remain enforced. `parallelToolCalls:false` still fails closed before actionable tool events. `tests/providers/ollama/ollama-native-parser.test.ts` covers these contracts. Forward-mode OpenAI passthrough also repairs replayed `call_id` values longer than the Responses API's 64-character limit. Sidechat/fork replay can namespace routed-provider ids beyond that limit, diff --git a/tests/providers/ollama/ollama-native-parser.test.ts b/tests/providers/ollama/ollama-native-parser.test.ts index 76f9194b646..4223c861723 100644 --- a/tests/providers/ollama/ollama-native-parser.test.ts +++ b/tests/providers/ollama/ollama-native-parser.test.ts @@ -312,6 +312,218 @@ describe("ollama-native — buffered terminal contract", () => { }); describe("ollama-native — tool calls", () => { + const capturedFrame = { + message: { + role: "assistant", content: "", tool_calls: [ + { id: "PpZ8JqRmm", function: { index: 0, name: "read_file", arguments: { path: "/etc/hostname" } } }, + { id: "DDe4wZqkx", function: { index: 0, name: "read_file", arguments: { path: "/etc/os-release" } } }, + ], + }, + done: false, + }; + const toolTerminal = { done: true, done_reason: "tool_calls" }; + + test.each(["stream", "buffered"])("native identity: captured index-zero calls stay distinct (%s)", async mode => { + const adapter = createOllamaNativeAdapter(provider()); + adapter.buildRequest(parsedWith([{ role: "user", content: "go" }], { parallelToolCalls: true })); + const budget = createTestTranslatorBudget({ maxCallArgumentBytes: 32 }); + const events: AdapterEvent[] = []; + if (mode === "buffered") { + events.push(...await adapter.parseResponse!(new Response(JSON.stringify({ ...capturedFrame, ...toolTerminal })), budget)); + } else { + for await (const event of adapter.parseStream(ndjsonResponse([capturedFrame, toolTerminal]), budget)) events.push(event); + } + expect(events.filter(event => event.type !== "heartbeat")).toEqual([ + { type: "tool_call_start", id: "PpZ8JqRmm", name: "read_file" }, + { type: "tool_call_delta", arguments: '{"path":"/etc/hostname"}' }, + { type: "tool_call_end" }, + { type: "tool_call_start", id: "DDe4wZqkx", name: "read_file" }, + { type: "tool_call_delta", arguments: '{"path":"/etc/os-release"}' }, + { type: "tool_call_end" }, + { type: "done", stopReason: "tool_calls" }, + ]); + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, overflows: 0 }); + // Buffered parsing retains the translated event batch until its response owner disposes it. + if (mode === "stream") expect(budget.snapshot().currentBytes).toBe(0); + budget.dispose(); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("native identity: captured parallel calls fail at the parallel policy guard", async () => { + const adapter = createOllamaNativeAdapter(provider()); + adapter.buildRequest(parsedWith([{ role: "user", content: "go" }], { parallelToolCalls: false })); + const budget = createTestTranslatorBudget(); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(ndjsonResponse([capturedFrame, toolTerminal]), budget)) events.push(event); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" }); + expect((events[0] as { message: string }).message).toContain("parallel tool calls"); + expect((events[0] as { message: string }).message).not.toContain("changed a tool-call id"); + expect(events.some(event => event.type === "tool_call_start")).toBe(false); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0, overflows: 0 }); + }); + + test.each([0, 1])("native identity: repeated id replaces arguments even at index %i", async nextIndex => { + const adapter = createOllamaNativeAdapter(provider()); + adapter.buildRequest(parsedWith([{ role: "user", content: "go" }], { parallelToolCalls: false })); + const events = await collect(adapter, ndjsonResponse([ + frame({ tool_calls: [{ id: "stable", function: { index: 0, name: "read_file", arguments: { path: "old", stale: true } } }] }, false), + frame({ tool_calls: [{ id: "stable", function: { index: nextIndex, name: "read_file", arguments: { path: "new" } } }] }, false), + toolTerminal, + ])); + expect(events.filter(event => event.type !== "heartbeat")).toEqual([ + { type: "tool_call_start", id: "stable", name: "read_file" }, + { type: "tool_call_delta", arguments: '{"path":"new"}' }, + { type: "tool_call_end" }, + { type: "done", stopReason: "tool_calls" }, + ]); + }); + + test.each(["index", "position"])("native identity: id-less %s updates across frames", async identity => { + const index = identity === "index" ? { index: 3 } : {}; + const events = await collect(createOllamaNativeAdapter(provider()), ndjsonResponse([ + frame({ tool_calls: [{ function: { ...index, name: "read_file", arguments: { path: "old", stale: true } } }] }, false), + frame({ tool_calls: [{ function: { ...index, name: "read_file", arguments: { path: "new" } } }] }, false), + toolTerminal, + ])); + const starts = events.filter(event => event.type === "tool_call_start"); + expect(starts).toHaveLength(1); + expect(starts[0]).toMatchObject({ name: "read_file", id: expect.stringMatching(/^ollama_call_/) }); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([{ type: "tool_call_delta", arguments: '{"path":"new"}' }]); + expect(events.at(-1)).toEqual({ type: "done", stopReason: "tool_calls" }); + }); + + test("native identity: repeated id in one frame replaces arguments despite differing indexes", async () => { + const adapter = createOllamaNativeAdapter(provider()); + adapter.buildRequest(parsedWith([{ role: "user", content: "go" }], { parallelToolCalls: false })); + const events = await collect(adapter, ndjsonResponse([ + frame({ tool_calls: [ + { id: "stable", function: { index: 0, name: "read_file", arguments: { path: "old" } } }, + { id: "stable", function: { index: 1, name: "read_file", arguments: { path: "new" } } }, + ] }, false), + toolTerminal, + ])); + expect(events.filter(event => event.type !== "heartbeat")).toEqual([ + { type: "tool_call_start", id: "stable", name: "read_file" }, + { type: "tool_call_delta", arguments: '{"path":"new"}' }, + { type: "tool_call_end" }, + { type: "done", stopReason: "tool_calls" }, + ]); + }); + + test.each([0, 1])("native identity: id-less entries in one frame stay distinct at index %i", async secondIndex => { + const events = await collect(createOllamaNativeAdapter(provider()), ndjsonResponse([ + frame({ tool_calls: [ + { function: { index: 0, name: "read_file", arguments: { path: "old-first" } } }, + { function: { index: secondIndex, name: "read_file", arguments: { path: "old-second" } } }, + ] }, false), + frame({ tool_calls: [ + { function: { index: 0, name: "read_file", arguments: { path: "first" } } }, + { function: { index: secondIndex, name: "read_file", arguments: { path: "second" } } }, + ] }, false), + toolTerminal, + ])); + const starts = events.filter(event => event.type === "tool_call_start"); + expect(starts).toHaveLength(2); + expect(starts.map(event => event.name)).toEqual(["read_file", "read_file"]); + expect(new Set(starts.map(event => event.id)).size).toBe(2); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: '{"path":"first"}' }, + { type: "tool_call_delta", arguments: '{"path":"second"}' }, + ]); + expect(events.at(-1)).toEqual({ type: "done", stopReason: "tool_calls" }); + }); + + test("native identity: an id-less index updates an existing native-id call", async () => { + const events = await collect(createOllamaNativeAdapter(provider()), ndjsonResponse([ + frame({ tool_calls: [{ id: "stable", function: { index: 0, name: "read_file", arguments: { path: "old" } } }] }, false), + frame({ tool_calls: [{ function: { index: 0, name: "read_file", arguments: { path: "new" } } }] }, false), + toolTerminal, + ])); + expect(events.filter(event => event.type !== "heartbeat")).toEqual([ + { type: "tool_call_start", id: "stable", name: "read_file" }, + { type: "tool_call_delta", arguments: '{"path":"new"}' }, + { type: "tool_call_end" }, + { type: "done", stopReason: "tool_calls" }, + ]); + }); + + test("native identity: a later id adopts the id-less call at its index", async () => { + const adapter = createOllamaNativeAdapter(provider()); + adapter.buildRequest(parsedWith([{ role: "user", content: "go" }], { parallelToolCalls: false })); + const events = await collect(adapter, ndjsonResponse([ + frame({ tool_calls: [{ function: { index: 0, name: "read_file", arguments: { path: "old" } } }] }, false), + frame({ tool_calls: [{ id: "late", function: { index: 0, name: "read_file", arguments: { path: "new" } } }] }, false), + toolTerminal, + ])); + expect(events.filter(event => event.type !== "heartbeat")).toEqual([ + { type: "tool_call_start", id: "late", name: "read_file" }, + { type: "tool_call_delta", arguments: '{"path":"new"}' }, + { type: "tool_call_end" }, + { type: "done", stopReason: "tool_calls" }, + ]); + }); + + test.each([0, 1])("native identity: the same id cannot change function at index %i", async nextIndex => { + const events = await collect(createOllamaNativeAdapter(provider()), ndjsonResponse([ + frame({ tool_calls: [{ id: "stable", function: { index: 0, name: "read_file", arguments: {} } }] }, false), + frame({ tool_calls: [{ id: "stable", function: { index: nextIndex, name: "write_file", arguments: {} } }] }, false), + toolTerminal, + ])); + expect(events.at(-1)).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" }); + expect((events.at(-1) as { message: string }).message).toContain("another function"); + expect(events.some(event => event.type === "tool_call_start")).toBe(false); + }); + + test("native identity: different ids and functions at one index across frames preserve order", async () => { + const events = await collect(createOllamaNativeAdapter(provider()), ndjsonResponse([ + frame({ tool_calls: [{ id: "first", function: { index: 0, name: "read_file", arguments: { path: "first" } } }] }, false), + frame({ tool_calls: [{ id: "second", function: { index: 0, name: "write_file", arguments: { path: "second" } } }] }, false), + frame({ tool_calls: [{ id: "first", function: { index: 0, name: "read_file", arguments: { path: "updated" } } }] }, false), + toolTerminal, + ])); + expect(events.filter(event => event.type !== "heartbeat")).toEqual([ + { type: "tool_call_start", id: "first", name: "read_file" }, + { type: "tool_call_delta", arguments: '{"path":"updated"}' }, + { type: "tool_call_end" }, + { type: "tool_call_start", id: "second", name: "write_file" }, + { type: "tool_call_delta", arguments: '{"path":"second"}' }, + { type: "tool_call_end" }, + { type: "done", stopReason: "tool_calls" }, + ]); + }); + + test("native identity: the pending-call cap counts distinct ids at one index", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const calls = Array.from({ length: 129 }, (_, index) => ({ + id: `call_${index}`, function: { index: 0, name: "read_file", arguments: {} }, + })); + const accepted = await collect(adapter, ndjsonResponse([ + frame({ tool_calls: calls.slice(0, 128) }, false), toolTerminal, + ])); + expect(accepted.filter(event => event.type === "tool_call_start").map(event => event.id)).toEqual(calls.slice(0, 128).map(call => call.id)); + expect(accepted.at(-1)?.type).toBe("done"); + const budget = createTestTranslatorBudget(); + const rejected: AdapterEvent[] = []; + for await (const event of adapter.parseStream(ndjsonResponse([frame({ tool_calls: calls }, false)]), budget)) rejected.push(event); + expect(rejected.at(-1)).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" }); + expect((rejected.at(-1) as { message: string }).message).toContain("exceeded 128 pending tool calls"); + expect(rejected.some(event => event.type === "tool_call_start")).toBe(false); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0, overflows: 0 }); + }); + + test("native identity: distinct ids at one index retain separate metadata charges", async () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 2000 }); + const frames = Array.from({ length: 3 }, (_, index) => frame({ tool_calls: [{ + id: `call_${index}`, function: { index: 0, name: "n".repeat(700), arguments: {} }, + }] }, false)); + const events: AdapterEvent[] = []; + for await (const event of createOllamaNativeAdapter(provider()).parseStream(ndjsonResponse(frames), budget)) events.push(event); + expect(events.at(-1)).toMatchObject({ type: "error", code: "translation_buffer_limit" }); + expect(events.some(event => event.type === "tool_call_start")).toBe(false); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0, overflows: 1 }); + }); + test("indexed tool calls preserve order and identity", async () => { const adapter = createOllamaNativeAdapter(provider()); const budget = createTestTranslatorBudget();