From e675634eb32ca9d683fe2b0a11fa404afac19f18 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 11:35:39 +0100 Subject: [PATCH] fix: preserve streamed Kimi tool args on repair fallback --- CHANGELOG.md | 1 + .../pi-embedded-runner/run/attempt.test.ts | 37 ++++++++++++++ .../run/attempt.tool-call-argument-repair.ts | 51 ++++++++++++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9cf5c7ed99..d0fe3cdaecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ Docs: https://docs.openclaw.ai - Providers/GitHub Copilot: send IDE identity headers on runtime model requests and GitHub token exchange so IDE-authenticated Copilot runs stop failing with missing `Editor-Version`. (#60641) Thanks @VACInc and @vincentkoc. - Model picker/providers: treat bundled BytePlus and Volcengine plan aliases as their native providers during setup, and expose their bundled standard/coding catalogs before auth so setup can suggest the right models. (#58819) Thanks @Luckymingxuan. - Prompt caching: route Codex Responses and Anthropic Vertex through boundary-aware cache shaping, and report the actual outbound system prompt in cache traces so cache reuse and misses line up with what providers really receive. Thanks @vincentkoc. +- Agents/Kimi tool-call repair: preserve tool arguments that were already present on streamed tool calls when later malformed deltas fail reevaluation, while still dropping stale repair-only state before `toolcall_end`. - MiniMax/pricing: keep bundled MiniMax highspeed pricing distinct in provider catalogs and preserve the lower M2.5 cache-read pricing when onboarding older MiniMax models. (#54214) Thanks @octo-patch. - Agents/cache: preserve the full 3-turn prompt-cache image window across tool loops, keep colliding bundled MCP tool definitions deterministic, and reapply Anthropic Vertex cache shaping after payload hook replacements so KV/cache reuse stays stable. Thanks @vincentkoc. - Device pairing: reject rotating device tokens into roles that were never approved during pairing, and keep reconnect role checks bounded to the paired device's approved role set. (#60462) Thanks @eleqtrizit. diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 3f2bd931bb7..63d9dff1876 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -1776,6 +1776,43 @@ describe("wrapStreamFnRepairMalformedToolCallArguments", () => { expect(partialToolCall.arguments).toEqual({}); expect(streamedToolCall.arguments).toEqual({}); }); + + it("preserves preexisting tool arguments when later reevaluation fails", async () => { + const partialToolCall = { + type: "toolCall", + name: "read", + arguments: { path: "/etc/hosts" }, + }; + 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: "}", + 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({ path: "/etc/hosts" }); + 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 d41e0e1b5c3..3781f5bce90 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 @@ -171,6 +171,30 @@ function repairToolCallArgumentsInMessage( typedBlock.arguments = repairedArgs; } +function hasMeaningfulToolCallArgumentsInMessage(message: unknown, contentIndex: number): boolean { + if (!message || typeof message !== "object") { + return false; + } + const content = (message as { content?: unknown }).content; + if (!Array.isArray(content)) { + return false; + } + const block = content[contentIndex]; + if (!block || typeof block !== "object") { + return false; + } + const typedBlock = block as { type?: unknown; arguments?: unknown }; + if (!isToolCallBlockType(typedBlock.type)) { + return false; + } + return ( + typedBlock.arguments !== null && + typeof typedBlock.arguments === "object" && + !Array.isArray(typedBlock.arguments) && + Object.keys(typedBlock.arguments as Record).length > 0 + ); +} + function clearToolCallArgumentsInMessage(message: unknown, contentIndex: number): void { if (!message || typeof message !== "object") { return; @@ -211,6 +235,7 @@ function wrapStreamRepairMalformedToolCallArguments( ): ReturnType { const partialJsonByIndex = new Map(); const repairedArgsByIndex = new Map>(); + const hadPreexistingArgsByIndex = new Set(); const disabledIndices = new Set(); const loggedRepairIndices = new Set(); const originalResult = stream.result.bind(stream); @@ -219,6 +244,7 @@ function wrapStreamRepairMalformedToolCallArguments( repairMalformedToolCallArgumentsInMessage(message, repairedArgsByIndex); partialJsonByIndex.clear(); repairedArgsByIndex.clear(); + hadPreexistingArgsByIndex.clear(); disabledIndices.clear(); loggedRepairIndices.clear(); return message; @@ -262,8 +288,16 @@ function wrapStreamRepairMalformedToolCallArguments( shouldAttemptMalformedToolCallRepair(nextPartialJson, event.delta) || repairedArgsByIndex.has(event.contentIndex); if (shouldReevaluateRepair) { + const hadRepairState = repairedArgsByIndex.has(event.contentIndex); const repair = tryExtractUsableToolCallArguments(nextPartialJson); if (repair) { + if ( + !hadRepairState && + (hasMeaningfulToolCallArgumentsInMessage(event.partial, event.contentIndex) || + hasMeaningfulToolCallArgumentsInMessage(event.message, event.contentIndex)) + ) { + hadPreexistingArgsByIndex.add(event.contentIndex); + } repairedArgsByIndex.set(event.contentIndex, repair.args); repairToolCallArgumentsInMessage(event.partial, event.contentIndex, repair.args); repairToolCallArgumentsInMessage(event.message, event.contentIndex, repair.args); @@ -275,8 +309,20 @@ function wrapStreamRepairMalformedToolCallArguments( } } else { repairedArgsByIndex.delete(event.contentIndex); - clearToolCallArgumentsInMessage(event.partial, event.contentIndex); - clearToolCallArgumentsInMessage(event.message, event.contentIndex); + // Keep args that were already present on the streamed message, but + // clear repair-only state so stale repaired args do not get replayed. + const hadPreexistingArgs = + hadPreexistingArgsByIndex.has(event.contentIndex) || + (!hadRepairState && + (hasMeaningfulToolCallArgumentsInMessage(event.partial, event.contentIndex) || + hasMeaningfulToolCallArgumentsInMessage( + event.message, + event.contentIndex, + ))); + if (!hadPreexistingArgs) { + clearToolCallArgumentsInMessage(event.partial, event.contentIndex); + clearToolCallArgumentsInMessage(event.message, event.contentIndex); + } } } } @@ -294,6 +340,7 @@ function wrapStreamRepairMalformedToolCallArguments( repairToolCallArgumentsInMessage(event.message, event.contentIndex, repairedArgs); } partialJsonByIndex.delete(event.contentIndex); + hadPreexistingArgsByIndex.delete(event.contentIndex); disabledIndices.delete(event.contentIndex); loggedRepairIndices.delete(event.contentIndex); }