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>
This commit is contained in:
yuanchao 2026-03-29 11:37:50 +08:00 committed by GitHub
parent a2e4707cfe
commit ec7f19e2ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 105 additions and 6 deletions

View File

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

View File

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

View File

@ -81,6 +81,7 @@ function shouldAttemptMalformedToolCallRepair(partialJson: string, delta: string
type ToolCallArgumentRepair = {
args: Record<string, unknown>;
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<string, unknown>,
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<string, unknown>,
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`,