Summary
When the Claude backend's agent loop terminates abnormally — max_turns exhausted, an API error, an aborted stream — Nerve reports it to the user as a normal, successful end of turn. There is no message in the conversation, no error in the UI, and nothing in nerve.log. The agent simply stops mid-tool-chain and goes quiet.
The Codex backend does not have this problem, and the engine already has the machinery to surface it. Only the Claude backend's translation layer drops the signal.
Root cause
nerve/agent/backends/claude.py, ResultMessage → TurnCompleted translation:
elif isinstance(message, ResultMessage):
usage = (...)
out.append(ev.TurnCompleted(
native_session_id=message.session_id,
...
num_turns=getattr(message, "num_turns", None),
status="completed", # <-- hardcoded
))
claude_agent_sdk.types.ResultMessage carries five fields describing how the loop ended, and all five are discarded:
| field |
example value on an abnormal stop |
subtype |
"error_max_turns" |
is_error |
True |
terminal_reason |
"max_turns", "aborted_streaming", "aborted_tools" |
errors |
list[str] |
api_error_status |
429 / 500 / 529 |
Why this is a silent failure rather than a cosmetic one
The engine already knows how to render a failed turn — nerve/agent/engine.py:
if event.status == "failed" and event.error:
# Failed turns still complete: surface the error inline so
# the conversation shows what happened
note = f"⚠️ Turn failed: {event.error}"
...
and the Codex backend feeds it correctly (nerve/agent/backends/codex/backend.py):
return ev.TurnCompleted(
...
status=status, # type: ignore[arg-type]
error=error,
)
So the plumbing is in place end to end; the Claude backend is the only producer that never sets status/error.
Reproduction
- Set a low
agent.max_turns (e.g. 5) in the workspace config.
- Give a Claude-backed session a task that needs more tool calls than that.
- The session stops mid-task. The UI shows a normal completed turn;
nerve.log contains no indication that the cap was hit.
Observed on a real session with agent.max_turns: 50:
sqlite> SELECT created_at, num_turns FROM session_usage WHERE session_id = '<id>';
2026-08-09 16:09:09 | 51
num_turns sitting at the cap is currently the only way to tell this happened, and it requires querying the DB directly. grep -i max_turns nerve.log over 150k lines returns nothing.
Impact
Worst for exactly the sessions that matter most: long autonomous runs (worker mode, cron jobs, review-loop legs). A capped-out turn is indistinguishable from a completed one, so an operator sees "the agent finished" when it actually stopped halfway. Automation downstream of a turn result inherits the same wrong conclusion.
Suggested fix
Map the SDK's terminal signals onto the existing TurnStatus in the Claude translator, mirroring the Codex backend:
terminal_reason in {"aborted_streaming", "aborted_tools"} → status="interrupted"
subtype != "success" or is_error → status="failed", with error built from subtype/terminal_reason/errors/api_error_status (e.g. "max turns (50) exhausted")
- otherwise →
status="completed" as today
That alone makes a capped turn render as ⚠️ Turn failed: max turns (50) exhausted in the conversation, with no engine changes. A log line at WARNING for any non-success terminal state would also help, since today the event leaves no trace at all.
Happy to open a PR for this.
Summary
When the Claude backend's agent loop terminates abnormally —
max_turnsexhausted, an API error, an aborted stream — Nerve reports it to the user as a normal, successful end of turn. There is no message in the conversation, no error in the UI, and nothing innerve.log. The agent simply stops mid-tool-chain and goes quiet.The Codex backend does not have this problem, and the engine already has the machinery to surface it. Only the Claude backend's translation layer drops the signal.
Root cause
nerve/agent/backends/claude.py,ResultMessage→TurnCompletedtranslation:claude_agent_sdk.types.ResultMessagecarries five fields describing how the loop ended, and all five are discarded:subtype"error_max_turns"is_errorTrueterminal_reason"max_turns","aborted_streaming","aborted_tools"errorslist[str]api_error_status429/500/529Why this is a silent failure rather than a cosmetic one
The engine already knows how to render a failed turn —
nerve/agent/engine.py:and the Codex backend feeds it correctly (
nerve/agent/backends/codex/backend.py):So the plumbing is in place end to end; the Claude backend is the only producer that never sets
status/error.Reproduction
agent.max_turns(e.g.5) in the workspace config.nerve.logcontains no indication that the cap was hit.Observed on a real session with
agent.max_turns: 50:num_turnssitting at the cap is currently the only way to tell this happened, and it requires querying the DB directly.grep -i max_turns nerve.logover 150k lines returns nothing.Impact
Worst for exactly the sessions that matter most: long autonomous runs (worker mode, cron jobs, review-loop legs). A capped-out turn is indistinguishable from a completed one, so an operator sees "the agent finished" when it actually stopped halfway. Automation downstream of a turn result inherits the same wrong conclusion.
Suggested fix
Map the SDK's terminal signals onto the existing
TurnStatusin the Claude translator, mirroring the Codex backend:terminal_reasonin{"aborted_streaming", "aborted_tools"}→status="interrupted"subtype != "success"oris_error→status="failed", witherrorbuilt fromsubtype/terminal_reason/errors/api_error_status(e.g."max turns (50) exhausted")status="completed"as todayThat alone makes a capped turn render as
⚠️ Turn failed: max turns (50) exhaustedin the conversation, with no engine changes. A log line at WARNING for any non-success terminal state would also help, since today the event leaves no trace at all.Happy to open a PR for this.