Repository navigation
fix(sdk): pause an agent after three consecutive failed inferences - #68
Conversation
Review round 1
I checked the diff against the description, read the consumers of Major1. The pause also fires when the failing input was already dropped, so an agent that used to recover on the next message now stops answering. A non-retryable failure consumes the dequeued plugin messages ( Reproduced with the test harness: the entry agent gets three user messages that the provider rejects (
For a root agent nobody is told: the mailbox The mechanism this PR targets is narrower. A restart re-runs a failed turn only when its input survives the failure, and the only such input is Minor2. Retryable failures count toward the pause threshold. The streak now survives restarts and grows by one per error-resume cycle during an outage (one cycle per 60 s at the cap). After an outage of any length, the first non-retryable failure pauses the agent at once: 2 retryable failures plus 1 non-retryable is enough. One transient 400 after an outage (an aggregator returning "Provider returned error" for an upstream issue) used to leave the agent 3. No test covers resuming a The description says that Nit4. Two comments now say the opposite of the code. 5. The alert signal for the final failure changes kind. The failure that pauses skips Checked, no finding
|
Review response, round 1Everything is addressed in 49576ef. 1. Major: the pause fired when the failing input had already been dropped. Fixed in 49576ef.
This is the criterion you suggested, applied per failure instead of at pause time. Checking New test: a root agent gets 3 messages that are rejected ( 2. Minor: retryable failures counted toward the 3. Fixed in 49576ef.
A reducer test pins both: after a restart, 3. Minor: no test covered resuming a paused agent. Fixed in 49576ef.
4. Nit: two stale comments. Fixed in 49576ef.
5. Nit: the alert kind for the final failure changed. Kept, and now tested and documented in 49576ef. Checks on 49576ef:
|
A session restart reset every errored agent, so an agent whose conversation the provider rejects failed again on each restart and woke its parent each time. The failure streak was also cleared by the restart, so it never grew. - session_restarted keeps consecutiveInferenceFailures and clears only the backoff timestamp, so the first attempt after a restart is still immediate. - When a non-retryable failure is the third in a row, the agent pauses (reason 'limit') instead of staying errored. A paused agent is not in resetAgentIds, and the mailbox onPause hook tells the parent once, with the last error. Retryable failures keep retrying as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 1: the pause also fired when the failing input was a message, which a non-retryable failure consumes. An agent that used to answer the next message stayed paused instead, and a root agent had nobody to tell. - inference_failed records the error type. The reducer counts consecutiveRejectedTurns: non-retryable failures of a turn whose pending tool results survive the failure, which is the turn a restart rebuilds unchanged. Retryable failures, rejected messages and events without a type do not count. A committed turn or a conversation splice clears it; restarts and resumes keep it. - The agent pauses on the third such failure. - session_restarted resets consecutiveInferenceFailures again, as on main, so the backoff and retryable-notification behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
49576ef to
03c0541
Compare
Review round 2
I reviewed Round-1 findings
Conflict resolution against #69: correct
New findingsMinor: a resumed agent pauses on a retryable error, and the pause message blames the provider for rejecting the turn. The pause check reads only Checked, no finding
|
Review round 2: the rejected-turn count survives a resume, so any failure after resume_agent paused the agent again, an outage included, with a message saying the provider rejected the turn. The pause now also requires the current failure to be non-retryable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review response, round 2Minor: a resumed agent paused again on a retryable error. Fixed in 09ec260. The pause condition now also requires the current failure to be non-retryable: if (errorState && !isRetryableLLMError(llmResponse.error) && rejected >= Agent.MAX_CONSECUTIVE_REJECTED_TURNS)A non-retryable failure at or above the threshold is always itself a counted rejection. The count is non-zero only while the same tool results are pending, because a committed turn or a splice clears both. New integration test: "a resumed agent that hits an outage is retried after it, not paused again".
Checks on 09ec260:
|
Why
In a long-lived production session (15 days, 15
session_restartedevents from sandbox resumes), two coding sub-agents failed with the provider error "Provider returned error". One failed after a series of page snapshots with images, the other after reading a large JPEG. Both failed on a tool-result turn. From then on, every request with their conversation failed the same way.checkRecoveryNeededputs everyinferringorerroredagent inresetAgentIds, and thesession_restartedreducer resets them. A failure drops the turn's message but keeps its pending tool results. So on every restart both agents went back topending, rebuilt the same tool-result turn, failed again, and each failure woke the parent with "Agent encountered an error" (a cold, ~48k-token orchestrator prompt). In total: 31 failed inferences and 37 orchestrator calls with no result.The existing failure streak (
consecutiveInferenceFailures) could not catch this, becausesession_restartedresets it to start a fresh backoff episode. With one failure per restart it never grew past 1–2.What
inference_failedgets an optionalerrorType, theLLMErrortype (llmErrorTypeSchemaincore/llm/schema.ts;LLMError.typenow uses it).AgentState.consecutiveRejectedTurnsholds non-retryable failures of a turn whose input survives the failure:inference_failedonly whenerrorTypeis non-retryable (invalid_request,context_length) and the agent still has pending tool results;errorType;inference_completedandagent_conversation_splicedclear it, whilesession_restartedandagent_resumedleave it as it is.Agent.runInference: onceconsecutiveRejectedTurnsreaches 3 (MAX_CONSECUTIVE_REJECTED_TURNS, a static constant likeMAX_INFERENCE_RETRIES), the agent emitsagent_pausedwith reason'limit'and the messageThe provider rejected this turn 3 times in a row and the agent will not retry it on its own. Last error: …. It then runs theonPausehooks instead ofonError.resetAgentIds(no change tocheckRecoveryNeededwas needed). The mailboxonPausehook sends the parent one<child-paused agent="…">message with the error. No further restart touches the agent.emitHandlerPause(message)becomesemitPause(reason, message). Existing callers pass'handler'.consecutiveInferenceFailures, the error-resume backoff and the retryable-failure notifications are unchanged.Why a pause, not a new status
The existing pause already does what is needed. Restarts do not reset it,
decide()returnspaused, the parent is notified once throughonPause, and the parent's system prompt already explains<child-paused>andresume_agent. A newAgentStatuswould have to be mirrored in thesharedprojections, the conformance test, the protocol status, the CLI and the debug UI. Reason'limit'is documented as "agent hit a hard limit" and no emitter used it, so its enum and theagentStoppednotification enum stay unchanged.Behaviour changes
resume_agent/agents.resume.<child-paused>. A retryable failure after a resume does not pause it.agentStoppedkind: 'paused', reason: 'limit', notkind: 'errored'. A consumer that alerts only onerroreddoes not see it.errorType, so its existing failures do not count. An agent that is already a zombie pauses after 3 more rejected restarts under this version.Tests
session.test.ts› "a rejected turn across restarts":resetAgentIds, it ispendingafter the restart, the count is kept, and the backoff counter is reset as before;resetAgentIdsand stayspaused;errorTypeand rejected messages (no pending tool results) do not count;error-recovery.integration.test.ts› "an agent the provider keeps rejecting". Each case uses real restarts: a newTestHarnessover the same event store plusloadAllSessions.<child-paused>message with the provider error. The pausing load emits exactly oneagentStoppedfor the child,paused/limit.resumeAgenton the paused child retries the turn once, pauses again and sends one more<child-paused>.errored, notpaused, and the next restart retries the turn successfully.bun run ts:build: exit 0.bunx tsc -p packages/sdk/tsconfig.test.json --noEmit: exit 0.bun run lint: clean.bun run test: 2183 pass, 89 skip, 0 fail.resetAgentIdsat every later restart (14 and 10 later restarts, all of which reset them in the recorded log). 24 of the 31 failed inferences and 24 parent error notices would not have happened.Not verified
errorTyperecords it too.Status
changes, 1 major, 2 minor, 2 nits. All addressed in 49576ef (see the review-response comment).inference_failedkeeps bothproviderErroranderrorType. Fully re-verified after the rebase.approve, 1 minor. Fixed in 09ec260 (see the review-response comment). Still on the current main, no rebase needed.bun run ts:buildexit 0,bunx tsc -p packages/sdk/tsconfig.test.json --noEmitexit 0,bun run lintclean,bun run test2192 pass, 89 skip, 0 fail.🤖 Generated with Claude Code