Skip to content

fix(agent-core): end agentLoop/agentLoopContinue streams on rejection - #165

Closed
jasonkneen wants to merge 1 commit into
stepfun-ai:mainfrom
jasonkneen:fix/harness-agentloop-rejection
Closed

jasonkneen wants to merge 1 commit into
stepfun-ai:mainfrom
jasonkneen:fix/harness-agentloop-rejection

Conversation

@jasonkneen

Copy link
Copy Markdown

Problem

agentLoop (packages/agent-core/src/agent-loop.ts:32-55) and agentLoopContinue (packages/agent-core/src/agent-loop.ts:65-94) each did:

void runAgentLoop(...).then((messages) => {
	stream.end(messages);
});

with no rejection handler. If anything inside the loop threw or rejected — convertToLlm, transformContext, getApiKey, a synchronously-throwing streamFn, a tool hook, prepareNextTurn — the returned EventStream never completed: for await (const event of stream) consumers and await stream.result() hung forever, and the rejected promise surfaced as an unhandled rejection. The stateful Agent class already handled this correctly via runWithLifecycle'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 agentLoop and agentLoopContinue'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) — stopReason is "aborted" if signal?.aborted, else "error"; errorMessage from the caught error; usage is EMPTY_USAGE; model/api/provider come from config.model — mirroring Agent.handleRunFailure's message shape exactly.

It then pushes message_start, message_end, turn_end, and agent_end for that message onto the stream. Per EventStream.push (packages/providers/src/utils/event-stream.ts), pushing an event for which isComplete returns true (here, agent_end) already sets done = true and resolves the final-result promise — so no separate stream.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 via message_end events (tapped from the emit callback passed into runAgentLoop/runAgentLoopContinue) plus the synthesized failure message — reconstructing the equivalent of the newMessages array without changing runAgentLoop/runAgentLoopContinue's own signatures (every message that ends up in that internal array is preceded by a corresponding message_end emit, in the same order).

Agent.handleRunFailure now calls the same createFailureMessage helper instead of duplicating the object literal; no other behavior in Agent changed.

Test

Added a describe("agentLoop / agentLoopContinue rejection handling", ...) block to packages/agent-core/test/agent-loop.test.ts with 4 cases:

  • agentLoop: a throwing convertToLlm terminates the stream; last event is agent_end; stream.result() resolves with a failure message (stopReason: "error", matching errorMessage, EMPTY_USAGE, correct model/api/provider).
  • Same for agentLoopContinue.
  • agentLoop with an already-aborted AbortSignal → failure message has stopReason: "aborted".
  • No unhandled rejection: installs a 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 code 124 (timeout), reproducing the exact hang described in the issue. After the fix, the same command passes in ~2.4s (4 passed).

Risk / behaviour change

  • Purely additive on the failure path: successful runs are unaffected (the resolve branch of .then is unchanged).
  • Consumers of agentLoop/agentLoopContinue that 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 synthetic assistant failure message, matching how Agent already reports failures.
  • Agent.handleRunFailure behavior 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 current main; whichever merges later needs a small rebase.

https://claude.ai/code/session_01GUdnnHEaDThHUATSwXBpV9

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.
Copilot AI lite review requested due to automatic review settings September 23, 2026 11:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 High severity

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 agentLoop and agentLoopContinue.
  • 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);
}
@ZouR-Ma ZouR-Ma closed this Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants