From 600f9af64897ff032a3b71e809355ff6a5cb209c Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 31 Aug 2026 09:57:34 +0900 Subject: [PATCH 1/3] fix(ollama): bound pending native tool calls --- src/adapters/ollama-native.ts | 15 ++++++++ .../ollama/ollama-native-parser.test.ts | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/adapters/ollama-native.ts b/src/adapters/ollama-native.ts index 726fe55ad49..61d6480f466 100644 --- a/src/adapters/ollama-native.ts +++ b/src/adapters/ollama-native.ts @@ -106,6 +106,10 @@ type NativeReadResult = { done: false; value: Uint8Array } | { done: true; value const NATIVE_THINK_VALUES = new Set(["low", "medium", "high", "max"]); const NATIVE_TOOL_ID_MAX_LENGTH = 256; +const NATIVE_TOOL_NAME_MAX_BYTES = 1024; +const NATIVE_MAX_PENDING_TOOL_CALLS = 128; +// Account for the retained Map key and call bookkeeping in addition to the provider's name. +const NATIVE_TOOL_CALL_BOOKKEEPING_BYTES = 128; const NATIVE_TOOL_ID_CONTROL = /[\u0000-\u001f\u007f]/u; function isRecord(value: unknown): value is JsonRecord { @@ -598,6 +602,10 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg } const fn = rawCall.function; if (typeof fn.name !== "string" || !fn.name.trim()) throw new Error("ollama-native response tool call had no name"); + const nameBytes = new TextEncoder().encode(fn.name).byteLength; + if (nameBytes > NATIVE_TOOL_NAME_MAX_BYTES) { + throw new Error(`ollama-native response tool call name exceeded ${NATIVE_TOOL_NAME_MAX_BYTES} bytes`); + } const args = assertObjectArguments(fn.arguments, "response tool call"); const index = isFiniteNonNegativeInteger(fn.index) ? fn.index : undefined; const nativeId = validNativeToolCallId(rawCall.id); @@ -610,6 +618,9 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg if (!existing && !state.allowParallelToolCalls && state.toolCalls.size > 0) { throw new Error("ollama-native provider emitted parallel tool calls while parallelToolCalls:false was requested"); } + if (!existing && state.toolCalls.size >= NATIVE_MAX_PENDING_TOOL_CALLS) { + 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) { @@ -630,6 +641,10 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg }; budget.openCall(call.budgetKey); try { + budget.chargeRetained(nameBytes + NATIVE_TOOL_CALL_BOOKKEEPING_BYTES, { + kind: "tool_args", + callId: call.budgetKey, + }); replaceNativeToolArguments(call, args, budget); state.toolCalls.set(key, call); } catch (error) { diff --git a/tests/providers/ollama/ollama-native-parser.test.ts b/tests/providers/ollama/ollama-native-parser.test.ts index 71bfad723ae..24bd7946515 100644 --- a/tests/providers/ollama/ollama-native-parser.test.ts +++ b/tests/providers/ollama/ollama-native-parser.test.ts @@ -376,6 +376,41 @@ describe("ollama-native — tool calls", () => { expect(both.filter(e => e.type === "tool_call_start")).toHaveLength(2); }); + test("rejects oversized tool names and too many pending calls", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const oversized = await collect(adapter, ndjsonResponse([ + frame({ + role: "assistant", + tool_calls: [{ index: 0, function: { name: "x".repeat(1025), arguments: {} } }], + }, false), + ])); + expect(oversized.at(-1)).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" }); + expect((oversized.at(-1) as { message?: string }).message).toContain("name exceeded 1024 bytes"); + + const calls = Array.from({ length: 129 }, (_, index) => ({ + index, + function: { name: `tool_${index}`, arguments: {} }, + })); + const excessive = await collect(adapter, ndjsonResponse([ + frame({ role: "assistant", tool_calls: calls }, false), + ])); + expect(excessive.at(-1)).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" }); + expect((excessive.at(-1) as { message?: string }).message).toContain("exceeded 128 pending tool calls"); + }); + + test("charges retained tool names to the aggregate translator budget", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget({ maxTurnBytes: 2500 }); + const frames = Array.from({ length: 3 }, (_, index) => frame({ + role: "assistant", + tool_calls: [{ function: { index, name: `${index}${"n".repeat(699)}`, arguments: {} } }], + }, false)); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(ndjsonResponse(frames), budget)) events.push(event); + expect(events.at(-1)).toMatchObject({ type: "error", code: "translation_buffer_limit" }); + expect(budget.snapshot().overflows).toBe(1); + }); + test("tool-result replay pairs a toolResult message with its call id", () => { const adapter = createOllamaNativeAdapter(provider()); const built = adapter.buildRequest(parsedWith([ From bc10d7f1c2861826521a92aade7830f6fee6a7e8 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:33:32 +0900 Subject: [PATCH 2/3] test(ollama): lower budget cap so name charges alone overflow --- tests/providers/ollama/ollama-native-parser.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/providers/ollama/ollama-native-parser.test.ts b/tests/providers/ollama/ollama-native-parser.test.ts index 24bd7946515..f3012496cc2 100644 --- a/tests/providers/ollama/ollama-native-parser.test.ts +++ b/tests/providers/ollama/ollama-native-parser.test.ts @@ -400,7 +400,9 @@ describe("ollama-native — tool calls", () => { test("charges retained tool names to the aggregate translator budget", async () => { const adapter = createOllamaNativeAdapter(provider()); - const budget = createTestTranslatorBudget({ maxTurnBytes: 2500 }); + // 2000 sits below the three name+ledger charges alone (3 x (700 + 128) = 2484), so the + // overflow can no longer hide behind the buffered JSON line the way 2500 let it. + const budget = createTestTranslatorBudget({ maxTurnBytes: 2000 }); const frames = Array.from({ length: 3 }, (_, index) => frame({ role: "assistant", tool_calls: [{ function: { index, name: `${index}${"n".repeat(699)}`, arguments: {} } }], From dabadb763f77e7ae5fd2557598a84c5f39aa5b62 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:13:08 +0900 Subject: [PATCH 3/3] fix(ollama): charge tool-name metadata to shared budget, not per-call allowance --- src/adapters/ollama-native.ts | 22 ++++++++++++++++--- .../ollama/ollama-native-parser.test.ts | 19 ++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/adapters/ollama-native.ts b/src/adapters/ollama-native.ts index 61d6480f466..2f95c5dbdc9 100644 --- a/src/adapters/ollama-native.ts +++ b/src/adapters/ollama-native.ts @@ -88,6 +88,7 @@ interface NativeStreamToolCall { nativeIndex?: number; arguments: Record; argumentBytes: number; + metadataBytes: number; } interface NativeStreamState { @@ -638,16 +639,25 @@ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budg ...(index !== undefined ? { nativeIndex: index } : {}), arguments: args, argumentBytes: 0, + metadataBytes: 0, }; budget.openCall(call.budgetKey); try { - budget.chargeRetained(nameBytes + NATIVE_TOOL_CALL_BOOKKEEPING_BYTES, { + const metadataBytes = nameBytes + NATIVE_TOOL_CALL_BOOKKEEPING_BYTES; + // Name and bookkeeping are retained call metadata, not arguments: charging them to the + // call's budget key would consume the per-call argument allowance. Keep them on the + // shared retained budget and release them when the call closes. + budget.chargeRetained(metadataBytes, { kind: "tool_args", - callId: call.budgetKey, }); + call.metadataBytes = metadataBytes; replaceNativeToolArguments(call, args, budget); state.toolCalls.set(key, call); } catch (error) { + if (call.metadataBytes > 0) { + budget.releaseRetained(call.metadataBytes, { kind: "tool_args" }); + call.metadataBytes = 0; + } budget.closeCall(call.budgetKey); throw error; } @@ -702,7 +712,13 @@ function replaceNativeToolArguments( } function releaseNativeStateBuffers(state: NativeStreamState, budget: TranslatorBudget): void { - for (const call of state.toolCalls.values()) budget.closeCall(call.budgetKey); + for (const call of state.toolCalls.values()) { + if (call.metadataBytes > 0) { + budget.releaseRetained(call.metadataBytes, { kind: "tool_args" }); + call.metadataBytes = 0; + } + budget.closeCall(call.budgetKey); + } } function nativeBodyMessage(value: unknown): JsonRecord { diff --git a/tests/providers/ollama/ollama-native-parser.test.ts b/tests/providers/ollama/ollama-native-parser.test.ts index f3012496cc2..76f9194b646 100644 --- a/tests/providers/ollama/ollama-native-parser.test.ts +++ b/tests/providers/ollama/ollama-native-parser.test.ts @@ -387,6 +387,25 @@ describe("ollama-native — tool calls", () => { expect(oversized.at(-1)).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" }); expect((oversized.at(-1) as { message?: string }).message).toContain("name exceeded 1024 bytes"); + // The limit counts UTF-8 bytes, not JS code units: 513 multibyte characters exceed it even + // though the string is shorter than 1024 code units, while exactly 1024 bytes is accepted. + const oversizedUtf8 = await collect(adapter, ndjsonResponse([ + frame({ + role: "assistant", + tool_calls: [{ index: 0, function: { name: "\u00e9".repeat(513), arguments: {} } }], + }, true), + ])); + expect(oversizedUtf8.at(-1)).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" }); + expect((oversizedUtf8.at(-1) as { message?: string }).message).toContain("name exceeded 1024 bytes"); + + const boundaryUtf8 = await collect(adapter, ndjsonResponse([ + frame({ + role: "assistant", + tool_calls: [{ index: 0, function: { name: "\u00e9".repeat(512), arguments: {} } }], + }, true), + ])); + expect(boundaryUtf8.filter(e => e.type === "tool_call_start")).toHaveLength(1); + const calls = Array.from({ length: 129 }, (_, index) => ({ index, function: { name: `tool_${index}`, arguments: {} },