From a2e4707cfe4cb6b63d50cedb6e78a519893397aa Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 28 Mar 2026 20:22:22 -0700 Subject: [PATCH] fix(agents): recover prefixed malformed tool-call JSON --- CHANGELOG.md | 1 + .../pi-embedded-runner/run/attempt.test.ts | 75 +++++++++++++++++++ .../run/attempt.tool-call-argument-repair.ts | 60 +++++++++++---- 3 files changed, 123 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd527a4a0bc..bbe965efa50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -296,6 +296,7 @@ Docs: https://docs.openclaw.ai - Security/session policy: require sender ownership for `/send` policy changes so command-authorized non-owners cannot rewrite owner-only session delivery policy. - Security/bash stop: route `/bash stop` through the hardened process-tree killer so invalid or attacker-influenced SIGKILL targets cannot escape the intended bash-session scope. - Security/installer: hide staged project `.npmrc` files during skill and package installs so npm registry and git settings inside the stage directory cannot hijack trusted installs. +- Agents/tool-call repair: recover malformed Kimi/OpenRouter tool-call argument streams when provider preambles appear before JSON payloads, and fail closed on non-tool leading text so fragment strings do not leak into filesystem path arguments during sub-agent runs. (#56560) Thanks @Originalwhite. ## 2026.3.23 diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 89426348788..fe4758dddae 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -1550,6 +1550,81 @@ describe("wrapStreamFnRepairMalformedToolCallArguments", () => { expect(result).toBe(finalMessage); }); + it("repairs tool arguments when malformed tool-call preamble appears before JSON", async () => { + const partialToolCall = { type: "toolCall", name: "write", arguments: {} }; + const streamedToolCall = { type: "toolCall", name: "write", arguments: {} }; + const endMessageToolCall = { type: "toolCall", name: "write", arguments: {} }; + const finalToolCall = { type: "toolCall", name: "write", arguments: {} }; + const partialMessage = { role: "assistant", content: [partialToolCall] }; + const endMessage = { role: "assistant", content: [endMessageToolCall] }; + const finalMessage = { role: "assistant", content: [finalToolCall] }; + const baseFn = vi.fn(() => + createFakeStream({ + events: [ + { + type: "toolcall_delta", + contentIndex: 0, + delta: '.functions.write:8 \n{"path":"/tmp/report.txt"}', + partial: partialMessage, + }, + { + type: "toolcall_end", + contentIndex: 0, + toolCall: streamedToolCall, + partial: partialMessage, + message: endMessage, + }, + ], + resultMessage: finalMessage, + }), + ); + + const stream = await invokeWrappedStream(baseFn); + for await (const _item of stream) { + // drain + } + const result = await stream.result(); + + expect(partialToolCall.arguments).toEqual({ path: "/tmp/report.txt" }); + expect(streamedToolCall.arguments).toEqual({ path: "/tmp/report.txt" }); + expect(endMessageToolCall.arguments).toEqual({ path: "/tmp/report.txt" }); + expect(finalToolCall.arguments).toEqual({ path: "/tmp/report.txt" }); + expect(result).toBe(finalMessage); + }); + + it("does not repair tool arguments when leading text is not tool-call metadata", async () => { + const partialToolCall = { type: "toolCall", name: "read", arguments: {} }; + const streamedToolCall = { type: "toolCall", name: "read", arguments: {} }; + const partialMessage = { role: "assistant", content: [partialToolCall] }; + const baseFn = vi.fn(() => + createFakeStream({ + events: [ + { + type: "toolcall_delta", + contentIndex: 0, + delta: 'please use {"path":"/tmp/report.txt"}', + partial: partialMessage, + }, + { + type: "toolcall_end", + contentIndex: 0, + toolCall: streamedToolCall, + partial: partialMessage, + }, + ], + resultMessage: { role: "assistant", content: [partialToolCall] }, + }), + ); + + const stream = await invokeWrappedStream(baseFn); + for await (const _item of stream) { + // drain + } + + expect(partialToolCall.arguments).toEqual({}); + expect(streamedToolCall.arguments).toEqual({}); + }); + it("keeps incomplete partial JSON unchanged until a complete object exists", async () => { const partialToolCall = { type: "toolCall", name: "read", arguments: {} }; const partialMessage = { role: "assistant", content: [partialToolCall] }; diff --git a/src/agents/pi-embedded-runner/run/attempt.tool-call-argument-repair.ts b/src/agents/pi-embedded-runner/run/attempt.tool-call-argument-repair.ts index 051da7be521..0175364eac0 100644 --- a/src/agents/pi-embedded-runner/run/attempt.tool-call-argument-repair.ts +++ b/src/agents/pi-embedded-runner/run/attempt.tool-call-argument-repair.ts @@ -7,13 +7,21 @@ function isToolCallBlockType(type: unknown): boolean { return type === "toolCall" || type === "toolUse" || type === "functionCall"; } -function extractBalancedJsonPrefix(raw: string): string | null { +type BalancedJsonPrefix = { + json: string; + startIndex: number; +}; + +function extractBalancedJsonPrefix(raw: string): BalancedJsonPrefix | null { let start = 0; - while (start < raw.length && /\s/.test(raw[start] ?? "")) { + while (start < raw.length) { + const char = raw[start]; + if (char === "{" || char === "[") { + break; + } start += 1; } - const startChar = raw[start]; - if (startChar !== "{" && startChar !== "[") { + if (start >= raw.length) { return null; } @@ -46,7 +54,7 @@ function extractBalancedJsonPrefix(raw: string): string | null { if (char === "}" || char === "]") { depth -= 1; if (depth === 0) { - return raw.slice(start, i + 1); + return { json: raw.slice(start, i + 1), startIndex: start }; } } } @@ -54,7 +62,9 @@ function extractBalancedJsonPrefix(raw: string): string | null { } const MAX_TOOLCALL_REPAIR_BUFFER_CHARS = 64_000; +const MAX_TOOLCALL_REPAIR_LEADING_CHARS = 96; const MAX_TOOLCALL_REPAIR_TRAILING_CHARS = 3; +const TOOLCALL_REPAIR_ALLOWED_LEADING_RE = /^[a-z0-9\s"'`.:/_\\-]+$/i; const TOOLCALL_REPAIR_ALLOWED_TRAILING_RE = /^[^\s{}[\]":,\\]{1,3}$/; function shouldAttemptMalformedToolCallRepair(partialJson: string, delta: string): boolean { @@ -71,9 +81,23 @@ function shouldAttemptMalformedToolCallRepair(partialJson: string, delta: string type ToolCallArgumentRepair = { args: Record; + leadingPrefix: string; trailingSuffix: string; }; +function isAllowedToolCallRepairLeadingPrefix(prefix: string): boolean { + if (!prefix) { + return true; + } + if (prefix.length > MAX_TOOLCALL_REPAIR_LEADING_CHARS) { + return false; + } + if (!TOOLCALL_REPAIR_ALLOWED_LEADING_RE.test(prefix)) { + return false; + } + return /^[.:'"`-]/.test(prefix) || /^(?:functions?|tools?)[._:/-]?/i.test(prefix); +} + function tryParseMalformedToolCallArguments(raw: string): ToolCallArgumentRepair | undefined { if (!raw.trim()) { return undefined; @@ -82,22 +106,32 @@ function tryParseMalformedToolCallArguments(raw: string): ToolCallArgumentRepair JSON.parse(raw); return undefined; } catch { - const jsonPrefix = extractBalancedJsonPrefix(raw); - if (!jsonPrefix) { + const extracted = extractBalancedJsonPrefix(raw); + if (!extracted) { + return undefined; + } + const leadingPrefix = raw.slice(0, extracted.startIndex).trim(); + if (!isAllowedToolCallRepairLeadingPrefix(leadingPrefix)) { + return undefined; + } + const suffix = raw.slice(extracted.startIndex + extracted.json.length).trim(); + if (leadingPrefix.length === 0 && suffix.length === 0) { return undefined; } - const suffix = raw.slice(raw.indexOf(jsonPrefix) + jsonPrefix.length).trim(); if ( - suffix.length === 0 || suffix.length > MAX_TOOLCALL_REPAIR_TRAILING_CHARS || - !TOOLCALL_REPAIR_ALLOWED_TRAILING_RE.test(suffix) + (suffix.length > 0 && !TOOLCALL_REPAIR_ALLOWED_TRAILING_RE.test(suffix)) ) { return undefined; } try { - const parsed = JSON.parse(jsonPrefix) as unknown; + const parsed = JSON.parse(extracted.json) as unknown; return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? { args: parsed as Record, trailingSuffix: suffix } + ? { + args: parsed as Record, + leadingPrefix, + trailingSuffix: suffix, + } : undefined; } catch { return undefined; @@ -224,7 +258,7 @@ function wrapStreamRepairMalformedToolCallArguments( if (!loggedRepairIndices.has(event.contentIndex)) { loggedRepairIndices.add(event.contentIndex); log.warn( - `repairing Kimi tool call arguments after ${repair.trailingSuffix.length} trailing chars`, + `repairing Kimi tool call arguments with ${repair.leadingPrefix.length} leading chars and ${repair.trailingSuffix.length} trailing chars`, ); } } else {