fix(agents): recover prefixed malformed tool-call JSON

This commit is contained in:
Vignesh Natarajan 2026-03-28 20:22:22 -07:00
parent 64da916590
commit a2e4707cfe
No known key found for this signature in database
GPG Key ID: C5E014CC92E2A144
3 changed files with 123 additions and 13 deletions

View File

@ -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

View File

@ -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] };

View File

@ -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<string, unknown>;
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<string, unknown>, trailingSuffix: suffix }
? {
args: parsed as Record<string, unknown>,
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 {