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();