Recovery & reliability

Long agent runs meet refusals, cut-off replies, rate limits, crashed terminals and killed processes. rupu classifies every reply into a typed outcome, works down a fixed recovery ladder before it gives up, and picks interrupted work up where it stopped instead of starting it over.

Response outcomes

A model's reply does not always give the agent what it needs: it can be cut off at the output limit, refused, blocked by a safety filter, empty, or stopped on a tool call that cannot be parsed. The provider can also fail the request outright. rupu classifies every reply and every provider error. A normal reply (an end_turn, tool_use or stop_sequence stop with usable text or a tool call) has no outcome; anything else gets a typed class with a severity:

ClassSeverityWhat happened
pause_turninfoThe provider paused a server-side tool loop and expects the conversation to be sent back to continue.
max_tokenserrorThe reply hit the output limit. When it cut a tool call off mid-arguments, the outcome says which tool.
context_window_exceedederrorThe reply stopped because the context window is full.
refusalerrorThe model refused. The title carries the provider's category when it gave one (refused · cyber).
safetyerrorA safety filter blocked the reply.
malformed_tool_callerrorThe model called a tool with arguments that could not be used.
incompleteerrorThe provider reported the reply as incomplete. Also what a reply that is still paused after every continuation becomes (see the ladder notes).
empty_replyerrorA normal stop with no text and no tool call.
unrecognized_stopwarningThe provider sent a stop reason rupu does not know. The raw value is kept.
unreported_stopwarningThe provider reported no stop reason at all.
provider_errorerrorThe request failed. The error_class field carries the normalized class: rate_limited, overloaded, server, timeout, context_overflow, quota, not_found, policy, auth, permission, invalid_request, too_large or unrecognized.

Severity decides what the run does next. A warning outcome never fails a run on its own: the reply is kept, the outcome is recorded so the odd stop is visible, and the run carries on. The one info outcome, pause_turn, is handled in place. An error outcome enters the ladder.

Each outcome is one outcome line in the transcript, written ahead of the turn's content, and every recovery step taken for it is one recovery line pointing back at it by id (oc_1, oc_2, … are run-local), so a transcript reads as a timeline. The final run_complete line of a run that failed on an outcome carries that outcome too. The field-level reference is on the Transcript schema page.

The transcript viewers (rupu transcript show, rupu session, rupu watch), the live run view, the workflow printer and rupu autoflow serve print each event as one line, with a glyph for the outcome severity (✗ error, ! warning, ● info) and a ↺ for a recovery:

✗ outcome   truncated · output limit
↺ recovery  rung 0 · continued 1/3
✗ outcome   refused · cyber
↺ recovery  rung 1 · fell back to anthropic/claude-opus-5

The control plane's transcript view shows an outcome as a block with a severity-colored edge, its title and its detail, and a recovery as a one-line timeline entry. Beyond the transcript, a failed workflow step, fan-out unit or run records the outcome that caused it (its cause) on the run record, so a failure is stored as “refused · cyber” and not only as an error string. The control plane does not display cause yet.

The recovery ladder

When an error outcome occurs, rupu works down four rungs and stops at the first one that gets the run moving again:

rung 0 retry in place rung 1 same-provider model rung 2 other-provider model rung 3 fail with a hint stops at the first rung that gets the run moving · at most 20 recovery actions per run
Each outcome class decides which rungs it may use.

What each outcome does on rung 0, which later rungs it may use, and what happens to the partial reply:

OutcomeRung 0 (in place)Rung-0 budgetRung 1Rung 2The partial reply
pause_turnSend the paused conversation back and join the continuation to it5nonokept
max_tokensTell the model to continue exactly where it stopped3yesyeskept
max_tokens that cut a tool call offCompact the history, then retry at the model's full output cap. Applies only when the cap had been lowered to make input and output fit; otherwise rung 0 is skipped1yesyesdiscarded
context_window_exceededCompact the history, then continue3yesyeskept
refusal, safetynone0yesyesdiscarded
malformed_tool_callTell the model what was wrong with the call and ask for it again2yesyeskept
empty_replyTell the model its reply was empty and ask it to continue1yesyesnone to keep
incompleteRetry the turn1yesyesdiscarded
unrecognized_stop, unreported_stopnone; a warning, so the run carries on0nonokept
provider_error: rate_limited, overloaded, server, timeout, context_overflowThe existing retry, backoff and overflow compaction run first; the ladder adds a hop only after they give upn/anoyesn/a
provider_error: quotanone0noyesn/a
provider_error: not_found, policynone0yesyesn/a
provider_error: auth, permission, invalid_request, too_large, unrecognizednone; retrying elsewhere would not help0nonon/a

