fix(agent): fail tool calls from length-truncated assistant messages (#6285)

* fix(ai,agent): stop salvaging malformed tool-call argument JSON

Finalized tool-call arguments must strict-parse (allowing only lossless
string-escape repair). When the streamed JSON is truncated or malformed,
the raw JSON is preserved on ToolCall.malformedArguments, arguments
become {}, and the agent loop refuses to execute the call with an error
tool result instead of running it with silently salvaged partial
arguments. Partial parsing is now only used for streaming previews.

* fix(agent): fail tool calls from length-truncated assistant messages

Reverts the malformed-arguments tracking approach in favor of handling
truncation in the agent loop: when an assistant message stops with
"length", all tool calls in it are potentially borked (streamed
arguments are salvage-parsed), so none are executed. Each gets an error
tool result telling the model to re-issue the call, and the loop
continues. No new fields on ToolCall and no provider changes.

fixes #6284
This commit is contained in:
Armin Ronacher
2026-07-07 14:30:31 +02:00
committed by GitHub
parent 8a2ce5a540
commit 351efc828b
2 changed files with 114 additions and 1 deletions
+73
View File
@@ -307,6 +307,79 @@ describe("agentLoop with AgentMessage", () => {
}
});
it("should not execute tool calls from a length-truncated assistant message", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executed.push(params.value);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
};
let callIndex = 0;
const streamFn = () => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
if (callIndex === 0) {
// Output hit the token limit mid tool call. The salvage parser can
// produce arguments that validate but are silently truncated, so
// nothing in this message may execute.
const message = createAssistantMessage(
[{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hel" } }],
"length",
);
stream.push({ type: "done", reason: "length", message });
} else {
const message = createAssistantMessage([{ type: "text", text: "done" }]);
stream.push({ type: "done", reason: "stop", message });
}
callIndex++;
});
return stream;
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, streamFn);
for await (const event of stream) {
events.push(event);
}
// The tool must never execute with potentially truncated arguments.
expect(executed).toEqual([]);
const toolEnd = events.find((e) => e.type === "tool_execution_end");
expect(toolEnd).toBeDefined();
if (toolEnd?.type === "tool_execution_end") {
expect(toolEnd.isError).toBe(true);
const text = toolEnd.result.content.find((c: { type: string }) => c.type === "text");
expect(text && "text" in text ? text.text : "").toContain("output token limit");
}
// The loop continues so the model can re-issue the tool call.
expect(callIndex).toBe(2);
const messages = await stream.result();
expect(messages[messages.length - 1].role).toBe("assistant");
});
it("should execute mutated beforeToolCall args without revalidation", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const executed: Array<string | number> = [];