fix(agent): fail tool calls from length-truncated assistant messages (#6285)
Some checks are pending
CI / build-check-test (push) Waiting to run

* 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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 114 additions and 1 deletions

View file

@ -205,7 +205,13 @@ async function runLoop(
const toolResults: ToolResultMessage[] = []; const toolResults: ToolResultMessage[] = [];
hasMoreToolCalls = false; hasMoreToolCalls = false;
if (toolCalls.length > 0) { if (toolCalls.length > 0) {
const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit); // A "length" stop means the output was cut off by the token limit, so
// every tool call in the message may carry truncated arguments. Fail
// them all instead of executing potentially borked calls.
const executedToolBatch =
message.stopReason === "length"
? await failToolCallsFromTruncatedMessage(toolCalls, emit)
: await executeToolCalls(currentContext, message, config, signal, emit);
toolResults.push(...executedToolBatch.messages); toolResults.push(...executedToolBatch.messages);
hasMoreToolCalls = !executedToolBatch.terminate; hasMoreToolCalls = !executedToolBatch.terminate;
@ -367,6 +373,40 @@ async function streamAssistantResponse(
return finalMessage; return finalMessage;
} }
/**
* Fail all tool calls from an assistant message that was truncated by the
* output token limit. Streamed tool-call arguments are finalized with a
* best-effort JSON salvage parser, so a truncated message can yield tool calls
* whose arguments parse and validate but are silently incomplete. None of them
* are safe to execute; report each as an error so the model can re-issue them.
*/
async function failToolCallsFromTruncatedMessage(
toolCalls: AgentToolCall[],
emit: AgentEventSink,
): Promise<ExecutedToolCallBatch> {
const messages: ToolResultMessage[] = [];
for (const toolCall of toolCalls) {
await emit({
type: "tool_execution_start",
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.arguments,
});
const finalized: FinalizedToolCallOutcome = {
toolCall,
result: createErrorToolResult(
`Tool call "${toolCall.name}" was not executed: the response hit the output token limit, so its arguments may be truncated. Re-issue the tool call with complete arguments.`,
),
isError: true,
};
await emitToolExecutionEnd(finalized, emit);
const toolResultMessage = createToolResultMessage(finalized);
await emitToolResultMessage(toolResultMessage, emit);
messages.push(toolResultMessage);
}
return { messages, terminate: false };
}
/** /**
* Execute tool calls from an assistant message. * Execute tool calls from an assistant message.
*/ */

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 () => { it("should execute mutated beforeToolCall args without revalidation", async () => {
const toolSchema = Type.Object({ value: Type.String() }); const toolSchema = Type.Object({ value: Type.String() });
const executed: Array<string | number> = []; const executed: Array<string | number> = [];