Agents: preserve unsafe integer tool args in Ollama stream

This commit is contained in:
Vignesh Natarajan
2026-02-21 19:08:31 -08:00
parent 4550a52007
commit c45a5c551f
3 changed files with 161 additions and 2 deletions

View File

@@ -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", () => {

View File

@@ -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)}`);
}