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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user