diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 9e612d05d..795a8d59c 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 62eadf810..038680478 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 = [];