From 94fc54859f83ceaf69a039a3621af36b4d9d34a2 Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 15 Jul 2026 17:52:25 +0000 Subject: [PATCH] fix(agent-loop): reclassify empty toolUse in result-only completion Streams finalized via end(result) with no terminal done/error event fall through to the trailing-result branch, which returned response.result() unchanged. An empty toolUse turn completing that way stayed a silent success and never retried. Apply the same retainCompletedToolCalls/recoverTransientErrorToolTurn/ reclassifyEmptyToolUseStop chain to the trailing branch. Fixes #5600 --- packages/agent/src/agent-loop.ts | 7 +++++- packages/agent/test/agent-loop.test.ts | 31 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 647334787..c0798adc8 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -1558,7 +1558,12 @@ async function streamAssistantResponse( detachAbortListener?.(); } - let trailing = await response.result(); + let trailing = reclassifyEmptyToolUseStop( + recoverTransientErrorToolTurn( + retainCompletedToolCalls(await response.result(), completedToolCallIds), + context.tools ?? [], + ), + ); if (harmonyMitigationEnabled) { const detection = detectHarmonyLeakInAssistantMessage(trailing); if (detection) { diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index c64f5217a..9475f5c86 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -3352,4 +3352,35 @@ describe("agentLoop empty toolUse stop (issue #5600)", () => { expect(firstAssistant.errorMessage).toBeUndefined(); expect(messages.some(m => m.role === "toolResult")).toBe(true); }); + + it("reclassifies an empty toolUse turn finalized by end(result) with no terminal event", async () => { + const context: AgentContext = { systemPrompt: ["You are helpful."], messages: [], tools: [] }; + // A provider/wrapper that settles the stream via end(result) instead of + // yielding a terminal `done`/`error` event drives the trailing-result + // finalization branch. The empty toolUse turn must be reclassified there too. + const streamFn = () => { + const stream = new AssistantMessageEventStream(); + const partial = createAssistantMessage([{ type: "thinking", thinking: "planning..." }], "toolUse"); + stream.push({ type: "start", partial }); + stream.push({ type: "thinking_start", contentIndex: 0, partial }); + stream.push({ type: "thinking_delta", contentIndex: 0, delta: "planning...", partial }); + stream.push({ type: "thinking_end", contentIndex: 0, content: "planning...", partial }); + stream.end(partial); + return stream; + }; + const config: AgentLoopConfig = { model: createMockModel().model, convertToLlm: identityConverter }; + + const stream = agentLoop([createUserMessage("Hello")], context, config, undefined, streamFn); + for await (const _event of stream) { + // drain + } + const messages = await stream.result(); + const assistant = messages.find((m): m is AssistantMessage => m.role === "assistant"); + if (!assistant) throw new Error("expected an assistant message"); + + expect(assistant.stopReason).toBe("error"); + expect(assistant.content.filter(b => b.type === "toolCall")).toHaveLength(0); + expect(AIError.is(assistant.errorId, AIError.Flag.Transient)).toBe(true); + expect(AIError.retriable(assistant.errorId)).toBe(true); + }); });