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
This commit is contained in:
roboomp
2026-07-15 17:52:25 +00:00
parent f64a17c529
commit 94fc54859f
2 changed files with 37 additions and 1 deletions
+6 -1
View File
@@ -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) {
+31
View File
@@ -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);
});
});