From 351efc828b6fc5250fa50d6b32b20b0f0cb22cb4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 7 Jul 2026 14:30:31 +0200 Subject: [PATCH] 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 --- packages/agent/src/agent-loop.ts | 42 ++++++++++++++- packages/agent/test/agent-loop.test.ts | 73 ++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 9e612d05..795a8d59 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -205,7 +205,13 @@ async function runLoop( const toolResults: ToolResultMessage[] = []; hasMoreToolCalls = false; 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); hasMoreToolCalls = !executedToolBatch.terminate; @@ -367,6 +373,40 @@ async function streamAssistantResponse( 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 { + 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. */ diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 62eadf81..03868047 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -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 = { + 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 = [];