Configuring fallbacks

A fallback chain is an ordered list of models. Each entry names a model and, optionally, a provider. Declare it on an agent:

---
name: dependency-auditor
provider: anthropic
model: claude-opus-5
fallbacks:
  - model: claude-sonnet-5-5          # same provider as the run → rung 1
  - provider: openai-codex            # another provider → rung 2
    model: gpt-5.6-cyber
---

…or once, for every agent that declares none, in config.toml:

[recovery]
fallbacks = [
  { model = "claude-sonnet-5-5" },
  { provider = "openai-codex", model = "gpt-5.6-cyber" },
]
server_side_fallback = true
Data handling. A rung-2 hop sends the whole conversation — including file contents and tool output the agent has read — to a different company's API under that provider's terms. Put a provider in a chain only when the data in the run is allowed to go there; if it is not, leave it out of fallbacks:.

Anthropic server-side fallback

Anthropic can fall back to another model on its own side when the model you asked for refuses. rupu opts in for you, which saves a round trip and keeps the reply in one request.

Turn it off with [recovery].server_side_fallback = false (default true); the setting applies to the run's own provider and to any Anthropic hop. If the API refuses the opt-in (a 400 that names fallbacks, which an account or gateway that does not accept the beta returns), rupu stops sending it, retries the request once, and writes a notice of kind server_side_fallback_disabled (“server-side fallback refused by the API — disabled for this run”). The run is not affected otherwise; set the key to false for that account to avoid the refused request on every run.

When nothing is left

When the ladder runs out, the run ends with status error. The failure message is the outcome's title followed by a hint; for a provider error it is instead the provider's own error text (provider: <error>) followed by the hint. The hint depends on where the run was started:

# rupu run <agent> (a dispatched sub-agent's own transcript and run record get the same hint)
refused · cyber; no recovery left — continue with a prompt on the same model: rupu run dependency-auditor --continue <run id> "<instruction>", or with another model: rupu run dependency-auditor --continue <run id> --model <model> [--provider <provider>]

# a workflow step
no recovery left — add fallbacks: to the agent or [recovery].fallbacks to try other models

# a session turn (the conversation is kept, so the next message continues it)
no recovery left — send another message to continue, or start a new session on another model

A failed run's final output is empty: its last text is an interim message or a cut-off reply, not an answer. A workflow step tolerated by continue_on_error: publishes steps.<id>.output as "" and the reason as steps.<id>.error; a failed dispatched sub-agent returns "ok": false, "output": "" and the reason as "error" to the agent that dispatched it. That reason leaves out the rupu run … --continue hint, which the dispatching agent cannot act on. How to continue a failed run is under Continue a failed run on another model below.

Context overflow

rupu compacts proactively — summarizing older turns once the conversation reaches compactAtPercent (default 80) of the model's input limit, with room left for a full-length reply. When a provider still rejects a request as too long, rupu reads the real maximum out of the error text (Anthropic, OpenAI, Copilot, vLLM and Gemini formats are each recognized), lowers the run's input limit to it, writes a model_limits_clamped notice, compacts with a summary and retries. Deleting the oldest exchanges is the last resort.

Recover on interrupt

A closed terminal, a killed process, a crash or a reboot should not throw away an hour of agent work. rupu keeps enough on disk to continue interrupted work rather than restart it.

Continue an agent run

rupu run <agent> --continue <agent_run_id>

rupu rebuilds the conversation from the run's transcript, drops the turn that was in flight when it stopped, and tells the agent it was interrupted (work from that turn may be partially applied, so the agent re-checks before going on). The new run's transcript links back to the old one rather than copying it. If the run had actually finished, its recorded answer is printed without calling the model. A sub-agent's sub_… id works too: rupu finds its transcript under the run that dispatched it. The continued run also takes on the children the interrupted one launched, under the same handles: finished ones as they ended, the rest continued.

Continue with a prompt

A continue can be told something new. A positional that parses as a run target is still the target; anything else is the prompt, and --prompt is the explicit form:

rupu run <agent> --continue <agent_run_id> "now write the fix"
rupu run <agent> --continue <agent_run_id> --prompt "now write the fix"

A prompt is new input, so every ended run can take one:

