You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Know why an agent's turn ended: stopped by me, by Paseo, by a usage limit, or cut off
#7810
I run several agents at once, many of them started by other agents or by schedules, and check on them later. When one has gone quiet, I want to know why its last turn ended without opening it:
it finished the work;
I stopped it;
Paseo stopped it: a reload, a replacement prompt, a rewind or a daemon restart;
the provider stopped it at a usage or rate limit;
it ran out of tokens or turns partway through.
Each case needs a different next step: leave it, send it again, wait for the limit to reset, or tell it to continue. A parent agent or a plugin driving the work needs the same answer to decide on its own.
How I do it today
I open the agent and read the end of the transcript. For limits I look for the provider's message in the last reply, and sometimes in the provider's own session log.
Where Paseo gets in the way
Most providers report why a turn stopped. Paseo reduces that to completed, failed or canceled and drops most of the rest (main at 72e3316):
Open PRs fix some of these cases, each in its own shape: #7637 fails Claude turns that end on an API error, #7193 adds usageLimit: { resetsAt } to failed turns for auto-resume (#4122), and #7676 marks turns lost to a daemon restart (#3061).
What would the flow look like if it worked for me
Every finished turn carries one provider-neutral endReason (a sketch, names open). The existing free-form reason stays for display.
The daemon sets the stop reasons it causes itself. Each provider sets the rest from what it already reports, and leaves it unset when it can't tell:
Claude: the result's subtype, is_error, stop_reason and terminal_reason, and the assistant message's error.
Codex: turn.status and codexErrorInfo.
ACP: stopReason.
OpenCode: the v1 error name, and the v2 failure type and interrupt reason.
Pi and OMP: stopReason and errorMessage.
Plugin providers: a new optional field on session.turn, which today has only state and error.
Paseo keeps it on the agent and passes it to the agent.turn_ended plugin outcome, the parent's finish notification, and paseo run --json / paseo agent inspect. In the app it could be one line on the turn footer and in the sidebar's attention state: "Stopped by you", "Stopped: daemon restarted", "Cut off at max tokens", or "Usage limit, resets 17:20" using the reset time from #7193.
I'm happy to implement it in small PRs: first the stop reasons the daemon causes (one change that covers every provider), then the provider-reported reasons one provider at a time, on top of #7637 and #7193 if they land. Two things I'd want your call on first:
Whether this belongs on the turn events and the agent record. The cause of a daemon-initiated stop is known only inside cancelAgentRunBefore and is dropped before agent.turn_ended is published, so a plugin can't reconstruct it.
Whether cut-off turns count as completed or failed. Today ACP completes on max_turn_requests while Claude fails on error_max_turns; with an explicit reason, that can be decided once for every provider.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
What am I trying to do
I run several agents at once, many of them started by other agents or by schedules, and check on them later. When one has gone quiet, I want to know why its last turn ended without opening it:
Each case needs a different next step: leave it, send it again, wait for the limit to reset, or tell it to continue. A parent agent or a plugin driving the work needs the same answer to decide on its own.
How I do it today
I open the agent and read the end of the transcript. For limits I look for the provider's message in the last reply, and sometimes in the provider's own session log.
Where Paseo gets in the way
Most providers report why a turn stopped. Paseo reduces that to completed, failed or canceled and drops most of the rest (main at
72e3316):max_tokens,max_turn_requestsandrefusalall become a normal completion. Claude reports API errors, including its session limit, assubtype: "success"withis_error: true, and those complete normally too (bug: a Claude turn that hits the session limit completes as a normal reply, leaving the agent idle #7636).cancelAgentRunBefore(agentId, "reload" | "replace" | "rewind")), but only uses it in an error message. OpenCode v2 reportsuser | shutdown | superseded | inactivity, and Paseo replaces it with a fixed string.codemeans something different per provider: the JSON-RPC code for ACP, a number taken from the error text for Claude. Codex sendsturn.error.codexErrorInfo(usageLimitExceeded,rateLimitExceeded,contextWindowExceeded, …), and Paseo reads onlyturn.error.message. ACP providers that report a quota error with a successful stop reason end up idle with no error at all (bug: ACP provider errors that arrive as a successful stopReason (with _meta errorMessage) are silently dropped — turn shows completed, agent looks idle #5075).lastError, return the agent toidleand set attention tofinished, andpaseo run --jsonprintscompletedfor both. A parent's finish notification says "was canceled", but not why. Plugins get a free-formcanceled.reason(in practice "interrupted") and nothing for completed turns (plugin API: PluginTurnOutcome discards the provider stopReason, so a plugin cannot tell a finished turn from a truncated one (max_turn_requests / max_tokens) #5283).Open PRs fix some of these cases, each in its own shape: #7637 fails Claude turns that end on an API error, #7193 adds
usageLimit: { resetsAt }to failed turns for auto-resume (#4122), and #7676 marks turns lost to a daemon restart (#3061).What would the flow look like if it worked for me
Every finished turn carries one provider-neutral
endReason(a sketch, names open). The existing free-formreasonstays for display.endReasonend_turnmax_tokens,max_turns,refusaluser,reload,replace,rewind,shutdown,providerusage_limit,rate_limit,auth,overloaded,context_window,errorThe daemon sets the stop reasons it causes itself. Each provider sets the rest from what it already reports, and leaves it unset when it can't tell:
subtype,is_error,stop_reasonandterminal_reason, and the assistant message'serror.turn.statusandcodexErrorInfo.stopReason.stopReasonanderrorMessage.session.turn, which today has onlystateanderror.Paseo keeps it on the agent and passes it to the
agent.turn_endedplugin outcome, the parent's finish notification, andpaseo run --json/paseo agent inspect. In the app it could be one line on the turn footer and in the sidebar's attention state: "Stopped by you", "Stopped: daemon restarted", "Cut off at max tokens", or "Usage limit, resets 17:20" using the reset time from #7193.Earlier asks for parts of this: #5283, #6940 (closed into #3061), and a plugin author's tracking issue, XUJiahua/paseo-post-turn-gate-plugin#2, which asks for the same stop causes.
I'm happy to implement it in small PRs: first the stop reasons the daemon causes (one change that covers every provider), then the provider-reported reasons one provider at a time, on top of #7637 and #7193 if they land. Two things I'd want your call on first:
cancelAgentRunBeforeand is dropped beforeagent.turn_endedis published, so a plugin can't reconstruct it.max_turn_requestswhile Claude fails onerror_max_turns; with an explicit reason, that can be decided once for every provider.All reactions