Reliability & guard rails
Reliability constraints are first-class plan nodes, not instructions buried in a prompt. A retry policy, deadline, dispatch cap, or approval gate is visible before execution and enforced by the runtime during execution.
All ten guard rails have native text spellings: assert, retry, timeout, budget,
with_tools, try, confirm, verify, throttle, and debounce. Full field tables live in the
node reference.
assert — abort on a falsey condition
hits = grep(glob: "*.log", pattern: "ERROR")
assert hits, "no ERROR lines found"
assert len($hits) > 0, "no ERROR lines found"
assert <cond> aborts the flow with an error when the condition is falsey; execution never continues past a failed assert. The optional message follows the first top-level comma and becomes the error detail. The condition may be a symbol, a call, a literal, or a native expression such as $score >= 0.8, and it uses the same truthiness rules as when — see the execution model. Use it to fail fast instead of writing a when around a manual error return.
retry — retry transient failures
retry 3, backoff: exponential, delay: 500ms -> health
web.fetch("https://api.example.com/health")
The maximum is positional; the remaining header options are named:
| token | required | meaning |
|---|---|---|
<max> | yes | maximum attempts, including the first |
backoff: none / linear / exponential | no | inter-attempt delay strategy (default none) |
delay: <duration> | no | base delay — 250ms / 5s / 1m, or a bare integer read as milliseconds. Defaults to 500 ms, so a retry with no delay still waits between attempts |
-> result | no | binds the body's last expression on success |
The backoff schedule, where k counts retries (k = 1 is the wait before the second attempt):
| strategy | wait before retry k |
|---|---|
none | delay |
linear | delay × k |
exponential | delay × 2^(k−1), with the multiplier capped at 2^10 |
Semantics to keep in mind:
- Fatal errors are never retried. A policy denial, an unknown op, or a type error propagates immediately — retrying cannot fix them.
- A denied
confirmis not retried. A human "no" inside the body is an answer, not a transient failure. - Bind through the header, not inside the body.
-> resultcaptures the body's last expression on success; do not also bind the same result inside the body. - After
maxfailed attempts, the node errors with the last attempt's error message.
retry 3 -> out
bash("flaky.sh")
retry 3
bash("flaky.sh")
timeout — bound wall-clock time
timeout 5s -> page
web.fetch("https://example.com")
timeout <duration> runs its body under a wall-clock deadline. If the body finishes in time, -> result names its result. If the deadline expires, the node errors — and that error is catchable by an enclosing try or retry, so a slow path can degrade instead of killing the flow.
Dispatches that completed before the deadline stay counted and traced; a timeout does not erase the work that already happened, it only stops what comes after.
budget — cap op dispatches
budget 10 -> notes
hits = grep(glob: "*.rs", pattern: "TODO")
ai.reason(ask: "Cluster these TODOs: {hits}")
budget <n> caps the number of op dispatches inside its body — calls that go through the runtime's dispatch gate. Pure nodes (fmt, jq, expr, value templates) dispatch nothing and are free.
The cap is checked at statement boundaries. A single nested statement — an each over a long list, say — can consume several dispatches before the next check, so a scope can overshoot its limit by the width of one statement. Treat the budget as a firm brake, not an exact meter.
The current budget counts dispatches, not tokens or money. -> result names the body's result.
with_tools — capability scope
with_tools ["read", "grep"] -> hits
src = read("src/lib.rs")
grep(glob: "*.rs", pattern: "unwrap")
with_tools [...] restricts op dispatch inside its body to the named tools. A call to anything outside the allowlist fails closed at the runtime's dispatch gate — even when the surrounding session policy would have allowed it. This is a runtime-enforced capability boundary, not an advisory hint.
- Capabilities only narrow on descent. Nested
with_toolsscopes are intersected: an inner block can never re-grant a tool an outer block removed. - The analyzer echoes the rule statically. A literal call to a tool that is provably absent from the list is flagged before the flow runs; dynamic dispatch is still caught at runtime.
-> resultnames the body's result.
Use it to hand a sub-plan read-only capabilities, or to guarantee a model-influenced section cannot reach bash or write no matter what it emits. See Safety & approvals for the session-level policy this composes with.
try — catch and handle errors
try
bash("might-fail.sh")
catch err
bash("echo fallback: {err}")
- The body runs first. If it succeeds, the handler never runs.
- On failure, the error string is bound to the
catchsymbol (hereerr) and the handler runs — the handler can interpolate{err}or branch on it. - If the handler itself errors, that error propagates.
- The
catch errarm (and its handler block) is optional. Atrywith no handler suppresses errors silently — use that deliberately, or not at all.
confirm — human approval gate
confirm "Delete all temporary files?", risk: high
bash("rm -rf tmp/")
messageis required.riskis one oflow/medium(default) /high/critical.- The gate calls the session approver: the TUI shows a modal,
--yesauto-approves, and the plain CLI prompts interactively. See Safety & approvals. - The body runs only on approval; a denial makes the node error immediately. A denied
confirminside aretryis not retried. - A
confirmwith no body is valid — a pure gate that pauses the flow for a decision without a conditional action (confirm "Proceed?"on its own line). - The approver sees the risk prepended to the message, as
[high] Delete all temporary files?, so the severity is visible where the decision is made.
verify — assert on command output
verify bash("cargo test --workspace 2>&1") contains "test result: ok": "workspace tests failed"
- Runs the command (any expression producing a string — typically a
bashcall), then checks that the output contains the expected substring. - If the substring is missing, the flow aborts with a structured error; the optional
: "message"suffix overrides the default error text. - Use it after an edit or build to guard against silent failure. Wrap it in a
tryif you want to handle a failed check gracefully rather than aborting.
throttle — rate-limit dispatches
throttle "fetches", max: 5, per: 1m
web.fetch(url)
- The header reads
throttle "<name>", max: <count>, per: <duration>: at mostmaxop dispatches inside the body per sliding window. - The token bucket is keyed by
(session, name)and updated atomically, and it survives across turns. Twothrottlenodes with distinct names never share a bucket; reusing anamedeliberately shares one. - When the limit is exceeded the node errors instead of blocking, so the plan stays responsive. Wrap it in
tryorretry(with a delay) if waiting is the right response.
debounce — coalesce bursts across turns
debounce "rebuild", wait: 300ms
bash("rebuild.sh")
- The header reads
debounce "<name>", wait: <duration>. Each time the node is reached, a last-trigger timestamp for itsnameis recorded in the session store. The body runs only once the wait duration has elapsed since that key's last trigger. - Re-arrivals inside the window re-arm the timer, so a burst of triggers coalesces into a single body run after things settle.
- Because the timestamp lives in the session store keyed by
(session, name), the settling window spans turns — not just one plan execution.
Related docs
- Ordered graceful degradation with
fallback— Control flow - First-success
raceand fan-outparallel— Concurrency - Guaranteed cleanup (
scope), rollback (saga), and durable effect de-duplication after successful completion has been recorded (once) — Durability & cross-turn state - The session policy and approval chain every dispatch passes through — Safety & approvals