The runIt is sent
interruptedthe interruption note, then the prompt, in the conversation's trailing user turn
finishedthe prompt, as a new user turn after its answer (a real model call)
failed on a provider-side outcomethe “earlier attempt stopped” note, then the prompt, on its own model (or --model)
failed for another reason (max turns, say)the prompt alone, on its own model

The prompt is never part of the restored command: launch_args records no prompt, and the new run's launch_args are the merged command, still without it. The prompt lives in the new run's transcript as a user_message, and so in the conversation a later --continue of that run rebuilds — continuing it again never re-sends the prompt. With no prompt (or a blank one), nothing changes.

Continue a failed run on another model

rupu run <agent> --continue <agent_run_id> --model <model> [--provider <provider>]

A run that failed on an outcome — a refusal, a reply that kept getting cut off — has an intact conversation, so it can be picked up on a different model. The rebuilt conversation leaves out any discarded partial reply. The new model is told that the earlier attempt stopped, why, and which model it is continuing on.

Resume a workflow

rupu workflow resume <run-id> (also rupu run resume) takes a failed, cancelled, rejected or paused run back and re-runs only what did not finish. Steps with a recorded result are skipped, and for_each units that already succeeded are replayed from their checkpoints. For each linear step and each for_each unit without a recorded result, resume reads the latest agent attempt's transcript and picks one of three plans:

The attempt's transcriptPlanWhat runs
Stopped mid-run (killed, crashed, paused)continuedA new agent run seeded from the transcript (replayed, with a continuation note) carries on from where it stopped.
Had in fact finished — only its result was lostrecoveredIts final answer is the step's or unit's result; no agent runs and no model call is made.
Ended in failure, or missing / unreadablerestartedA fresh agent run from the rendered prompt.

Resume prints one line per step it planned something for (e.g. each: 1 continued · 1 recovered · 1 restarted) before dispatching; the live view marks a continued or recovered step or unit with ↩ continued / ↩ recovered (a restarted one looks like any fresh start), and the run's events.jsonl records each as an attempt_resumed event naming the interrupted agent run it picked up. Every agent attempt is recorded in a per-run attempts.jsonl ledger as it starts; a run interrupted before the ledger existed is covered by deriving the same attempts from its events.jsonl.

rupu workflow resume run_01EXAMPLE                         # continue interrupted work (the default)
rupu workflow resume run_01EXAMPLE --restart-interrupted   # start every interrupted step and for_each unit over from its prompt
rupu workflow resume run_01EXAMPLE --mode readonly --plain  # override the permission mode; plain line printer instead of the live graph view

A run left Running by a runner that has since died (SIGKILL, an out-of-memory kill, a reboot) is marked Failed first — the same transition rupu cp serve's sweep makes — so the CLI alone can recover it. A run whose recorded runner is still alive is refused.

What still restarts. parallel: sub-steps and panel: members, steps that are members of a loops: subgraph, and a step or unit placed on a remote host (host: / distribute:) are started over from their prompt rather than continued: a remote transcript cannot be continued from the coordinator.

From the control plane

The control plane records a control action and lets rupu cp serve carry it out, so a 200 or 202 means recorded; the run's own status is what confirms it. See Control plane and the HTTP API.

Model limits discovery

Every run needs its model's real input limit (for proactive compaction) and output cap (the per-request max_tokens). Per field, rupu takes the first of:

  1. the agent's contextWindowTokens / maxTokens frontmatter;
  2. a [[providers.<name>.models]] entry in config;
  3. the provider's live model list, from the cache;
  4. unknown — and the run says so.

A value of 0 counts as unset at every level. Each run writes a model_limits notice to its transcript with the values and where each came from. With an unknown input limit, compaction is off; with an unknown output cap, Anthropic requests carry 8192 and other providers get no cap (the model's own maximum).

Live lists are cached per provider (or per named account) at ~/.rupu/cache/models/<provider>.json for one hour. A run refreshes a stale or missing entry itself, bounded at 10 seconds; a failed refresh is remembered for 5 minutes so a provider outage doesn't cost every launch the timeout. Providers that expose no listing — a Gemini CLI / Code Assist login, for one — leave limits unknown unless you declare them.

rupu models list                              # CONTEXT, OUTPUT, SOURCE and FETCHED per model
rupu models list --provider anthropic-work     # a named account works too
rupu models refresh                           # re-fetch every live list; non-zero exit if none refreshed
rupu models refresh --provider openai

In the control plane, Settings → Models shows the same catalog with per-provider Refetch buttons. See Providers & Auth for where each provider's limits come from.