mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 14:54:57 +00:00
Agents: preserve unsafe integer tool args in Ollama stream
This commit is contained in:
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### 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.
|
- 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.
|
- 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:<id>]]`, `[[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.
|
- Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:<id>]]`, `[[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.
|
||||||
|
|||||||
@@ -244,6 +244,40 @@ describe("parseNdjsonStream", () => {
|
|||||||
// Final done:true chunk has no tool_calls
|
// Final done:true chunk has no tool_calls
|
||||||
expect(chunks[2].message.tool_calls).toBeUndefined();
|
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", () => {
|
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 ─────────────────────────────────────────
|
// ── Ollama /api/chat response types ─────────────────────────────────────────
|
||||||
|
|
||||||
interface OllamaChatResponse {
|
interface OllamaChatResponse {
|
||||||
@@ -262,7 +386,7 @@ export async function* parseNdjsonStream(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
yield JSON.parse(trimmed) as OllamaChatResponse;
|
yield parseJsonPreservingUnsafeIntegers(trimmed) as OllamaChatResponse;
|
||||||
} catch {
|
} catch {
|
||||||
log.warn(`Skipping malformed NDJSON line: ${trimmed.slice(0, 120)}`);
|
log.warn(`Skipping malformed NDJSON line: ${trimmed.slice(0, 120)}`);
|
||||||
}
|
}
|
||||||
@@ -271,7 +395,7 @@ export async function* parseNdjsonStream(
|
|||||||
|
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
try {
|
try {
|
||||||
yield JSON.parse(buffer.trim()) as OllamaChatResponse;
|
yield parseJsonPreservingUnsafeIntegers(buffer.trim()) as OllamaChatResponse;
|
||||||
} catch {
|
} catch {
|
||||||
log.warn(`Skipping malformed trailing data: ${buffer.trim().slice(0, 120)}`);
|
log.warn(`Skipping malformed trailing data: ${buffer.trim().slice(0, 120)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user