fix(agent-core): end agentLoop/agentLoopContinue streams on rejection - #165
Closed
jasonkneen wants to merge 1 commit into
Closed
jasonkneen wants to merge 1 commit into
jasonkneen wants to merge 1 commit into
Conversation
Invariant: a failure is its own terminal outcome — no consumer of the event stream may hang. agentLoop and agentLoopContinue did `void runAgentLoop(...).then((messages) => stream.end(messages))` with no rejection handler. If anything inside the loop threw synchronously or rejected (convertToLlm, transformContext, getApiKey, streamFn, a tool hook, prepareNextTurn), the returned EventStream never completed: `for await` consumers and `await stream.result()` hung forever, plus an unhandled promise rejection. Fix: attach a rejection handler to both functions that synthesizes an assistant failure message (stopReason "aborted" if the signal was aborted, else "error", errorMessage from the caught error, EMPTY_USAGE, model/api/provider from config.model) and pushes message_start, message_end, turn_end, and agent_end for it — mirroring Agent.handleRunFailure's semantics exactly. Since EventStream marks itself done as soon as an agent_end event is pushed, no separate stream.end(...) call follows the failure push. The final messages array is the messages already observed via message_end plus the synthesized failure message, tracked by tapping the emit callback (no change to runAgentLoop/runAgentLoopContinue's own signatures). Extracted the failure-message construction into a small shared helper (agent-failure.ts) and had Agent.handleRunFailure reuse it instead of duplicating the shape.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Two unresolved findings remain in agent-loop.ts involving result reconstruction and failure metadata.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (1)
What changed in this PR
This PR makes standalone agent-loop streams terminate cleanly on rejection with synthetic failure messages.
Changes:
- Added rejection handling to
agentLoopandagentLoopContinue. - Centralized failure-message construction and reused it in
Agent. - Added rejection, abort, and unhandled-rejection tests.
| File | Summary |
|---|---|
packages/agent-core/test/agent-loop.test.ts |
Adds rejection and stream-completion coverage. |
packages/agent-core/src/agent.ts |
Reuses the shared failure-message helper. |
packages/agent-core/src/agent-loop.ts |
Emits terminal failure events. Open findings: critical (2 votes) regarding uncommitted messages in stream.result(), and moderate (1 vote) regarding stale model/config metadata. |
packages/agent-core/src/agent-failure.ts |
Adds shared synthetic failure-message construction. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+48
to
+50
| if (event.type === "message_end") { | ||
| messagesSoFar.push(event.message); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Problem
agentLoop(packages/agent-core/src/agent-loop.ts:32-55) andagentLoopContinue(packages/agent-core/src/agent-loop.ts:65-94) each did:with no rejection handler. If anything inside the loop threw or rejected —
convertToLlm,transformContext,getApiKey, a synchronously-throwingstreamFn, a tool hook,prepareNextTurn— the returnedEventStreamnever completed:for await (const event of stream)consumers andawait stream.result()hung forever, and the rejected promise surfaced as an unhandled rejection. The statefulAgentclass already handled this correctly viarunWithLifecycle's try/catch →handleRunFailure(packages/agent-core/src/agent.ts:502-527), which emits a synthetic failure message and terminates the run; the two standalone loop functions had no equivalent.Fix
Added a rejection handler to both
agentLoopandagentLoopContinue's.then(onFulfilled, onRejected)call. On rejection,emitLoopFailure(packages/agent-core/src/agent-loop.ts) builds a synthetic assistant failure message via a new shared helper,createFailureMessage(packages/agent-core/src/agent-failure.ts) —stopReasonis"aborted"ifsignal?.aborted, else"error";errorMessagefrom the caught error; usage isEMPTY_USAGE;model/api/providercome fromconfig.model— mirroringAgent.handleRunFailure's message shape exactly.It then pushes
message_start,message_end,turn_end, andagent_endfor that message onto the stream. PerEventStream.push(packages/providers/src/utils/event-stream.ts), pushing an event for whichisCompletereturns true (here,agent_end) already setsdone = trueand resolves the final-result promise — so no separatestream.end(...)call is added or needed after that push (a comment in the code flags this to avoid a future accidental double-end).The messages returned by
stream.result()on the failure path are the messages already observed viamessage_endevents (tapped from theemitcallback passed intorunAgentLoop/runAgentLoopContinue) plus the synthesized failure message — reconstructing the equivalent of thenewMessagesarray without changingrunAgentLoop/runAgentLoopContinue's own signatures (every message that ends up in that internal array is preceded by a correspondingmessage_endemit, in the same order).Agent.handleRunFailurenow calls the samecreateFailureMessagehelper instead of duplicating the object literal; no other behavior inAgentchanged.Test
Added a
describe("agentLoop / agentLoopContinue rejection handling", ...)block topackages/agent-core/test/agent-loop.test.tswith 4 cases:agentLoop: a throwingconvertToLlmterminates the stream; last event isagent_end;stream.result()resolves with a failure message (stopReason: "error", matchingerrorMessage,EMPTY_USAGE, correct model/api/provider).agentLoopContinue.agentLoopwith an already-abortedAbortSignal→ failure message hasstopReason: "aborted".process.on("unhandledRejection", ...)listener around a rejecting run and asserts it never fires (vitest also fails the whole run on an unhandled rejection, so this is a second, explicit check).Before the fix, running the new tests against the pre-fix code hung indefinitely — confirmed via
timeout 20 npx vitest --run test/agent-loop.test.ts -t "rejection handling", which exited with code124(timeout), reproducing the exact hang described in the issue. After the fix, the same command passes in ~2.4s (4 passed).Risk / behaviour change
.thenis unchanged).agentLoop/agentLoopContinuethat previously relied on the promise/stream hanging forever on failure (unlikely, and contrary to the stated invariant) will now see the stream end with a syntheticassistantfailure message, matching howAgentalready reports failures.Agent.handleRunFailurebehavior is unchanged; it now delegates message construction to the shared helper instead of building it inline.Merge note
This is one of five independent PRs that edit
packages/agent-core/src/agent-loop.ts(tool-result cap, strict tool args, leak-retry transcript, turn cap, agentLoop rejection). Each is based on currentmain; whichever merges later needs a small rebase.https://claude.ai/code/session_01GUdnnHEaDThHUATSwXBpV9