diff --git a/src/agents/ollama-stream.test.ts b/src/agents/ollama-stream.test.ts index ded8064ea19..dd1956505fc 100644 --- a/src/agents/ollama-stream.test.ts +++ b/src/agents/ollama-stream.test.ts @@ -54,6 +54,77 @@ describe("convertToOllamaMessages", () => { ]); }); + it("parses OpenAI-style toolCall arguments JSON strings for Ollama", () => { + const messages = [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_1", + name: "get_weather", + arguments: '{"location": "Beijing"}', + }, + ], + }, + ]; + const result = convertToOllamaMessages(messages); + expect(result[0].tool_calls).toEqual([ + { function: { name: "get_weather", arguments: { location: "Beijing" } } }, + ]); + }); + + it("preserves unsafe integer literals in OpenAI-style toolCall argument JSON strings", () => { + const messages = [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_1", + name: "send", + arguments: '{"target":12345678901234567890}', + }, + ], + }, + ]; + const result = convertToOllamaMessages(messages); + expect(result[0].tool_calls?.[0].function.arguments).toEqual({ + target: "12345678901234567890", + }); + }); + + it("parses tool_use input JSON strings for Ollama", () => { + const messages = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call_1", + name: "get_weather", + input: '{"location":"Paris"}', + }, + ], + }, + ]; + const result = convertToOllamaMessages(messages); + expect(result[0].tool_calls).toEqual([ + { function: { name: "get_weather", arguments: { location: "Paris" } } }, + ]); + }); + + it("treats invalid toolCall arguments JSON as empty object", () => { + const messages = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "broken", arguments: "{not json" }], + }, + ]; + const result = convertToOllamaMessages(messages); + expect(result[0].tool_calls).toEqual([{ function: { name: "broken", arguments: {} } }]); + }); + it("converts tool result messages with 'tool' role", () => { const messages = [{ role: "tool", content: "file1.txt\nfile2.txt" }]; const result = convertToOllamaMessages(messages); @@ -166,6 +237,29 @@ describe("buildAssistantMessage", () => { expect(toolCall.id).toMatch(/^ollama_call_[0-9a-f-]{36}$/); }); + it("normalizes string tool call arguments from Ollama wire format", () => { + const response = { + model: "qwen3:32b", + created_at: "2026-01-01T00:00:00Z", + message: { + role: "assistant" as const, + content: "", + tool_calls: [{ function: { name: "get_weather", arguments: '{"location": "Beijing"}' } }], + }, + done: true, + prompt_eval_count: 20, + eval_count: 10, + }; + const result = buildAssistantMessage(response, modelInfo); + const toolCall = result.content[0] as { + type: "toolCall"; + name: string; + arguments: Record; + }; + expect(toolCall.name).toBe("get_weather"); + expect(toolCall.arguments).toEqual({ location: "Beijing" }); + }); + it("sets all costs to zero for local models", () => { const response = { model: "qwen3:32b", @@ -261,7 +355,7 @@ describe("parseNdjsonStream", () => { // Simulate the accumulation logic from createOllamaStreamFn const accumulatedToolCalls: Array<{ - function: { name: string; arguments: Record }; + function: { name: string; arguments: Record | string }; }> = []; const chunks = []; for await (const chunk of parseNdjsonStream(reader)) { diff --git a/src/agents/ollama-stream.ts b/src/agents/ollama-stream.ts index f332ad1fd83..a0c47647d85 100644 --- a/src/agents/ollama-stream.ts +++ b/src/agents/ollama-stream.ts @@ -66,7 +66,8 @@ interface OllamaTool { interface OllamaToolCall { function: { name: string; - arguments: Record; + /** Wire format may be a JSON string; normalize before re-sending to Ollama. */ + arguments: Record | string; }; } @@ -221,8 +222,39 @@ interface OllamaChatResponse { type InputContentPart = | { type: "text"; text: string } | { type: "image"; data: string } - | { type: "toolCall"; id: string; name: string; arguments: Record } - | { type: "tool_use"; id: string; name: string; input: Record }; + | { + type: "toolCall"; + id: string; + name: string; + arguments: Record | string; + } + | { type: "tool_use"; id: string; name: string; input: Record | string }; + +/** + * Normalize tool `arguments` / `input` for Ollama: OpenAI-style messages use a JSON string; + * Ollama expects an object. Uses the same unsafe-integer quoting as NDJSON parsing. + */ +function normalizeOllamaToolArguments(raw: unknown): Record { + if (typeof raw === "string") { + const trimmed = raw.trim(); + if (!trimmed) { + return {}; + } + try { + const parsed = parseJsonPreservingUnsafeIntegers(trimmed); + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + return {}; + } catch { + return {}; + } + } + if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) { + return raw as Record; + } + return {}; +} function extractTextContent(content: unknown): string { if (typeof content === "string") { @@ -254,9 +286,13 @@ function extractToolCalls(content: unknown): OllamaToolCall[] { const result: OllamaToolCall[] = []; for (const part of parts) { if (part.type === "toolCall") { - result.push({ function: { name: part.name, arguments: part.arguments } }); + result.push({ + function: { name: part.name, arguments: normalizeOllamaToolArguments(part.arguments) }, + }); } else if (part.type === "tool_use") { - result.push({ function: { name: part.name, arguments: part.input } }); + result.push({ + function: { name: part.name, arguments: normalizeOllamaToolArguments(part.input) }, + }); } } return result; @@ -355,7 +391,7 @@ export function buildAssistantMessage( type: "toolCall", id: `ollama_call_${randomUUID()}`, name: tc.function.name, - arguments: tc.function.arguments, + arguments: normalizeOllamaToolArguments(tc.function.arguments), }); } }