mirror of https://github.com/openclaw/openclaw.git
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:
parent
a2e4707cfe
commit
ec7f19e2ef
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue