diff --git a/src/adapters/ollama-native.ts b/src/adapters/ollama-native.ts index 569ae90622..b8789bf4dc 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 { @@ -558,6 +562,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); @@ -570,6 +578,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) { @@ -590,6 +601,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/ollama-native-parser.test.ts b/tests/ollama-native-parser.test.ts index 589d84e3fc..0e2903223e 100644 --- a/tests/ollama-native-parser.test.ts +++ b/tests/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([