mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 06:41:22 +00:00
Agents: preserve unsafe integer tool args in Ollama stream
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user