mirror of https://github.com/openclaw/openclaw.git
fix(agents): recover prefixed malformed tool-call JSON
This commit is contained in:
parent
64da916590
commit
a2e4707cfe
|
|
@ -296,6 +296,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Security/session policy: require sender ownership for `/send` policy changes so command-authorized non-owners cannot rewrite owner-only session delivery policy.
|
||||
- Security/bash stop: route `/bash stop` through the hardened process-tree killer so invalid or attacker-influenced SIGKILL targets cannot escape the intended bash-session scope.
|
||||
- Security/installer: hide staged project `.npmrc` files during skill and package installs so npm registry and git settings inside the stage directory cannot hijack trusted installs.
|
||||
- Agents/tool-call repair: recover malformed Kimi/OpenRouter tool-call argument streams when provider preambles appear before JSON payloads, and fail closed on non-tool leading text so fragment strings do not leak into filesystem path arguments during sub-agent runs. (#56560) Thanks @Originalwhite.
|
||||
|
||||
## 2026.3.23
|
||||
|
||||
|
|
|
|||
|
|
@ -1550,6 +1550,81 @@ describe("wrapStreamFnRepairMalformedToolCallArguments", () => {
|
|||
expect(result).toBe(finalMessage);
|
||||
});
|
||||
|
||||
it("repairs tool arguments when malformed tool-call preamble appears before JSON", async () => {
|
||||
const partialToolCall = { type: "toolCall", name: "write", arguments: {} };
|
||||
const streamedToolCall = { type: "toolCall", name: "write", arguments: {} };
|
||||
const endMessageToolCall = { type: "toolCall", name: "write", arguments: {} };
|
||||
const finalToolCall = { type: "toolCall", name: "write", 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: '.functions.write:8 \n{"path":"/tmp/report.txt"}',
|
||||
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: {} };
|
||||
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: 'please use {"path":"/tmp/report.txt"}',
|
||||
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({});
|
||||
});
|
||||
|
||||
it("keeps incomplete partial JSON unchanged until a complete object exists", async () => {
|
||||
const partialToolCall = { type: "toolCall", name: "read", arguments: {} };
|
||||
const partialMessage = { role: "assistant", content: [partialToolCall] };
|
||||
|
|
|
|||
|
|
@ -7,13 +7,21 @@ function isToolCallBlockType(type: unknown): boolean {
|
|||
return type === "toolCall" || type === "toolUse" || type === "functionCall";
|
||||
}
|
||||
|
||||
function extractBalancedJsonPrefix(raw: string): string | null {
|
||||
type BalancedJsonPrefix = {
|
||||
json: string;
|
||||
startIndex: number;
|
||||
};
|
||||
|
||||
function extractBalancedJsonPrefix(raw: string): BalancedJsonPrefix | null {
|
||||
let start = 0;
|
||||
while (start < raw.length && /\s/.test(raw[start] ?? "")) {
|
||||
while (start < raw.length) {
|
||||
const char = raw[start];
|
||||
if (char === "{" || char === "[") {
|
||||
break;
|
||||
}
|
||||
start += 1;
|
||||
}
|
||||
const startChar = raw[start];
|
||||
if (startChar !== "{" && startChar !== "[") {
|
||||
if (start >= raw.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +54,7 @@ function extractBalancedJsonPrefix(raw: string): string | null {
|
|||
if (char === "}" || char === "]") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return raw.slice(start, i + 1);
|
||||
return { json: raw.slice(start, i + 1), startIndex: start };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -54,7 +62,9 @@ function extractBalancedJsonPrefix(raw: string): string | null {
|
|||
}
|
||||
|
||||
const MAX_TOOLCALL_REPAIR_BUFFER_CHARS = 64_000;
|
||||
const MAX_TOOLCALL_REPAIR_LEADING_CHARS = 96;
|
||||
const MAX_TOOLCALL_REPAIR_TRAILING_CHARS = 3;
|
||||
const TOOLCALL_REPAIR_ALLOWED_LEADING_RE = /^[a-z0-9\s"'`.:/_\\-]+$/i;
|
||||
const TOOLCALL_REPAIR_ALLOWED_TRAILING_RE = /^[^\s{}[\]":,\\]{1,3}$/;
|
||||
|
||||
function shouldAttemptMalformedToolCallRepair(partialJson: string, delta: string): boolean {
|
||||
|
|
@ -71,9 +81,23 @@ function shouldAttemptMalformedToolCallRepair(partialJson: string, delta: string
|
|||
|
||||
type ToolCallArgumentRepair = {
|
||||
args: Record<string, unknown>;
|
||||
leadingPrefix: string;
|
||||
trailingSuffix: string;
|
||||
};
|
||||
|
||||
function isAllowedToolCallRepairLeadingPrefix(prefix: string): boolean {
|
||||
if (!prefix) {
|
||||
return true;
|
||||
}
|
||||
if (prefix.length > MAX_TOOLCALL_REPAIR_LEADING_CHARS) {
|
||||
return false;
|
||||
}
|
||||
if (!TOOLCALL_REPAIR_ALLOWED_LEADING_RE.test(prefix)) {
|
||||
return false;
|
||||
}
|
||||
return /^[.:'"`-]/.test(prefix) || /^(?:functions?|tools?)[._:/-]?/i.test(prefix);
|
||||
}
|
||||
|
||||
function tryParseMalformedToolCallArguments(raw: string): ToolCallArgumentRepair | undefined {
|
||||
if (!raw.trim()) {
|
||||
return undefined;
|
||||
|
|
@ -82,22 +106,32 @@ function tryParseMalformedToolCallArguments(raw: string): ToolCallArgumentRepair
|
|||
JSON.parse(raw);
|
||||
return undefined;
|
||||
} catch {
|
||||
const jsonPrefix = extractBalancedJsonPrefix(raw);
|
||||
if (!jsonPrefix) {
|
||||
const extracted = extractBalancedJsonPrefix(raw);
|
||||
if (!extracted) {
|
||||
return undefined;
|
||||
}
|
||||
const leadingPrefix = raw.slice(0, extracted.startIndex).trim();
|
||||
if (!isAllowedToolCallRepairLeadingPrefix(leadingPrefix)) {
|
||||
return undefined;
|
||||
}
|
||||
const suffix = raw.slice(extracted.startIndex + extracted.json.length).trim();
|
||||
if (leadingPrefix.length === 0 && suffix.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const suffix = raw.slice(raw.indexOf(jsonPrefix) + jsonPrefix.length).trim();
|
||||
if (
|
||||
suffix.length === 0 ||
|
||||
suffix.length > MAX_TOOLCALL_REPAIR_TRAILING_CHARS ||
|
||||
!TOOLCALL_REPAIR_ALLOWED_TRAILING_RE.test(suffix)
|
||||
(suffix.length > 0 && !TOOLCALL_REPAIR_ALLOWED_TRAILING_RE.test(suffix))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(jsonPrefix) as unknown;
|
||||
const parsed = JSON.parse(extracted.json) as unknown;
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? { args: parsed as Record<string, unknown>, trailingSuffix: suffix }
|
||||
? {
|
||||
args: parsed as Record<string, unknown>,
|
||||
leadingPrefix,
|
||||
trailingSuffix: suffix,
|
||||
}
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
|
|
@ -224,7 +258,7 @@ function wrapStreamRepairMalformedToolCallArguments(
|
|||
if (!loggedRepairIndices.has(event.contentIndex)) {
|
||||
loggedRepairIndices.add(event.contentIndex);
|
||||
log.warn(
|
||||
`repairing Kimi tool call arguments after ${repair.trailingSuffix.length} trailing chars`,
|
||||
`repairing Kimi tool call arguments with ${repair.leadingPrefix.length} leading chars and ${repair.trailingSuffix.length} trailing chars`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Reference in New Issue