Safety framework — limits, modes, and intervention flow¶
Reyn's safety framework bounds how long an agent can run, how deep it can
recurse, and how many times it can loop before the system stops it. All
bounded operations share a single checkpoint API (handle_limit_exceeded)
so operators configure behaviour once and it applies uniformly.
Design principle: no limit hard-stops without either an ask (interactive) or a clean partial/degrade — every checkpoint either asks the operator for permission to continue, auto-extends within a configured budget, or stops with a decision-enabling message that explains what to change.
Parallel gate design. The permission system (JIT operator ask for tier-2/3 ops) and this limit framework are intentionally parallel: both share the RequestBus interface for their interactive paths, both offer the same three-state gate (allow / deny / ask), and both degrade to deny when no bus is wired. An operator who understands one gate understands the other.
Modes (safety.on_limit.mode)¶
| mode | behaviour on limit |
|---|---|
interactive (default) |
Dispatch a yes/no question to the operator via the intervention bus. Allow on yes; abort on no / timeout. |
auto_extend |
Automatically extend up to safety.on_limit.auto_extend_times times per (run_id, limit_kind), then abort. |
unattended |
Abort immediately. Never ask. |
When no intervention bus is available (headless / non-TTY run), interactive
degrades to the same abort path as unattended. In all abort paths the
outbox error message is decision-enabling: it states what limit was hit,
the current configured value, and which config key to change.
Limit inventory¶
| limit | config path | default | checkpointed? | partial data? |
|---|---|---|---|---|
| Phase act-turns | safety.loop.max_act_turns_per_phase |
10 | ✅ | yes |
| Phase visits | safety.loop.max_phase_visits |
25 | ✅ | yes |
| Router calls/turn | safety.loop.max_router_calls_per_turn |
3 | ✅ | yes |
| Workflow calls/chain | (configured per workflow) | — | ✅ | yes |
| Agent hops | safety.loop.max_agent_hops |
3 | ✅ | yes |
| Phase wall-clock | safety.timeout.phase_seconds |
0 (off) | ✅ | yes |
| Chain wait | safety.timeout.chain_seconds |
60 | ✅ | yes |
| Router iterations | safety.loop.max_router_iterations |
5 | ✅ | partial |
| LLM call timeout | safety.timeout.llm_call_seconds |
60 | ❌ auto-retry/abort | — |
| Media cap | multimodal.max_bytes |
5 MB | ❌ auto-degrade | — |
| Summary body cap | chat.compaction.body_token_cap |
1500 | ❌ auto-truncate | — |
Rows marked ✅ flow through handle_limit_exceeded.
Rows marked ❌ have autonomous behaviour that does not require operator input.
Intervention flow¶
limit hit
│
├─ mode=unattended ──► allow=False, reason="unattended"
│
├─ mode=auto_extend ──► within budget → allow=True, reason="auto_extended"
│ budget exhausted → allow=False, reason="unattended"
│
└─ mode=interactive
├─ bus=None ──► allow=False, reason="no_bus"
└─ bus present ──► UserIntervention dispatched
├─ yes ──► allow=True, reason="user_approved"
└─ no ──► allow=False, reason="user_refused"
every allow=False path ──► force-close wrap-up
emit `limit_denied` event (kind = max_iterations | router_cap)
→ one final tool-less LLM turn summarizing what was accomplished
├─ wrap-up has text ──► outbox kind="agent",
│ meta.limit_stopped=True, meta.limit_kind=<kind>
└─ wrap-up fails/empty ──► decision-enabling outbox error (fallback)
A2A peer sessions. A2A sessions use the same on_limit config as CLI
sessions (default: interactive). When a limit fires in interactive mode,
the intervention is surfaced to the A2A peer via A2AInterventionBus:
the run's status is mirrored to "input-required" and the payload is appended
to the SSE stream / POSTed to the webhook. The peer answers via the A2A answer
endpoint (POST /a2a/agents/<name> {task_id, answer}), which resolves the
iv and allows the loop to continue. If a caller wants bounded behaviour instead
of waiting indefinitely for a peer answer, set safety.on_limit.ask_timeout_seconds
to a finite value (e.g. ask_timeout_seconds: 60.0) — a timeout refusal produces
the same decision-enabling error as a "no" answer.
Force-close wrap-up on deny. A denied limit no longer goes
straight to a canned error. The OS first emits a limit_denied event
(audit truth, P6) and gives the LLM one final tool-less turn to
summarize what was accomplished before the turn ends. The stop cause is
injected into that wrap-up's system prompt (the steady-state SP stays
cause-neutral; the cause is not appended as a trailing user message
because some providers reject a user turn immediately after a
tool_result). When the wrap-up produces text it is delivered as an
ordinary kind="agent" outbox message carrying a structured
meta.limit_stopped=True + meta.limit_kind marker — the UI reads the
marker to indicate a forced stop without a competing prose block. For
phase/plan hosts the wrap-up is also handed back for checkpoint
persistence (record_force_close); chat hosts no-op that hook.
Decision-enabling error message contract — emitted only on the
fallback path (the wrap-up call raised or produced no text). All
allow=False paths still degrade to a message containing:
1. What limit was hit and its current configured value
2. The config key to increase, or the safety.on_limit.mode to set for
interactive or auto-extend behaviour
3. Whether partial results are available
Config reference (reyn.yaml)¶
safety:
on_limit:
mode: interactive # interactive | auto_extend | unattended
auto_extend_times: 1 # extensions granted per (run_id, limit_kind) in auto_extend mode
ask_timeout_seconds: 0.0 # 0 = wait forever; >0 = timeout then refuse
loop:
max_act_turns_per_phase: 10
max_phase_visits: 25
max_router_calls_per_turn: 3
max_agent_hops: 3
max_router_iterations: 5 # max LLM tool-call iterations per user turn (CLI --max-iterations overrides)
timeout:
phase_seconds: 0.0 # 0 = disabled
chain_seconds: 60.0
llm_call_seconds: 60.0