fix: preserve streamed Kimi tool args on repair fallback

This commit is contained in:
Peter Steinberger 2026-04-04 11:35:39 +01:00
parent 5bef64bc31
commit e675634eb3
No known key found for this signature in database
3 changed files with 87 additions and 2 deletions

View File

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

View File

@ -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", () => {

View File

@ -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<string, unknown>).length > 0
);
}
function clearToolCallArgumentsInMessage(message: unknown, contentIndex: number): void {
if (!message || typeof message !== "object") {
return;
@ -211,6 +235,7 @@ function wrapStreamRepairMalformedToolCallArguments(
): ReturnType<typeof streamSimple> {
const partialJsonByIndex = new Map<number, string>();
const repairedArgsByIndex = new Map<number, Record<string, unknown>>();
const hadPreexistingArgsByIndex = new Set<number>();
const disabledIndices = new Set<number>();
const loggedRepairIndices = new Set<number>();
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);
}