Skip to content

fix(agent): retry empty final turns instead of silently abandoning the task - #1896

Open
snimu wants to merge 4 commits into
mainfrom
snimu/empty-turn-retry
Open

fix(agent): retry empty final turns instead of silently abandoning the task#1896
snimu wants to merge 4 commits into
mainfrom
snimu/empty-turn-retry

Conversation

@snimu

@snimu snimu commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Providers occasionally end a turn with a normal stop reason and zero usable content: no text, no tool calls, sometimes a thinking-only message (observed and raw-SSE-confirmed for moonshotai/kimi-k3 via Prime Inference: single-chunk stream, no content, usage.output=0; ~1/10 raw probes). The agent loop treated any no-tool-call turn as completion, so the agent silently abandoned its task: print mode printed nothing and exited 0; interactive mode thought for minutes and then did nothing.

The fix

  • streamAssistantResponse now retries an empty final turn silently, up to 3 attempts. The retry happens before message_end is emitted, which is the durability edge: discarded attempts are popped from the request context and never reach session persistence, usage accounting, or transcript consumers.
  • Empty means: no tool calls and no non-whitespace text (thinking does not count). error, aborted, and length stops are excluded - they are signals of their own, and an identical resend cannot help.
  • Context-overflow shapes pass through untouched (!isContextOverflow(message, contextWindow) guards the retry, reusing the existing detector): a silent-overflow turn is empty by definition, and retrying it would burn full-context requests and then mask the stop reason that auto-compaction recovery keys on.
  • After 3 consecutive empty turns, the final message becomes a standard error turn (Model returned an empty response ... 3 times in a row) and flows the existing error path: print mode exits non-zero with the message on stderr; session-level retry/backoff applies as for any other turn error.
  • RLM children: a child whose turn ends in an error now delivers RLM child <name> failed: <error> to its parent instead of the misleading "completed without sending a reply" notice. (Found while wiring exhaustion propagation: graceful error turns resolve promptAndWait, so the parent never saw the error text.)

Tests

Eight new tests in packages/agent (silent retry with clean transcript and exactly one message_end; whitespace-only retry; exhaustion after 3 attempts; no-retry guards for real content, tool calls, aborts, empty length stops, and silent-overflow turns - the last two pin the compaction-recovery contract) plus one in agent-session-recursion (parent receives the child failure message, no terminal notice). All retry-path tests were proven red against the pre-fix loop.

Linear: ENG-5795


Note

Medium Risk
Changes core assistant-turn completion and message_end timing for every agent run; behavior is guarded and tested but affects persistence, events, and RLM parent notices.

Overview
Fixes a case where providers return a normal stop with no usable output (thinking-only or whitespace text, no tool calls) and the agent loop treated that as a finished turn, so work stopped with no error.

Agent loop (streamAssistantResponse): detects empty assistant turns (no tool calls and no non-empty text; error / aborted / length stops are not retried). It silently re-requests the model up to 3 times. Failed attempts are popped from context and never get message_end, so they do not hit the transcript or retry context. After three empties in a row, the turn is finalized with stopReason: error and a clear errorMessage. Turns that match isContextOverflow are left alone so compaction recovery still sees them.

RLM: when a child finishes without a parent reply, if the last assistant message has stopReason === 'error' (e.g. empty-response exhaustion), the parent gets rlm_child_failure instead of a completed without reply notice.

Tests cover retry hygiene, exhaustion, no-retry guards, overflow passthrough, and parent failure propagation.

Reviewed by Cursor Bugbot for commit 537e79e. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Retry empty final turns up to 3 times in streamAssistantResponse instead of abandoning

  • Adds MAX_EMPTY_TURN_ATTEMPTS=3 and isEmptyAssistantTurn helper; empty final turns (no tool calls, no non-whitespace text, stopReason not error/aborted/length) are silently dropped from context and retried up to 3 times, after which the message is finalized with stopReason='error' and an explanatory errorMessage.
  • Context-overflow turns are excluded from retry via isContextOverflow.
  • Refactors single-attempt streaming into streamAssistantResponseAttempt, which no longer emits message_end; the caller emits it exactly once after retry handling.
  • Updates AgentSession.runRlmChild so that when a child run ends with no parent reply but the last assistant message has stopReason='error', the parent receives rlm_child_failure with the child's error message instead of the previous completed_without_reply terminal notice.
  • Risk: callers relying on message_end being emitted by the inner attempt function must now depend on the outer streamAssistantResponse; in agent-loop.ts the only caller is updated.

Macroscope summarized 537e79e.

snimu added 2 commits August 29, 2026 14:53
…pleting (ENG-5795)

Providers occasionally end a stream with a normal stop reason but no usable
output (no text, no tool calls; sometimes thinking-only). The agent loop
treated any no-tool-call turn as completion, silently abandoning the task
(-p printed nothing and exited 0).

- agent: an empty final turn (no tool calls, no non-thinking content) is
  silently resent up to 3 attempts; empty attempts are dropped from the
  request context and never emitted as message_end, so they do not pollute
  the transcript. After the third empty response the turn ends as a normal
  turn error (print mode exits non-zero via the existing error path).
- coding-agent: an RLM child whose final turn ended in a graceful error and
  that never replied now surfaces to the parent as rlm_child_failure with
  the error text instead of a bare completed-without-reply notice.
…G-5795)

Review follow-up: an empty length-stop turn (Xiaomi MiMo overflow shape) and
a silent stop-overflow turn (z.ai shape, usage.input past the context window)
must pass through untouched so agent_end compaction recovery can see them.
The retry now skips stopReason length entirely (truncation is a signal; an
identical resend cannot change the outcome) and guards with the existing
isContextOverflow detector instead of re-deriving overflow logic.
Comment thread packages/agent/src/agent-loop.ts
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.

1 participant