From c45a5c551faaeabe67b365290999c407e7c0e967 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 21 Feb 2026 19:08:31 -0800 Subject: [PATCH] Agents: preserve unsafe integer tool args in Ollama stream --- CHANGELOG.md | 1 + src/agents/ollama-stream.test.ts | 34 ++++++++ src/agents/ollama-stream.ts | 128 ++++++++++++++++++++++++++++++- 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b3601e4641..884d10b98da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/Ollama: preserve unsafe integer tool-call arguments as exact strings during NDJSON parsing, preventing large numeric IDs from being rounded before tool execution. (#23170) Thanks @BestJoester. - Gateway/Pairing: treat operator.admin pairing tokens as satisfying operator.write requests so legacy devices stop looping through scope-upgrade prompts introduced in 2026.2.19. (#23125, #23006) Thanks @vignesh07. - Memory/QMD: add optional `memory.qmd.mcporter` search routing so QMD `query/search/vsearch` can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07. - Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:]]`, `[[audio_as_voice]]`) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces. diff --git a/src/agents/ollama-stream.test.ts b/src/agents/ollama-stream.test.ts index 0a962589220..780f761fec0 100644 --- a/src/agents/ollama-stream.test.ts +++ b/src/agents/ollama-stream.test.ts @@ -244,6 +244,40 @@ describe("parseNdjsonStream", () => { // Final done:true chunk has no tool_calls expect(chunks[2].message.tool_calls).toBeUndefined(); }); + + it("preserves unsafe integer tool arguments as exact strings", async () => { + const reader = mockNdjsonReader([ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"send","arguments":{"target":1234567890123456789,"nested":{"thread":9223372036854775807}}}}]},"done":false}', + ]); + + const chunks = []; + for await (const chunk of parseNdjsonStream(reader)) { + chunks.push(chunk); + } + + const args = chunks[0]?.message.tool_calls?.[0]?.function.arguments as + | { target?: unknown; nested?: { thread?: unknown } } + | undefined; + expect(args?.target).toBe("1234567890123456789"); + expect(args?.nested?.thread).toBe("9223372036854775807"); + }); + + it("keeps safe integer tool arguments as numbers", async () => { + const reader = mockNdjsonReader([ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"send","arguments":{"retries":3,"delayMs":2500}}}]},"done":false}', + ]); + + const chunks = []; + for await (const chunk of parseNdjsonStream(reader)) { + chunks.push(chunk); + } + + const args = chunks[0]?.message.tool_calls?.[0]?.function.arguments as + | { retries?: unknown; delayMs?: unknown } + | undefined; + expect(args?.retries).toBe(3); + expect(args?.delayMs).toBe(2500); + }); }); describe("createOllamaStreamFn", () => { diff --git a/src/agents/ollama-stream.ts b/src/agents/ollama-stream.ts index cdf379a0eb5..321d26b5452 100644 --- a/src/agents/ollama-stream.ts +++ b/src/agents/ollama-stream.ts @@ -49,6 +49,130 @@ interface OllamaToolCall { }; } +const MAX_SAFE_INTEGER_ABS_STR = String(Number.MAX_SAFE_INTEGER); + +function isAsciiDigit(ch: string | undefined): boolean { + return ch !== undefined && ch >= "0" && ch <= "9"; +} + +function parseJsonNumberToken( + input: string, + start: number, +): { token: string; end: number; isInteger: boolean } | null { + let idx = start; + if (input[idx] === "-") { + idx += 1; + } + if (idx >= input.length) { + return null; + } + + if (input[idx] === "0") { + idx += 1; + } else if (isAsciiDigit(input[idx]) && input[idx] !== "0") { + while (isAsciiDigit(input[idx])) { + idx += 1; + } + } else { + return null; + } + + let isInteger = true; + if (input[idx] === ".") { + isInteger = false; + idx += 1; + if (!isAsciiDigit(input[idx])) { + return null; + } + while (isAsciiDigit(input[idx])) { + idx += 1; + } + } + + if (input[idx] === "e" || input[idx] === "E") { + isInteger = false; + idx += 1; + if (input[idx] === "+" || input[idx] === "-") { + idx += 1; + } + if (!isAsciiDigit(input[idx])) { + return null; + } + while (isAsciiDigit(input[idx])) { + idx += 1; + } + } + + return { + token: input.slice(start, idx), + end: idx, + isInteger, + }; +} + +function isUnsafeIntegerLiteral(token: string): boolean { + const digits = token[0] === "-" ? token.slice(1) : token; + if (digits.length < MAX_SAFE_INTEGER_ABS_STR.length) { + return false; + } + if (digits.length > MAX_SAFE_INTEGER_ABS_STR.length) { + return true; + } + return digits > MAX_SAFE_INTEGER_ABS_STR; +} + +function quoteUnsafeIntegerLiterals(input: string): string { + let out = ""; + let inString = false; + let escaped = false; + let idx = 0; + + while (idx < input.length) { + const ch = input[idx] ?? ""; + if (inString) { + out += ch; + if (escaped) { + escaped = false; + } else if (ch === "\\") { + escaped = true; + } else if (ch === '"') { + inString = false; + } + idx += 1; + continue; + } + + if (ch === '"') { + inString = true; + out += ch; + idx += 1; + continue; + } + + if (ch === "-" || isAsciiDigit(ch)) { + const parsed = parseJsonNumberToken(input, idx); + if (parsed) { + if (parsed.isInteger && isUnsafeIntegerLiteral(parsed.token)) { + out += `"${parsed.token}"`; + } else { + out += parsed.token; + } + idx = parsed.end; + continue; + } + } + + out += ch; + idx += 1; + } + + return out; +} + +function parseJsonPreservingUnsafeIntegers(input: string): unknown { + return JSON.parse(quoteUnsafeIntegerLiterals(input)) as unknown; +} + // ── Ollama /api/chat response types ───────────────────────────────────────── interface OllamaChatResponse { @@ -262,7 +386,7 @@ export async function* parseNdjsonStream( continue; } try { - yield JSON.parse(trimmed) as OllamaChatResponse; + yield parseJsonPreservingUnsafeIntegers(trimmed) as OllamaChatResponse; } catch { log.warn(`Skipping malformed NDJSON line: ${trimmed.slice(0, 120)}`); } @@ -271,7 +395,7 @@ export async function* parseNdjsonStream( if (buffer.trim()) { try { - yield JSON.parse(buffer.trim()) as OllamaChatResponse; + yield parseJsonPreservingUnsafeIntegers(buffer.trim()) as OllamaChatResponse; } catch { log.warn(`Skipping malformed trailing data: ${buffer.trim().slice(0, 120)}`); }