Skip to content

Commit 351efc8

Browse files
authored
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
1 parent 8a2ce5a commit 351efc8

2 files changed

Lines changed: 114 additions & 1 deletion

File tree

packages/agent/src/agent-loop.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,13 @@ async function runLoop(
205205
const toolResults: ToolResultMessage[] = [];
206206
hasMoreToolCalls = false;
207207
if (toolCalls.length > 0) {
208-
const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit);
208+
// A "length" stop means the output was cut off by the token limit, so
209+
// every tool call in the message may carry truncated arguments. Fail
210+
// them all instead of executing potentially borked calls.
211+
const executedToolBatch =
212+
message.stopReason === "length"
213+
? await failToolCallsFromTruncatedMessage(toolCalls, emit)
214+
: await executeToolCalls(currentContext, message, config, signal, emit);
209215
toolResults.push(...executedToolBatch.messages);
210216
hasMoreToolCalls = !executedToolBatch.terminate;
211217

@@ -367,6 +373,40 @@ async function streamAssistantResponse(
367373
return finalMessage;
368374
}
369375

376+
/**
377+
* Fail all tool calls from an assistant message that was truncated by the
378+
* output token limit. Streamed tool-call arguments are finalized with a
379+
* best-effort JSON salvage parser, so a truncated message can yield tool calls
380+
* whose arguments parse and validate but are silently incomplete. None of them
381+
* are safe to execute; report each as an error so the model can re-issue them.
382+
*/
383+
async function failToolCallsFromTruncatedMessage(
384+
toolCalls: AgentToolCall[],
385+
emit: AgentEventSink,
386+
): Promise<ExecutedToolCallBatch> {
387+
const messages: ToolResultMessage[] = [];
388+
for (const toolCall of toolCalls) {
389+
await emit({
390+
type: "tool_execution_start",
391+
toolCallId: toolCall.id,
392+
toolName: toolCall.name,
393+
args: toolCall.arguments,
394+
});
395+
const finalized: FinalizedToolCallOutcome = {
396+
toolCall,
397+
result: createErrorToolResult(
398+
`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.`,
399+
),
400+
isError: true,
401+
};
402+
await emitToolExecutionEnd(finalized, emit);
403+
const toolResultMessage = createToolResultMessage(finalized);
404+
await emitToolResultMessage(toolResultMessage, emit);
405+
messages.push(toolResultMessage);
406+
}
407+
return { messages, terminate: false };
408+
}
409+
370410
/**
371411
* Execute tool calls from an assistant message.
372412
*/

packages/agent/test/agent-loop.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,79 @@ describe("agentLoop with AgentMessage", () => {
307307
}
308308
});
309309

310+
it("should not execute tool calls from a length-truncated assistant message", async () => {
311+
const toolSchema = Type.Object({ value: Type.String() });
312+
const executed: string[] = [];
313+
const tool: AgentTool<typeof toolSchema, { value: string }> = {
314+
name: "echo",
315+
label: "Echo",
316+
description: "Echo tool",
317+
parameters: toolSchema,
318+
async execute(_toolCallId, params) {
319+
executed.push(params.value);
320+
return {
321+
content: [{ type: "text", text: `echoed: ${params.value}` }],
322+
details: { value: params.value },
323+
};
324+
},
325+
};
326+
327+
const context: AgentContext = {
328+
systemPrompt: "",
329+
messages: [],
330+
tools: [tool],
331+
};
332+
333+
const config: AgentLoopConfig = {
334+
model: createModel(),
335+
convertToLlm: identityConverter,
336+
};
337+
338+
let callIndex = 0;
339+
const streamFn = () => {
340+
const stream = new MockAssistantStream();
341+
queueMicrotask(() => {
342+
if (callIndex === 0) {
343+
// Output hit the token limit mid tool call. The salvage parser can
344+
// produce arguments that validate but are silently truncated, so
345+
// nothing in this message may execute.
346+
const message = createAssistantMessage(
347+
[{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hel" } }],
348+
"length",
349+
);
350+
stream.push({ type: "done", reason: "length", message });
351+
} else {
352+
const message = createAssistantMessage([{ type: "text", text: "done" }]);
353+
stream.push({ type: "done", reason: "stop", message });
354+
}
355+
callIndex++;
356+
});
357+
return stream;
358+
};
359+
360+
const events: AgentEvent[] = [];
361+
const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, streamFn);
362+
for await (const event of stream) {
363+
events.push(event);
364+
}
365+
366+
// The tool must never execute with potentially truncated arguments.
367+
expect(executed).toEqual([]);
368+
369+
const toolEnd = events.find((e) => e.type === "tool_execution_end");
370+
expect(toolEnd).toBeDefined();
371+
if (toolEnd?.type === "tool_execution_end") {
372+
expect(toolEnd.isError).toBe(true);
373+
const text = toolEnd.result.content.find((c: { type: string }) => c.type === "text");
374+
expect(text && "text" in text ? text.text : "").toContain("output token limit");
375+
}
376+
377+
// The loop continues so the model can re-issue the tool call.
378+
expect(callIndex).toBe(2);
379+
const messages = await stream.result();
380+
expect(messages[messages.length - 1].role).toBe("assistant");
381+
});
382+
310383
it("should execute mutated beforeToolCall args without revalidation", async () => {
311384
const toolSchema = Type.Object({ value: Type.String() });
312385
const executed: Array<string | number> = [];

0 commit comments

Comments
 (0)