From ec7f19e2ef7abc1868c3d1fe088cb5263814bb81 Mon Sep 17 00:00:00 2001 From: yuanchao <807088414@qq.com> Date: Sun, 29 Mar 2026 11:37:50 +0800 Subject: [PATCH] fix(kimi): preserve valid Anthropic-compatible toolCall arguments in malformed-args repair path (openclaw#54491) Verified: - pnpm build - pnpm check - pnpm test -- src/agents/pi-embedded-runner/run/attempt.test.ts Co-authored-by: yuanaichi <7549002+yuanaichi@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + .../pi-embedded-runner/run/attempt.test.ts | 86 +++++++++++++++++++ .../run/attempt.tool-call-argument-repair.ts | 24 ++++-- 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe965efa50..a428823d0b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Hooks/config: accept runtime channel plugin ids in `hooks.mappings[].channel` (for example `feishu`) instead of rejecting non-core channels during config validation. (#56226) Thanks @AiKrai001. - TUI/chat: keep optimistic outbound user messages visible during active runs by deferring local-run binding until the first gateway chat event reveals the real run id, preventing premature history reloads from wiping pending local sends. (#54722) Thanks @seanturner001. - TUI/model picker: keep searchable `/model` and `/models` input mode from hijacking `j`/`k` as navigation keys, and harden width bounds under `m`-filtered model lists so search no longer crashes on long rows. (#30156) Thanks @briannicholls. +- Agents/Kimi: preserve already-valid Anthropic-compatible tool call argument objects while still clearing cached repairs when later trailing junk exceeds the repair allowance. (#54491) Thanks @yuanaichi. ## 2026.3.28 diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index fe4758dddae..2acab353454 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -1591,6 +1591,53 @@ describe("wrapStreamFnRepairMalformedToolCallArguments", () => { expect(finalToolCall.arguments).toEqual({ path: "/tmp/report.txt" }); expect(result).toBe(finalMessage); }); + it("preserves anthropic-compatible tool arguments when the streamed JSON is already valid", async () => { + const partialToolCall = { type: "toolCall", name: "read", arguments: {} }; + const streamedToolCall = { type: "toolCall", name: "read", arguments: {} }; + const endMessageToolCall = { type: "toolCall", name: "read", arguments: {} }; + const finalToolCall = { type: "toolCall", name: "read", 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: '{"path":"/tmp/report.txt"', + partial: partialMessage, + }, + { + type: "toolcall_delta", + contentIndex: 0, + delta: "}", + 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: {} }; @@ -1727,6 +1774,45 @@ describe("wrapStreamFnRepairMalformedToolCallArguments", () => { expect(partialToolCall.arguments).toEqual({}); expect(streamedToolCall.arguments).toEqual({}); }); + + it("clears a cached repair when a later delta adds a single oversized trailing suffix", 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: '{"path":"/tmp/report.txt"}', + partial: partialMessage, + }, + { + type: "toolcall_delta", + contentIndex: 0, + delta: "oops", + 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({}); + }); }); describe("isOllamaCompatProvider", () => { 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 0175364eac0..d41e0e1b5c3 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 @@ -81,6 +81,7 @@ function shouldAttemptMalformedToolCallRepair(partialJson: string, delta: string type ToolCallArgumentRepair = { args: Record; + kind: "preserved" | "repaired"; leadingPrefix: string; trailingSuffix: string; }; @@ -98,13 +99,20 @@ function isAllowedToolCallRepairLeadingPrefix(prefix: string): boolean { return /^[.:'"`-]/.test(prefix) || /^(?:functions?|tools?)[._:/-]?/i.test(prefix); } -function tryParseMalformedToolCallArguments(raw: string): ToolCallArgumentRepair | undefined { +function tryExtractUsableToolCallArguments(raw: string): ToolCallArgumentRepair | undefined { if (!raw.trim()) { return undefined; } try { - JSON.parse(raw); - return undefined; + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? { + args: parsed as Record, + kind: "preserved", + leadingPrefix: "", + trailingSuffix: "", + } + : undefined; } catch { const extracted = extractBalancedJsonPrefix(raw); if (!extracted) { @@ -129,6 +137,7 @@ function tryParseMalformedToolCallArguments(raw: string): ToolCallArgumentRepair return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { args: parsed as Record, + kind: "repaired", leadingPrefix, trailingSuffix: suffix, } @@ -249,13 +258,16 @@ function wrapStreamRepairMalformedToolCallArguments( return result; } partialJsonByIndex.set(event.contentIndex, nextPartialJson); - if (shouldAttemptMalformedToolCallRepair(nextPartialJson, event.delta)) { - const repair = tryParseMalformedToolCallArguments(nextPartialJson); + const shouldReevaluateRepair = + shouldAttemptMalformedToolCallRepair(nextPartialJson, event.delta) || + repairedArgsByIndex.has(event.contentIndex); + if (shouldReevaluateRepair) { + const repair = tryExtractUsableToolCallArguments(nextPartialJson); if (repair) { repairedArgsByIndex.set(event.contentIndex, repair.args); repairToolCallArgumentsInMessage(event.partial, event.contentIndex, repair.args); repairToolCallArgumentsInMessage(event.message, event.contentIndex, repair.args); - if (!loggedRepairIndices.has(event.contentIndex)) { + if (!loggedRepairIndices.has(event.contentIndex) && repair.kind === "repaired") { loggedRepairIndices.add(event.contentIndex); log.warn( `repairing Kimi tool call arguments with ${repair.leadingPrefix.length} leading chars and ${repair.trailingSuffix.length} trailing chars`,