Session.__init__ construction rationale¶
Session.__init__ (src/reyn/runtime/session.py) wires ~40 sub-components together at
session construction time. Historically the wiring carried its design rationale —
ordering constraints, eager-vs-deferred dependency resolution, byte-identical-extraction
provenance, trade-offs — inline as long comments (#3121 step2 audit: 582 of 959 __init__
lines, 61%, were comments). This doc is the relocation target for that rationale: code
keeps a one-line intent + a pointer here (or to the originating issue, when the issue
already carries the detail); this doc keeps the why.
The organizing spine mirrors the codebase's own #3082 "Family" decomposition — the
builder methods (_build_*_bundle) that assemble __init__'s ~40 sub-components — plus a
handful of topical sections for construction rationale that predates / sits outside that
decomposition (identity, capability/visibility, multimodal, safety, misc lifecycle wiring).
Identity (the Agent value object) — FP-0043 Stage 2¶
agent: Agent is a required __init__ param — the sole source of identity
(#3133 Priority-0 step-2). There is no agent=None fallback and no duplicate flat
identity params (agent_name/model/permission_resolver/workspace_base_dir/
workspace_state_dir/sandbox_config/sandbox_backend/environment_backend/
agent_role) on Session.__init__ — they were removed together with the
Agent(...) fallback construction they fed, so agent_name != agent.agent_name
is no longer constructible. In production, build_scoped_chat_session is the
single chokepoint that assembles the real Agent and passes it in as agent=.
Tests build an Agent explicitly too — tests/_support/agent_session.py's
make_session helper (and the compaction helper in tests/_support/session.py)
do this for every test call site, so no test constructs Session with the old
flat identity kwargs either.
agent_name/model/_perm/workspace dirs/environment_backend/sandbox_config/
sandbox_backend/workspace_dir/agent_role are then read-only @property delegations
to self._agent (defined later in the class body) — external code reads them exactly as
before; only the storage moved.
Field-by-field notes on the identity cluster (now Agent-held, exposed via the @property
block):
- _sandbox_config — exec-tool backend policy, plumbed to spawned Agents.
- _environment_backend (#1200 PR-F1) — the agent's EnvironmentBackend INSTANCE for the
chat FS seam (the router Workspace built in make_router_op_context); None → the
workspace's own HostBackend default.
- _workspace_base_dir / _workspace_state_dir (#187) — the chat OpContext
Workspace's FS root + host-side state dir. With a container env-backend the repo lives
inside the container, so base_dir must be the container repo root (the partner of
build_environment_backend's backend) — otherwise read_file/grep/glob resolve
against the host cwd and the agent never sees the target tree (the #187 step-3 empty-FS
defect this param closes).
- output_language (#4206 slice 1) — public @property, NOT a plain constructor-set
attribute since this slice: live-resolves reyn.runtime.preferences's ③ (free-override)
axis — session-layer <session-state-dir>/config.yaml preferences.output_language wins
over agent-layer profile.yaml preferences.output_language wins over
_project_output_language (the value this Session was constructed with, config.
output_language for the default factory). Live re-read on every access, the SAME
"session layer in front of agent layer, re-read every time" shape _workspace_base_dir
below already established for base_dir — an operator editing either file by hand takes
effect on the next read, not just the next process start.
- reasoning_display (#4206 slice 2) — public @property, the SAME shape as
output_language immediately above, factored through a shared
Session._resolve_session_preference(key, project_default) helper both now
call. Resolves ONLY chat.reasoning.display — chat.reasoning.continuity/
recent_turns stay ② bounding (read directly off self._reasoning,
unaffected). RouterHostAdapter.reasoning_display_enabled() consults this
live via a reasoning_display_fn callback wired at construction (the SAME
callback shape reasoning_continuity_section_fn already established for
the sibling continuity-section renderer) — None (every pre-slice-2 host)
falls back to the frozen reasoning_config.display read, byte-identical.
- warn_ratio_overrides() (#4206 Slice B, #4724) — public METHOD (not a
property — returns a fresh dict[str, float] each call, not a scalar), the
③ resolution for the 7 cost.*.warn_ratio keys. A DIFFERENT shape from
output_language/reasoning_display above: BudgetTracker is
process-shared (one instance across every agent/session in a process), so
it cannot resolve a session/agent identity itself the way a per-Session
property can — this method collects only the keys actually overridden at
either the agent or session layer (an omitted key means "use the
tracker's own project-level ratio, unchanged") and the CALLER
(RouterLoop/BudgetGateway, via RouterHostAdapter.
warn_ratio_overrides() / BudgetGateway's own warn_ratio_overrides_fn
callback, both wired at construction the SAME way reasoning_display_fn
is) passes the resulting mapping into BudgetTracker.check_pre_llm/
record_llm/format_budget_full as an explicit argument ("Design C",
#4724 — no process-shared override registry, no session-id plumbed into
the tracker itself).
- model_class_ceiling (#4206 ②) — public @property, the ②bounding
axis's ONE key so far (model). A DIFFERENT composition from
output_language/reasoning_display/warn_ratio_overrides above: those
are ③ (free-override, last-present-wins); this one is restrict-only
(narrowest-of-project/agent/session wins, via
reyn.runtime.bounding.compose_model_ceiling) because model consumes a
shared, bounded resource (BudgetTracker's quota) that a free override
could exhaust. Composed from self._resolver.class_ceiling() (project)
+ this agent's profile.yaml bounding.model + this session's own
config.yaml bounding.model (session wins over agent when both narrow,
same precedence order ③ uses) — a layer that declares a WIDER class than
a layer above it never widens the effective value. Live re-read on every
access, same shape as the ③ properties above. RouterHostAdapter.
model_class_ceiling() consults this live via a model_class_ceiling_fn
callback wired at construction (the SAME callback shape
reasoning_display_fn established) — None (every pre-② host) falls
back to resolver.class_ceiling() directly, byte-identical to
RouterLoop's prior construction-time-cached read. The composed value
feeds the SAME #1190 chokepoint (recorded_acompletion) model_class_ceiling
has always fed — ② only changes WHERE that value comes from, not the
enforcement itself.
- _sandbox_backend (#1200 PR-F2) — the agent's SandboxBackend INSTANCE for the chat exec
seam (the router OpContext); None → get_default_backend. This is the INSTANCE, not
the sandbox_config.backend STRING used for exec-tool gating — for a docker agent it is
the SAME object as environment_backend (DockerEnvironmentBackend satisfies both
protocols); REQUIRED in production, because without it chat falls back to
get_default_backend (rebuild-per-call, no docker) — a different backend than the FS
seam, i.e. a single-shared-sandbox violation.
#3121 step1 parameter objects¶
reactivity / capability_scope / task_wiring / presentation_wiring replace 12 flat
params (see reyn.runtime.session_params for the per-field type definitions). Each
defaults to its own all-None dataclass when omitted, then is unpacked into the same
local names __init__'s body already read — byte-identical to the pre-step1 flat-param
None defaults. presentation_wiring.presentation_consumer is REQUIRED in production
(build_scoped_chat_session always supplies one); None is reachable only via
direct/test construction.
Family 1 — Audit-event spine (P6)¶
_build_audit_event_bundle constructs event_store → audit_events (EventLog) →
outbox_hub, plus the opt-in OTEL subscriber attached to audit_events when an OTLP
endpoint is configured (P5 ADR-0039: config value or OTEL_EXPORTER_OTLP_ENDPOINT env).
None/no-endpoint → not attached, zero overhead, byte-identical to no-OTEL. This is
Family 1 because several later families (3, 4, 6a, 8a) eagerly read self._audit_events
at construction and must run after it — the ordering constraint that pins Family 1 to
run before Families 3/4/6a/8a below.
self._audit_events is also published as the process's ambient EventLog sink for the LLM
acompletion chokepoint (#1669: set_llm_request_event_log) — every in-session LLM call
emits an observable llm_request event without threading events through the call stack.
Family 2 — Recovery (WAL / journal)¶
reyn.runtime.services.recovery.build_recovery constructs generation_store → journal
(SnapshotJournal, extracted in PR-refactor-session-1 wave 2 — the session keeps
_snapshot_path only for diagnostic logging; the journal owns the actual I/O). Session no
longer builds this pair itself: every construction site (the scoped session factory, the
test helpers) calls build_recovery and passes generation_store= / journal= in as
required Session.__init__ params (recovery-bundle-out-of-Session refactor). The default
.reyn/agents/<agent_name>/state/snapshot.json path convention lives in
reyn.runtime.services.recovery.default_snapshot_path, the single source both Session
and its callers derive from. build_recovery reads the caller's local state_log value,
not self._state_log off an already-constructed Session. state_log is process-shared
(owned by AgentRegistry); None disables persistence (tests / non-chat invocation). _session_id
(FP-0043 Stage 5, default "main") threads to the journal so every WAL append carries it;
a spawned session's real sid is set post-construction (spawn_session → set_session_id)
before its run-loop goes live, so every append carries the right session_id for
per-session snapshot routing.
Adjacent recovery-adjacent state that stays inline (not builder-owned):
- _turn_idle (ADR-0038 Stage 1c) — set = no turn in flight; cleared while
run_one_iteration processes a turn. Lets a global rewind await_quiescent before
appending the reset-record, so no append lands past the reset seq.
- _turn_owner_task — lets await_quiescent skip _turn_idle.wait() when called
re-entrantly from the same task that owns the current turn (e.g. a slash handler calling
registry.checkout mid-turn).
- _background_tasks (TrackedTaskSet, #4759) — the single funnel every background
task this session (or a sub-component it owns) spawns via asyncio.create_task
routes through, replacing a prior per-field enumeration (_inflight_wal_tasks was
one such field, ADR-0038 Stage 1c coverage; folded in by #4759). Each registration
carries two INDEPENDENT axes (tracked_tasks.py's own module docstring has the
full rationale, including a first-attempt axis name that shipped a real
regression, #4759/#4765 co-vet): disposition ("cancel_join" vs "await" —
HOW a task folds) and appends_wal (WHETHER it's safe to fold it DURING a
rewind, not just at shutdown). Fire-and-forget WAL-append tasks (intervention
dispatch / intervention_answer_consumed) register with disposition=
"cancel_join", appends_wal=True via _track_wal_task; await_quiescent
calls aclose(appends_wal=True) — the WAL-appending subset ONLY, not the whole
set — so no such append can land past the rewind reset-record seq, without also
tearing down appends_wal=False mechanisms (OutboxHub's drain loop, the
hook-bus bridge, …) that the session still needs mid-rewind. The unfiltered
drain (aclose() with no appends_wal filter) lives at
Session.aclose_background_tasks, reached only from real shutdown
(AgentRegistry.shutdown()). See tracked_tasks.py's own module docstring for
the root cause this replaced and why a single owned
collection, not another named field, is the fix.
- _state_log is also kept directly (not only via the journal) because ops launched from
this session need it to emit step events into the same WAL that the journal writes to.
- _halted_reason (#2259 PR-3) — set when the session FAIL-STOPS (e.g.
"durability_failure"); None while running. In-memory only (durability is dead → it
cannot itself be a durable event) — the operator-visible pair to the raised
DurabilityHaltError. #2280: the first time this latches (on EITHER the accept-edge
_put_inbox raise or the process-edge run_one_iteration halt — guarded so it fires
once), a session_halted audit-event carrying reason is emitted, so an operator who is
IDLE (not currently submitting an op) learns the halt proactively — the TUI status line
(interfaces/inline/textual_chat/chrome.py's status_line_text) and the plain --cui
renderers' bottom_toolbar both surface it via this event, never by polling
halted_reason on a timer. Purely observability; the raise/halt above remain the whole
safety mechanism.
- How DurabilityWorker reaches the §4-exhausted state above — a durable-write
failure is classified by exception type: OSError (disk-full, EIO, a transient
filesystem hiccup) is retried with bounded backoff (max_write_attempts); a
non-OSError (a real bug — retrying cannot help) raises immediately, no retry.
Exhausting the bounded retries is what escalates: raised to the submitter for a
blocking submit, or latched to durability_failed (→ _halted_reason above)
for a fire-and-forget write. One task's escalation does not kill the worker —
it keeps serving subsequent submissions, so a single bad write doesn't wedge
every other session's durability.
- _queue_seq (#3300 P2a) — monotonic sent-queue-mutation counter, an order-race gate for a client merging the granular user_submitted/turn_started queue deltas (see _bump_queue_seq for the full rationale). Deliberately NOT WAL-durable: it is a client read-model liveness aid (resolves snapshot/delta interleaving on the wire), not recovery state. A restart safely resumes from 0 because a fresh connection's STATE_SNAPSHOT always seeds the client's last-applied seq before any delta is merged — there is no window where a delta referencing a pre-restart seq reaches a client that hasn't first received the fresh snapshot's baseline.
- cancelled_msg_ids (#3300 P3, Y-server; moved off Session onto InboxArbiter in #3978 P1, née _cancelled_msg_ids) — an in-memory skip-at-consume set for msg_ids cancelled via cancel_queued while still sitting in the (durable) asyncio.Queue, whose entry cannot be removed in place (no such API exists). InboxArbiter.consume_inbox/drain_to_wake discard a dequeued item whose msg_id is in this set instead of dispatching it. The item's snapshot.inbox entry and the WAL inbox_cancel tombstone are recorded SYNCHRONOUSLY at cancel time, independent of this deferred physical dequeue (see cancel_queued / SnapshotJournal.cancel_inbox). This is what makes the in-memory-only set safe: a crash before the dequeue leaves no stale Queue entry to skip, because a fresh process starts with an empty asyncio.Queue and repopulates it from the recovered snapshot — which already excludes the cancelled item (see restore_state).
- pending_inbox_item (#3792; moved off Session onto InboxArbiter in #3978 P1, née _pending_inbox_item) — a 1-slot, volatile (not WAL/snapshot-backed) peek buffer for mid-turn CLIENT_INPUT injection: InboxArbiter.peek_mid_turn_injection dequeues via inbox.get_nowait() into this slot WITHOUT telling the journal (asyncio.Queue has no peek/un-get API), so the snapshot-backed SSoT stays untouched until Session._commit_mid_turn_injection (stayed on Session — the extraction's scope was inbox-drain/dispatch-attribution state, not this commit path) actually commits it. consume_inbox checks this slot FIRST, ahead of a fresh inbox.get() — this is what makes an item that was peeked but never committed (an ineligible-origin head the peek deliberately left alone, or a genuine abnormal exit before commit) surface exactly where it would have if the peek had never happened, rather than being stranded or reordered.
- _turn_cancel_self_initiated (#2242) — True only for the window between cancel_inflight() calling _turn_owner_task.cancel() and run_one_iteration observing the resulting CancelledError. It distinguishes OUR OWN hard-cancel (which the run-loop swallows so the driver task survives) from an externally-cancelled driver task — e.g. an anyio scope teardown cancelling the MCP/A2A request-handler task that pumps run_one_iteration directly (FP-0013 §ADR-A). In the external case, await self._turn_owner_task ALSO raises CancelledError (asyncio propagates an awaiting task's cancel into whatever Task/Future it is suspended on) — but that cancellation must be RE-RAISED, not swallowed, so the driver's own cancellation completes normally instead of silently surviving a cancel that was never ours. Confusing the two makes the runtime's cancellation bookkeeping and the caller's anyio scope diverge: the scope believes its teardown cancel completed; the driver task is still alive.
- RE-DRAIN LOOP (#2115, generalised by #4759) — await_quiescent calls
self._background_tasks.aclose(appends_wal=True) (TrackedTaskSet,
tracked_tasks.py) to cancel every "cancel_join"-disposition,
appends_wal=True tracked task (cancel, not join-only: the intervention-dispatch
task awaits the user-answer future indefinitely, and the tasks are drop-safe so
cancelling is correct) and join every appends_wal=True task currently tracked,
looping to a fixpoint (bounded by tracked_tasks.py's own _MAX_ACLOSE_ROUNDS,
née await_quiescent's own _QUIESCE_MAX_ROUNDS before #4759 moved the loop
into the shared primitive) rather than a one-shot pass. A joined task can
register a NEW tracked task DURING the gather — a one-shot snapshot-then-drain
misses that reschedule (this was #2115, originally scoped to WAL-append tasks
alone: a straggler append landing after await_quiescent had already returned,
i.e. after the global-rewind reset-record was appended). Looping until the
appends_wal=True subset is fully drained closes that window, now for every
WAL-appending producer registered through the funnel, not just the original
two. Deliberately NOT unfiltered: await_quiescent runs during a rewind, not
only at shutdown, and an unfiltered aclose() here regressed once already (CI
caught a session that silently stopped answering after a rewind, because the
unfiltered drain also killed appends_wal=False mechanisms like OutboxHub's
drain loop that the session still needs — #4759/#4765 co-vet; see
tracked_tasks.py's own module docstring for the full regression story and why
the axis is named by WAL-appendability, not by task lifetime). On reconstruct,
restore() re-arms timers from the recovered snapshot, so cancelling here
remains reversible. See TrackedTaskSet.aclose's own docstring for the
re-entrancy case (a tracked task itself calling aclose(), e.g. the
ephemeral-vanish task's own await_quiescent call) and exactly which
guarantee that case weakens.
- Wall-clock bound on the whole call, rewind-path only (#4771) — the
RE-DRAIN LOOP above is bounded by round count, not by time; await_quiescent()
itself stays unbounded by design (its own docstring's "critical invariant":
returning early risks a straggler append landing past the reset-record).
AgentRegistry.checkout's step 3 wraps each session's call in
_await_quiescent_bounded, an asyncio.wait_for with a per-session bound
(_quiesce_bound_s — one _MCP_CLIENT_CLOSE_WORST_CASE_S unit, 6.5s, per
currently-held MCP connection, floored at one unit for a connection-less
session). On timeout it raises RewindQuiesceTimeoutError and checkout
aborts BEFORE the reset-record append (step 4) — fail-safe, not fail-open:
unlike shutdown() (safe to abandon a straggler because the process exits
right after), a rewound session keeps running, so proceeding past an
unquiesced session risks the exact silent-corruption class this whole
mechanism exists to prevent. Scoped to the rewind/checkout call site only
— await_quiescent() itself is unchanged for every other caller.
Family 3 — Hook-event / reactivity¶
_build_hook_event_bundle constructs hook_bus → the awaited hook_dispatcher →
fs_watcher → composer_registry → composed_consumer → hot_reloader together. It runs
right after Family 1's audit_events assignment because this family consumes
audit_events: hot_reloader reads it EAGERLY at construction (events=audit_events), and
hook_bus/hook_dispatcher/the Composers emit through deferred self._audit_events
lambdas.
The config-derivation this builder takes as inputs is resolved inline, BEFORE the builder
call:
- Hooks are LAYERED (#2073 S2b): the reyn.yaml startup layer (OUT-set, captured once as
_startup_hooks_raw, never re-read on reload) ∪ the .reyn/hooks.yaml runtime layer
(IN-set, hot-reloadable; the LLM-op writes it in S3). _build_hook_registry combines
them; the boot registry includes the runtime layer too (active from session start,
mirroring .reyn/mcp.yaml), and the hooks-reapply seam re-reads only the runtime layer +
re-combines.
- Composers mirror the same layering (Hook-Event Redesign Phase 4b/5, #2880/#2881):
_startup_composers_raw captures the composers: startup (OUT-set) layer once;
_build_composer_defs combines it with the runtime/per-agent/per-session layers (same
4-layer additive shape as _build_hook_registry). Composers are v1-startup-only — no
hot-reload/reapply seam, unlike hooks (restarting a live Composer's PendingStore
mid-session is a separate, not-yet-designed concern).
- _build_composer_pending_store (#3180) runs inside the Family 3 builder and returns the
DurablePendingStore shared by every durable composer (op: deadline by default), or
None when no definition asks for durability — so a durability-free session writes no
file. It reads/writes <per-session state dir>/composer_pending.json (the same dir
_toggle_store_dir uses), and prunes restored records whose composer no longer exists in
the combined config, so a renamed deadline cannot leave an arm nothing will ever disarm.
- _build_composer_defs is deliberately run BEFORE _build_hook_registry: it is a
pure/side-effect-free parse (confirmed — no hook-registry interaction), so knowing the
full set of configured composers (all 4 layers) BEFORE hooks are validated lets a
composed:* hook's matcher be schema-checked too (#2889) — closing the open-set gap
Phase 3 left for composed kinds (every composed event, across all 7 Composer ops, is
emitted by the single _emit_composed producer with the fixed payload shape
{"inputs": [...], "correlation_key": <key>} — composer.py:336-338 — so this schema is
knowable and identical for every composer, keyed by its emit_kind). Composers are
v1-startup-only, so self._composed_schemas is computed once here and reused by the
hooks-reapply seam too.
- _runtime_cron_names (#2073 S4) tracks the RUNTIME (.reyn/cron.yaml) cron job names so
the cron-reapply seam can unschedule jobs removed from the runtime file WITHOUT touching
startup (reyn.yaml) jobs (the same startup/runtime layering as hooks); seeded from the
boot IN-set, updated each reload.
- _fs_watch_cfg (#2608 H4 / #3082 Family 3) is a precursor/builder input resolved here;
the FsWatcher itself is constructed inside _build_hook_event_bundle alongside the
rest of the hook-event family. paths/debounce_seconds default to empty/0.2 when no
fs_watch: config block was resolved (mirrors hooks_config defaulting to []).
After the bundle returns, self._hot_reloader is published as the process-wide active
reloader (#2073 S3: set_active_hot_reloader) so the hooks-write LLM-op can
request_reload after writing .reyn/hooks.yaml (mirrors set_active_scheduler;
multi-session = last-registered wins, a known cron caveat). _register_hot_reload_seams
(#2073 S2) then registers the per-component hot-reload reapply seams once the
sub-components they orchestrate (router_host etc., built later by Family 6a) exist —
each seam reapplies one IN-set component live at the turn boundary, and the Session owns +
orchestrates them (a single multi-holder per-agent swap here, not scattered captures).
Hooks specifically use S2b; validate-before-apply applies too.
FsWatcher hook_trigger — deferred dispatcher lambda (#2608 H4)¶
fs_watcher (the session-owned filesystem watcher; see reyn.runtime.fs_watcher's
module docstring for the thread→async bridge design) is constructed
unconditionally inside _build_hook_event_bundle — this is cheap: no OS
thread is spun up at construction, only inside FsWatcher.start() (called
later, from run()).
hook_trigger is the same deferred-lambda-over-self._hook_dispatcher
pattern this family's H1 wiring uses elsewhere in this builder: at the point
fs_watcher is constructed, hook_dispatcher is only a LOCAL variable in
this builder — it is unpacked onto self._hook_dispatcher by the caller
only after this builder returns. The lambda is never CALLED until
FsWatcher.start() is awaited from run(), long after __init__ has
finished, so the deferred read is always safe.
Binding self._hook_dispatcher eagerly at this call site instead (e.g.
hook_trigger=self._hook_dispatcher.dispatch) raises AttributeError
immediately at Session construction, since the attribute does not exist on
self yet — this fails loud (any test constructing a Session hits it).
paths / debounce_seconds default to empty / 0.2 when no fs_watch:
config block was resolved — this mirrors hooks_config defaulting to [].
Composer registry / consumer construction vs start (#2880/#2881)¶
composer_registry and composed_consumer (the Hook-Event Redesign Phase
4b/5 Composer definitions + the composed:*→Sync consumer bridge) are built
inside _build_hook_event_bundle, but neither is STARTED there. Starting a
component means spawning background asyncio tasks — that requires run()'s
async execution context, not __init__'s synchronous one.
run() calls self._composer_registry.start() / self._composed_consumer.start()
once, near its top; both are stop()ed in run()'s shutdown finally block —
the same start/stop shape FsWatcher uses (constructed unconditionally,
started/stopped only from run()).
Calling .start() from inside __init__ instead would try to schedule
background tasks with no guaranteed running event loop bound to this
session's lifecycle, and would race ahead of sibling components (e.g.
router_host, built later by Family 6a) the composed-consumer bridge may
need live.
Family 4 — Cost / budget¶
_build_budget constructs the budget adapter — a byte-identical extraction, the
simplest of the #3082 families (no reordering). It runs here (unchanged position) because
it consumes Family 1's audit_events, read EAGERLY (events=). It returns the
BudgetGateway directly (#3121 step4 removed the prior single-field wrapper dataclass).
Two other cost-adjacent construction points stay inline:
- _cost_warn_config (#2230): the resolved cost_warn: config so the high-cost-model
warn/block gate actually fires in production. Without it the session had no config to
read and the gate silently no-op'd (fail-open) — this is a production-bug fix. Always
set (defaults when unthreaded) so the read can't AttributeError into a silent
fail-open.
- _offload_config (tool-result-schema-redesign §5): a debug lever disabling all
tool-result size gates (text cap / structured inline cap / media follow-up budget).
None → defaults (enabled=True, normal offload behaviour).
- The set_llm_call_limit_context call publishes the per-call budget-exceed policy for
the chat path's per-LLM-call cost gate (#1868, call_llm/call_llm_tools). It reuses
safety.on_limit (one unified limit policy) and the SAME intervention path the chat-side
limit checkpoint uses. run_id falls back to agent_name (session scope, mirroring
_handle_limit_checkpoint); non_interactive flows through so a non-tty run fails
closed (bounded); UNSET → fail-closed deny.
- #3053: the bus is resolved BRIDGE-AWARE via _make_router_intervention_bus (the same
seam #3052 gave every MCP router-op) instead of a self-bound _dispatch_intervention
captured on THIS session. Before this fix, a safety.limit prompt raised on an
ATTACHED spawned/driver session (a pipeline driver, a delegated sub-agent) dispatched
on the driver's OWN listener-less InterventionRegistry — silently auto-refusing
(enforce_listener_presence short-circuit) without ever reaching the pipeline
originator's live operator, violating the same intervention-delivery rule #3052
fixed for MCP ops (fails SAFE here, not into a hang, since
handle_limit_exceeded treats an empty/refused answer as "deny" — but still the wrong
surface). Resolving fresh on each call (not capturing a frozen bus reference) means a
re-bound bridge is picked up uniformly, exactly like every other router-op
intervention.
- _render_template_bounds (FP-0055 / #2679): the operator render_template output
bounds (max_output_chars/wall_clock_seconds), resolved once into a
RenderTemplateBounds and threaded to every router OpContext builder. Default config
(256,000 chars / 5.0s) is byte-identical to the prior in-handler fallback. The
render_template op reads ctx.render_template_bounds.
Family 5 — Retrieval¶
_build_retrieval_bundle constructs the embedding block (three attrs:
action_embedding_index/embedding_provider/embedding_model_class) — a
byte-identical extraction (same objects, same conditionals, same
try/except None-fallbacks, same args as the inline sequence it replaced).
The call site sits right after Family 1 (_build_audit_event_bundle)
rather than before it.
#4552 (2026-08) — action_usage_tracker removed. This family used to
also construct action_usage_tracker (the hot-list feature's freq+recency
ranking, ActionUsageTracker), plus an _on_hot_list_changed closure that
emitted a hot_list_updated audit-event on every ranking reorder — removed
with the hot-list feature (owner directive: discarded, superseded by
list_actions as the canonical discovery path). _build_retrieval_bundle
dropped its agent_name/audit_events params along with it (both existed
solely to feed the tracker's persist path and the closure's audit-emit,
respectively — nothing else in the builder read them).
The reordering this family's call site still carries (#3408: MOVED from its
original position BEFORE Family 1 to run right AFTER it, so the
now-removed hot-list closure could bind audit_events by IDENTITY rather
than a deferred self._audit_events NAME lookup — closing the #2856
accident's class, a name reference silently resolving to a DIFFERENT
EventLog than the one live when it was written) no longer has a live
reason attached to THIS family specifically — nothing remaining here reads
audit_events at all. The position is left as-is rather than reverted (no
live bug either way; reverting on its own merits, if ever warranted, is a
separate, later change). tests/repo/test_audit_events_single_assignment_
3408.py's AST arm (git-grep evidence: _audit_events = is a single
assignment repo-wide, in Session.__init__ only) remains true and
unaffected by this removal — its own subject was never this family's
attrs, just the identity-vs-name binding discipline generally.
_universal_wrappers_enabled (FP-0034 PR-3b-iii; renamed from _action_retrieval
and its source moved from the now-deleted action_retrieval: block to tool_use:,
4552 PR-3+4) drives whether the universal catalog wrappers appear in the router¶
tools=. A plain bool, chat_universal_wrappers_enabled: bool = True by default
(previously an ActionRetrievalConfig object; the field simplified to a scalar
along with the same move), so existing chat behaviour is preserved when callers
don't pass one. _eager_embedding_build
(B25-S5-1 fix): when True, RouterLoop awaits the embedding index build synchronously on
the first turn (Turn 1 blocks ~2-5s) so search_actions is visible to the LLM from the
very first call; default False keeps the lazy background-build path.
Embedding off-state degrade (FP-0034 Phase 2 step 1)¶
When embedding.enabled is not set (or universal_wrappers_enabled is
False), _build_retrieval_bundle leaves action_embedding_index,
embedding_provider, and embedding_model_class at None rather than
constructing stand-in objects (FP-0066 §7 — clean-break replacement for the
retired action_retrieval.embedding_class on/off gate; the model CLASS is
embedding.default_class).
This None state is a contract two OTHER call sites rely on:
- build_tools reads it to HIDE the search_actions wrapper from the
LLM-visible catalog entirely when embedding is off.
- The search_actions handler degrades to an empty-result response if it is
ever reached with a None index (defense in depth — build_tools should
already have hidden it).
Removing the None guard (e.g. always constructing a stand-in
ActionEmbeddingIndex) without updating both of those call sites would
surface search_actions to the LLM with a None/inert index; invoking it
would raise AttributeError inside the handler instead of returning the
intended empty-result degrade.
Embedding event sink — removed (#3438)¶
FP-0043 Component C.3 originally wired the embedding provider's lazy
model-load lifecycle (downloading / loaded / error) into the session's
events bus via an _embedding_event_sink closure (f"embedding_{kind}"),
threaded through Session -> OpContext -> the embed op -> the provider
(FP-0057 #2856 Part A) so the TUI could render a sticky status row. #3128
later removed the in-process embedding-model backend that had a lazy-load
lifecycle to report on — reyn depends on litellm exclusively for
embeddings now, and local/offline models are reached (if wanted) via an
operator-run litellm proxy, which reyn does not manage a download
lifecycle for (see
rag.md § Local and offline embedding models).
That left the sink with no producer: get_provider only forwards an
event_sink kwarg to a provider class whose signature accepts it, and the
sole implementation (LiteLLMEmbeddingProvider) never did — so no
embedding_* audit-event kind was ever emitted. #3438 (independent
re-verification confirmed the zero-producer finding, and found no
comment/ADR/issue recording an intent to keep the wire for a future
provider) deleted the sink, OpContext.embedding_event_sink, and every hop
of the threading, along with the KIND_FAMILY registry entry it used to
justify in event_schema.py's DYNAMIC_KIND_EMIT_SITES.
#4552 (2026-08): the _on_hot_list_changed closure this paragraph used
to warn about (a name-capture recurrence risk, C.4 hotfix 2026-05-27) is
removed along with the hot-list feature it belonged to (owner directive:
discarded) — the warning no longer has a live subject.
Family 6a — Router-waist (RouterHostAdapter)¶
_build_router_waist aggregates ~40 already-constructed Session sub-components (Families
1-5's outputs + params/early attrs set earlier in __init__) into RouterHostAdapter, the
single object most later families read through — a byte-identical extraction (same object,
same construction order, same values, including the DEFERRED per-turn callables —
live_session_id_inputs.live_session_id_fn and every *_fn field of the op-context
supplier — kept verbatim, still closing over self and resolved at call time, not
eager-ized). It stays UNMOVED, invoked at its original position — every dependency is
already set on self by this point.
#3607 op-context supplier: _build_router_waist also builds
self._router_op_context_source (a RouterOpContextSource, see
runtime/router_op_context.py) and hands the SAME object to the adapter as
op_context_source. It is the only caller of build_router_op_context, so
Session._make_router_op_context and RouterHostAdapter.make_router_op_context
are both one-line delegations to .build() and cannot hand out different
capabilities. It replaced a 16-field RouterOpContextInputs bundle that was a
COPY of the Session attributes the Session's own builder read directly — two
argument lists for one object, which had diverged on twelve fields.
#3482 param bundling: RouterHostAdapter.__init__ groups every real
consumer-set cluster (measured by AST, not by name prefix) into frozen,
default-free dataclasses built just before the constructor call — three of
them today (ROUTER_HOST_ADAPTER_BUNDLE_TYPES), each named after the sole
consumer the measurement found:
| bundle | fields | sole consumer |
|---|---|---|
McpGatewayInputs |
mcp_connection_service/mcp_agent_id/ephemeral_fn (#3447's Path A fold) |
_mcp_list_via_gateway |
PutOutboxInputs |
put_outbox/agent_replies_tracker |
the put_outbox method |
LiveSessionIdInputs |
session_id/live_session_id_fn |
the live_session_id property |
(SendToAgentInputs was a fourth bundle here — removed along with the dead
RouterLoopHost.send_to_agent/RouterHostAdapter.send_to_agent Protocol
member it existed solely to serve, #4144/#4153; InterAgentMessaging.send_to_agent,
the still-live P4e delivery transport, is a different, unrelated method.)
Session still builds each field with the exact same expression as before
(same object, same order, same call-time semantics — ephemeral_fn /
live_session_id_fn and the two tracker lambdas are still live per-turn
callables, not eager-ized); only the wire shape changed.
#3607 — ask the layering question BEFORE the bundling one. Four params
(file_read / file_write / file_delete / file_regenerate_index) left the
constructor entirely rather than becoming a sixth bundle. They shared a consumer
set, so the measurement above would happily have bundled them — and the result
would have been primitives neatly grouped, with the 57 lines of memory domain
rule still sitting in RouterLoop on top of them. The prior question is "is this
a capability, or a primitive the capability is assembled from?": the adapter now
receives memory (the MemoryService) and the router calls its operations. Note
what the bundling criterion cannot see — a Parameter Object moves parameters to a
common root; only a Facade Service hides the aggregate BEHAVIOUR. The five
bundles below are the former by design (their own docstrings say "no construction
logic"); they are not evil, they are insufficient for a layering defect.
The remaining bare params are bare because no other param travels to the
same set of destinations — and that is not recorded anywhere as prose. It is
COMPUTED, by scripts/measure_router_host_adapter_consumers.py (exact
consumer-set equality; a member that reads an already-bundled attribute is a
landed bundle's hub and is not counted twice), and enforced by
tests/runtime/test_router_host_adapter_param_gate_3482.py, which goes RED when a
bare param acquires an exact-match partner, when a param loses its last
measurable consumer without being shelved in
ROUTER_HOST_ADAPTER_CONSUMER_UNMEASURED, or when a written claim in either
registry contradicts the measurement. Bundle coverage is deliberately not
100% — forcing it would be name-prefix grouping pressure, not a consumer-set
one. Do not re-record the predicate as a per-param prose reason: a gate can
only check that prose is non-empty, never that it is true — when this was
tried, six such reasons were false, and they are the three bundles at the
bottom of the table above.
Chat turn_budget engine — None on small context, never raise (#1092 PR-F1)¶
_build_router_waist defines _build_chat_turn_budget_engine, a closure
passed to RouterHostAdapter as turn_budget_engine_factory= — NOT called
at construction (#3671 follow-up: TurnBudgetEngine.__init__ touches
litellm's model catalog, which used to put that cost on every session's TUI
startup for a value nothing reads until the first force-close check,
mid-turn). RouterHostAdapter._ensure_turn_budget_engine calls the factory
at most once, on first reference to wrap_up_output_reserve — a lazy,
single-owner cache (_TURN_BUDGET_ENGINE_UNSET sentinel, since None is
already a legitimate cached RESULT here — see below). When the factory does
run, it resolves the model — self._resolver.resolve(self.model).model —
mirroring how CompactionEngine resolves a model class before use (the
#1172
pattern: never hand the cosmetic class straight through).
try_build_default_turn_budget_engine returns None — it does NOT raise —
when the model's context window is too small to satisfy the by-construction
force-close floor (output_reserve + offload_cap < threshold). A
small-context model is a legitimate chat session that simply cannot support
force-close; it degrades to the pre-force-close path (no cap, no handoff)
rather than failing __init__ outright. This is exactly why the lazy cache
needs a sentinel distinct from None: None is this function's own valid
"non-viable" answer, not "not computed yet".
The engine is ADDITIVE: its sole consumer is
RouterHostAdapter.wrap_up_output_reserve, inert until the F2 handoff calls
_force_close_call — chat stays REACTIVE-only by deliberate per-axis
choice. Changing the builder to raise instead of degrade would make every
small-context-model session fail construction, not just skip one feature.
A /model switch rebuilds this EAGERLY, as part of Session
._rebuild_derived_model_engines_for_model (the ONE private-Session entry
point the /model slash handler calls for BOTH this engine and
CompactionEngine's own rebuild below — folded into a single accessor
rather than exposed as two, per the _SESSION_RESIDUE ratchet in
test_3595_s4_slash_handler_seam.py; see that method's own docstring).
turn_budget's half stays eager while compaction's half (below) stays lazy
— a deliberate difference, not an inconsistency: by the time /model
runs, maybe_block_high_cost_model has already touched litellm on this
same call, so TurnBudgetEngine's own touch is a cheap re-touch either way,
and its result feeds a per-turn cap check worth having correct immediately.
Family 6b — History / compaction¶
_build_history_compaction_bundle constructs history_buffer / compaction_controller
(including the None-then-patch that breaks their circular dependency) / budget_advisor —
a byte-identical extraction, same construction sequence, same position (right after Family
6a's router_host, since history_buffer eager-depends on it). The session drives
compaction via force_compact_now() (pre-frame guard) — background task lifecycle was
removed in #1128 PR-a, all callbacks resolve against self at call time. _token_learner
(PR-N6) is the adaptive per-user token-estimation learner.
#4552 (2026-08): this builder used to also take a merge_action_usage param — the
_merge_action_usage_from_candidates closure (FP-0019 Wave 1 / #1128 PR-a), a hot-list
compactor sink — threaded through as a nested closure passed to the builder as a callback.
Removed with the hot-list feature it fed (owner directive: discarded).
CompactionEngine model — resolved class, not the cosmetic label (#1172)¶
CompactionEngine's model= argument is self.model — the model CLASS
label (e.g. "standard"), NOT a pre-resolved litellm string.
CompactionEngine.__init__ resolves it itself via its own resolver
param — the #1172 guarantee lives INSIDE the engine, not at each call site,
so no construction site (chat / planner / phase) can leak an unresolved
class to litellm by forgetting to resolve first.
Before #1172, the unresolved class was handed straight through: litellm
received "standard" literally, which is not a real model identifier, and
litellm.acompletion raised BadRequestError on the FIRST compaction
trigger for that session — a dead-end-critical failure mode, since
compaction is what keeps long-running sessions inside their context
window.
#3785 removed the model_class_by_purpose.compaction override #1679
had layered on top (self._resolver.purpose_class_or("compaction",
self.model)): compaction never tracked a /model switch mid-session
(nothing ever rebuilt it), so an operator who set a per-purpose compaction
model was silently getting a STALE conversation model instead of either
the compaction override or the current one — the config key described a
guarantee the code never provided. Compaction now always follows
self.model directly, and a config that still declares
model_class_by_purpose.compaction fails to load
(config/root.py::_build_model_class_by_purpose) rather than silently
doing nothing.
Construction is lazy, and so is the rebuild (#3671, #3785)¶
_build_history_compaction_bundle defines _build_chat_compaction_engine,
a closure passed to CompactionController as compaction_engine_factory=
— NOT called at construction (#3671 follow-up: CompactionEngine.__init__
measures T_comp_SP and derives budgets, both of which touch litellm's
model catalog, for a value nothing reads until compaction actually
triggers, mid-turn). CompactionController._engine (a property) calls the
factory at most once, on first reference, caching the result — None means
"not built yet" here (unlike TurnBudgetEngine's sentinel: CompactionEngine
has no legitimate "built but absent" case, so None has exactly one
meaning and needs no sentinel).
A /model switch reaches this half through the SAME Session
._rebuild_derived_model_engines_for_model call as turn_budget's rebuild
above (not a separate accessor — see that method's docstring), which calls
CompactionController.rebuild_engine() — this ONLY discards
the cache (__engine_cache = None); it does not rebuild eagerly. The SAME
factory closure reads self.model fresh on each call, so invalidating the
cache is sufficient: the next real compaction trigger builds against
whatever model is current at THAT moment, not the one active at /model
switch time. This stays lazy (unlike TurnBudgetEngine's eager rebuild,
above) because a /model switch that never triggers compaction again
should not pay to rebuild it — consistent with #3671's construction-time
discipline.
Family 7 — Intervention¶
_build_intervention_bundle constructs chains / interventions /
intervention_handler / intervention_coordinator / chain_timeout_glue — a
byte-identical extraction, same construction order, same position as chains's original
spot. chain_timeout_glue is the one exception: UP-moved from its original position
(~160 lines below, AFTER Family 8's InterAgentMessaging) to land here, inside this same
contiguous builder call, BEFORE InterAgentMessaging (which stays untouched and reads
self._chains) — this UP-move is safe because the F8→F7 self._chains cross-dependency is
preserved (F7 now runs strictly before F8, so self._chains is already set when F8 reads
it).
_pending_command_ui (F4) is a one-shot command-UI request (e.g. the /rewind checkpoint
picker) that a front-end renders as a selector; the inline CUI region polls it like it
polls the head intervention, and the plain --cui path renders a text fallback. None =
nothing pending; a dict carries {"kind", ...}.
Family 8a — Inter-agent messaging¶
_build_inter_agent_messaging constructs InterAgentMessaging (FP-0019 Wave 2 part
2 — agent-to-agent messaging service, extracting _send_to_agent/_send_agent_response/
_handle_agent_request/_handle_agent_response/_resolve_pending_chain from Session;
hybrid design (案 C): InterAgentMessaging owns agent-side logic, transport-side routing is
handled by FP-0013 RoutingLayer via send_request_callback/send_response_callback
injection). Byte-identical extraction, same construction order, same (unmoved) position —
post-waist, reading Family 7's self._chains and Family 1's self._audit_events eagerly
(both already set by this point) plus a tail of deferred self.*/lambda: self.* closures
kept verbatim.
Family 8b — Memory¶
_build_memory constructs the memory-store capability — MemoryService, which owns
memory path resolution plus remember / forget / read_body and the domain rules
they carry (the FP-0050 memory-write threat scan, YAML frontmatter, listing-index
regeneration, knowledge-index ingest/de-index via the injected MemoryKnowledgeSync).
RouterHostAdapter receives it whole and exposes it as host.memory; #3607 removed the
adapter's memory_path / memory_dir delegates and its four file_* callbacks, which
existed only so RouterLoop could assemble those operations itself.
Position unmoved and PRE-WAIST, before Family 6a's _build_router_waist reads
self._memory eagerly. Args are eager self._X reads except knowledge_sync's
op_context_fn, a lambda resolving self._router_host at call time (the waist does not
exist yet here, and an OpContext is per-turn state that must not be snapshotted).
Family 8c — MCP connection service¶
_build_mcp_connection_service — see the builder's own docstring for the full deferred-
resolution crux (4 lambdas resolving self._audit_events/self._router_host/
self._hook_dispatcher/self._interventions at CALL time, none of which exist yet at this
position in __init__).
MCPConnectionService — four deferred lambdas over not-yet-built siblings (#2597)¶
_build_mcp_connection_service (Family 8c) constructs the session-owned
held-open MCP connection service (Option C: one persistent MCPClient per
server, reused across chat turns/tasks for the session's whole lifetime).
Construction is unconditional and cheap — an empty dict until the first
get().
Ephemeral routing. Only the non-ephemeral MCP call sites
(Session._mcp_call_tool and, on RouterHostAdapter,
mcp_list_servers/mcp_list_tools/mcp_list_resources/
mcp_list_resource_templates/mcp_list_prompts — #3447 folded the five
listing methods off Session onto the adapter itself, threading this same
self._mcp_connection_service instance through as a raw constructor
argument) route through this held-open service. An ephemeral session
(self._ephemeral, set post-construction by the registry once spawn mode
is known — read via a LIVE ephemeral_fn callable on the adapter side, not
a snapshot, for the same reason session_id/ephemeral are read through
live providers elsewhere in this doc) keeps using the per-call
MCPClientPool instead, so a sub-second-lived spawned session never holds
a server connection open needlessly. The service is closed at session
teardown via aclose_mcp_connections (registry.remove_session /
archive_agent's main-session path).
Deferred lambdas. Three of the six constructor arguments are lambdas
that defer resolution to CALL time, because none of the attributes they
close over exist on self yet at this point in __init__:
- emit_sink / tools_cache_invalidate defer self._audit_events /
self._router_host (both assigned later in __init__) — mirrors the
emit_event=lambda et, **d: self._audit_events.emit(et, **d) pattern used
elsewhere.
- hook_trigger defers self._hook_dispatcher (same H1 pattern
_build_hook_event_bundle's fs_watcher uses).
None of the three is ever CALLED until a held MCP connection actually
receives a server-pushed notification, long after __init__ has
finished — but eager-binding any of them here (e.g.
tools_cache_invalidate=self._router_host.invalidate_mcp_tools_cache)
raises AttributeError immediately, since self._router_host does not
exist yet at Family 8c's position in __init__.
elicitation_gate uses the SAME consent_bus/consent_gate split #2095's
shell-hook consent already uses: a server→client elicitation only routes
through this session's RequestBus when a live intervention listener is
attached; headless (no listener) auto-declines inside the handler
(reyn.mcp.elicitation), never here. elicitation_bus=self.as_request_bus()
is safe to call EAGERLY (unlike the deferred lambdas) — it only wraps
self in an adapter and reads nothing constructed later.
agent_name=self.agent_name is likewise eager, since self._agent is
already set earlier in __init__.
Capability, permission & visibility¶
_available_skills(#2548 PR-A) — enabled skill registry snapshot backing the system prompt's## Skillsblock.None→ the block is omitted from the prompt entirely, never rendered empty._skill_collisions(#3100 Axis 4) — same-name-across-config-tiers collision map, consulted by:skillinvocation (reyn.interfaces.skill_invoke) to fire a LOUD audit-event + warning instead of silently letting one skill definition shadow another._exclude_tools(#187) — tool names excluded for the MAIN chatRouterLoop, threaded to the loop construction below. General capability (mirrors the sub-loopexclude_tools,planner.py:1136); the faithful SWE-eval excludesweb_search/web_fetchso the agent solves from the repo + issue, not a web lookup of the gold solution. #3378: this is not a separate advertisement axis —RouterLoopcomposes it into_contextual_permissionas one more restrict-only ∩ term, so it reaches the LLM-visible catalog and the livetool_excludedgate through the same field (before, an explicit contextual discarded it from enforcement while it still filtered the catalog)._contextual_permission(#1827 S3) — per-sessioncapability_profilenarrowing (ContextualPermission) resolved from the agent's topology role. Threaded to the live tool gate (RouterLoop) + control-IROpContext.None= no narrowing (byte-identical). #3378: it is also the source the LLM-visible catalog is filtered by, so a tool this narrowing denies is never advertised in the first place — see Capability profile § Advertisement and enforcement read one source._untrusted_contextual_cache(#1827 S4b, context-auto) is a lazily-resolved minimal_untrustedprofileContextualPermission, composed into the per-turn narrowing while untrusted external content is live in context;Noneuntil first needed — and it is never resolved at all unless the operator opted in viasafety.threat_scan.capability_narrowing(#3501; defaultoff). See Capability profile § Context-auto untrusted narrowing._excluded_categories(#1667) — catalog categories hidden at the universal-catalog source (e.g.reyn_repoon the external-repo eval path so it doesn't compete withfile__*for the weak model); interactive default empty =reyn_repokept._visibility_override(#2285) — session-scoped LLM tool-VISIBILITY override: the capabilities the user toggled OFF via the status bar, per kind. Applied as one more restrict-only ∩ conjunct ON TOP of the re-resolved agent envelope (_reapply_visibility_override), so it can only HIDE within the authorized set (visible ⊆ authorized by construction). In-memory (step1 live); step2 was planned to persist it to the per-sessionconfig.yamlsoresolved_profile_for(sid)re-derives it._disabled_hooks(#2285) — session-scoped hook APPLICABILITY override: hook names the user disabled via the status bar. TheHookDispatcher(per-session) skips a hook whose name is in this set at dispatch time (live). Per-session by construction: eachSessionowns its own dispatcher + this set, so disabling a hook in session S1 does NOT affect S2 (even though the hook config is shared). In-memory (step1 live); step2 was planned to persist to the per-sessionhooks.yaml._spawned_tasks(#2103 S1bc-exec) — sid → original-task record for sessions THIS session spawned. When a spawned session's result routes back, the result header renderstask=<the spawner's OWN request>from THIS trusted record (keyed by the spawned sid) — never the spawned session's echo (which a compromised sub-session could forge into trusted framing). Bounded-by-construction: evicted on result arrival; a max-size cap (evict-oldest) caps a never-arriving result.
sandbox_backend gate reads the injected instance, not the config string (#1417 / FP-0034)¶
The exec D14 visibility gate — whether exec/sandboxed_exec appears as an
available capability in the universal catalog — must gate on the INJECTED
backend's real capability, not the reyn.yaml sandbox.backend config
STRING.
sandbox_backend=_exec_gate_backend_name(self._sandbox_backend,
self._sandbox_config) reads self._sandbox_backend — the SAME object used
for actual exec (tools/exec.py, #3226 Phase 3 renamed from
sandboxed_exec.py: ctx.sandbox_backend or get_default_backend(...)).
Both injected backend types (DockerEnvironmentBackend, SandboxBackend)
expose .name.
The construction-forwarding-gap this closes: without reading the
injected instance, sandbox.backend=noop config plus an injected exec
backend (e.g. --env-backend=docker) would HIDE exec from discovery even
though sandboxed_exec is functionally available through the injected
backend. The mismatch produces no error — the capability is simply absent
from the catalog the LLM sees, indistinguishable from an operator who
deliberately disabled exec. No injected instance → falls back to the config
string (auto / host-default behaviour unchanged).
available_skills_fn reads the BASE set, not the UX-filtered copy (#3196)¶
RouterHostAdapter receives two related-but-distinct skill sets:
available_skills=self._available_skills— the base registered-skill set (#2548 PR-A).available_skills_fn=lambda: self._available_skillson the op-context supplier — a LIVE read of that SAMESession._available_skillsfield.
The reason for the second, seemingly-redundant source: RouterHostAdapter
holds its OWN _available_skills attribute, which reapply_skill_visibility
mutates into a UX-filtered COPY (skills the user toggled off via the status
bar disappear from it, #2285). The router OpContext uses
available_skills_fn for the file op's skill-load provenance gate — a
TRUST decision, not a UI-visibility decision.
If a future edit swaps available_skills_fn to close over
RouterHostAdapter's own (filtered) _available_skills instead of
Session._available_skills, the provenance gate would start following the
UX visibility toggle: a skill a user merely HID from their own view (but is
still authorized to use) could fail a trust check it should pass, or vice
versa. Neither failure raises an exception; it only shows up as a wrong
trust decision on a specific skill, discovered later if at all.
Multimodal / media¶
_multimodal_config(#364) — media-size gate config plumbed through to spawned Agents AND to the router host adapter (chat-routerweb_fetch/read_file/mcp paths)._media_store(#383 PR-C) — a singleMediaStoreinstance per Session, constructed from the multimodal config's storage dirs, then threaded into spawned Agents (for control-IR ops invoked from sub-agents) AND into the router host adapter (for ops invoked directly from the chat router via tool calls).Nonewhen no multimodal config is supplied — handlers fall back to the pre-#383 inline shape.agent_name(β core impl sub-task 1) is set so path-refs minted by this session carryresource_uri/source_agentso cross-host consumers (other agents via A2A/MCP/Browser) can dispatch back here.base_url(β core impl sub-task 3b) is set only when this Reyn instance is reachable over HTTP (operator setsmultimodal.base_urlinreyn.yaml), so path-refs also carry aurlfield pointing at the resources router — cross-host consumers can then HTTP GET the body; when unset, only same-hostpathis available._pending_user_images(#366) — queue of image blocks the user attached via/image PATHor--image PATH, drained on the next user-message turn (attached to thatChatMessage'smediafield). litellm-style content parts:{"type": "image_url", "image_url": {"url": "data:...;base64,..."}}._web_fetch_config(#4274) —reyn.yamlweb_fetch.*(SSL verify / private-IP opt-in / download-byte cap), plumbed straight into the routerOpContext.web_fetch_configtheweb_fetchop reads. Same shape as_multimodal_configabove (a plain value, not a per-turn supplier —WebFetchConfigdoesn't change mid-session). Threaded fromSessionFactoryConfig.web_fetch_config(#2093 bundle), so it reaches all five session-factory sites uniformly; before #4274 the field existed onOpContext(#4174 T4) but no factory site ever populated it, so every real session silently ignored an operator'sweb_fetch:block.
Safety, limits & interactive mode¶
_router_max_iterations(#187) — per-message tool-call budget for the MAIN chatRouterLoop. The interactive default (5) suits a human turn; an autonomous one-shot run (reyn chat --oncefor SWE) needs far more (explore→edit→verify rounds), so the one-shot path constructs the session with a higher value. Bounded either way — the loop stops at the cap (finite) or when the agent ends._non_interactive(#1439 Fix #1) — in run-once (no interactive user) the router SP must not tell the agent to "ask ONE clarifying question" (nobody answers → dead stop,#13398). Threaded tobuild_system_prompt. DefaultFalse= interactive byte-identical._hook_driven_turns(#1800 slice 7) — the loop-valve counter: hook-driven (kind="hook") turns since the last human user turn; resets on each user turn (re-arm). Bounds hook self-continuation. Snapshot-backed since #2884 (AgentSnapshot.hook_driven_turns;restore_staterehydrates it,SnapshotJournal.record_hook_driven_turnspersists it) — a pure in-memory counter reset to 0 on crash+restart, handing a near-cap self-wake chain a fresh budget window. (This entry previously read "in-memory only (NOT snapshot-persisted)", which #2884 falsified without updating the doc.)next_turn_context(#1800 slice 4b; moved offSessionontoInboxArbiterin#3978P1, née_next_turn_context) — in-memory staging buffer forwake=falseride-along (C) messages drained byInboxArbiter.drain_to_wake. Entries are applied to the next trigger's turn as attributed system-role history entries. Persisted durably in the snapshot (decision B) via_journal; restored byrestore_state. Cleared (durably) after injection at the trigger turn._buffered_intervention_answers(PR-intervention-link L6) — in-memory buffer of answers from restored-then-resolved interventions, keyed byrun_id. The firstbus.requestfrom the resumingrun_idconsumes the entry and returns it without re-dispatching. Persistence across the (user_answered → process_crashed → run_not_yet_resumed) window is R-D12 follow-up._current_task_id(#1953 §16, recursive-request) — thetask_idthis session is currently EXECUTING as a task-as-request, set per-turn from an execute-wake's meta (run_one_iteration). Read by the router op-context supplier sotask.createderives ownership (requester=<this task>).None= not executing an assigned task (a user / hook / recovery turn). Slice B extends the lifetime to a persistent assignment spanning continuation + recovery turns._current_turn_origin(proposal 0060 Phase 1 Layer A, A7) — the OS-authoritative provenance classification of the turn currently being processed, mirroring_current_task_idexactly (same seam, same threading). Set per-turn in_stamp_execution_context; read per build by the router op-context supplier so install-op handlers (skill/pipeline/present, A9) stampentry["provenance"]from a single OS-set source the LLM cannot spoof. Initialized to the STRICTER value (fail-safe: never default to"user_directed"before the first turn is classified)._ephemeral/_vanish_scheduled/_vanish_task(#2103) — a spawned EPHEMERAL session (spawn-timemode="ephemeral") auto-vanishes once its task is done. Set post-construction by the registry on an ephemeral spawn; the main session + persistent spawns leave_ephemeralFalse._vanish_scheduledguards against a double-schedule across turns.
Misc lifecycle wiring¶
_STATE_CHANGE_EVENT_MAPPINGSper-entry rationale (why each op-emitted event is worth astate_changeline, not just that it is one):mcp_server_installed(mcp_installop) — the LLM otherwise has no way to learn a newly-installed server exists until it re-lists tools; surfacing it asstate_changelets it use the new server within the same conversation without an explicit re-check.mcp_server_removed(mcp_drop_serverop) — symmetric tomcp_server_installed: surfaces the 'no longer available' state-change so the LLM stops retrying calls against a removed server instead of discovering the failure only on next invocation.index_dropped(index_dropop) — recall against the dropped source will now miss; surfacing the change lets the LLM understand a source it cited earlier in the conversation no longer exists, rather than treating a later empty-recall result as a retrieval bug._spawn_tracker(SpawnTracker,#2103, see#3133P3 Extract Class) — owns the spawned-task correlation record + ephemeral auto-vanish scheduling state; Session holds one reference and delegates via thin forwarders rather than re-owning the state (seespawn_tracker.py). Itssession_id/ephemeralinputs are threaded as LIVE providers (lambda: self._session_id, etc.), not snapshotted values — both are reassigned post-construction by the registry (spawn-time re-key, ephemeral-spawn flip), so a value captured atSpawnTrackerconstruction time would go stale the moment the registry re-keys or flips it.CapabilityVisibility(constructed later in__init__) faces the identical hazard for its ownsession_id_providerand uses the same live-lambda fix — see that construction site for the mirrored pattern._on_perm_persist_cb(#398 v4 emitter wiring, permission_manager → state_change) — subscribes to_persistevents on the sharedPermissionResolverso a permission grant/revoke mints astate_changehistory entry in this session; the LLM sees "permission for X was granted" in its next turn and breaks out of the #352 refusal trap. Stored as a bound method so the same reference can be unregistered on session shutdown.ChatLifecycleForwarder(#162) — surfaces session-level lifecycle events (compaction today; attach/detach + budget warnings as growth) into the conv pane viaOutboxMessage(kind="system"). Given THIS session's ownEventLog(#2708 P3.1 Half-B) so its driver→parent bridge (on_pipeline_run_attached) can re-emit a driverpresentedaudit event onto the PARENT's log (bridged_from=<driver_sid>), closing the split audit trail the visible-output bridge (Half-A) leaves._on_audit_event_for_state_change(#398 v4 emitter family) — a generic events-log subscriber converting known op-emitted events (mcp_server_installed, future:config_reloaded/sp_version_changed) tostate_changehistory entries via the_STATE_CHANGE_EVENT_MAPPINGSdispatch table. Sister to thepermission_managerdirect-callback wiring (PR #456)._presentation_consumer(#2708 P1) — the present-sink consumer. In production it is always supplied bybuild_scoped_chat_session(required kwarg); a direct/test construction (None) falls back to the outbox-backed consumer so the per-turnOpContextstill wires anOutboxPresentationRenderer(byte-identical to the removed uniform default). The renderer is obtained lazily (sink(self)) so it can bind this Session — noOutboxPresentationRendereris instantiated at this call site; the AST guard (test_present_sink_ast_guard_2708) requires the sole construction site to beOutboxPresentationConsumer.sink()._intervention_bridge(#2708 P3.2a) — the spawn-time intervention bridge (None= self-bound default). When set (attached pipeline driver), the routerintervention_bus_factorybuilds a bus bound to the PARENT session so the driver'sask_userreaches the parent's live operator listener by construction (mirror of_presentation_consumer)._pipeline_registry(IS-5 / #2575) — Session owns a livePipelineRegistrysorun_pipelinehas a registry to look up against. The session factory builds it ONCE fromconfig.pipelines(disk scan → parse → register) and passes it in; a direct/test construction with no registry falls back to an empty one (byte-identical to the pre-#2575 own-constructed empty registry). Threaded toRouterHostAdapter(mirrorsagent_registry=self._registry) →RouterCallerState.pipeline_registry→ the universal catalog'spipelinecategory enumerator surfaces each registered pipeline aspipeline__<name>to the LLM (IS-5 D19)._presentation_registry(FP-0054 PR-C) — the session's named-presentation-template registry. Threaded toRouterHostAdapter(mirrorspipeline_registry) → each routerOpContext'spresentation_registry→ thepresentop's stage-1 template resolution. The hot-reload seam (_reapply_presentations) SWAPS this reference AND the adapter's captured copy so a newly-registered template is visible at the next turn boundary.None(direct/test) → empty registry._max_hop_depth(PR11) — max delegation hop depth (LangGraph-style).0= user input, each_send_to_agentincrements; refuse send when depth > limit._chain_timeout_seconds(PR18) — per-chain wall-clock budget. Non-positive disables. When the budget elapses, the runtime synthesizes an error response upstream so a chain stuck on a non-responsive delegate doesn't hang forever._on_limit/_safety_extensions(FP-0005) — per-session safety-limit checkpoint policy, and per-(turn or chain) extension counters granted by_handle_limit_checkpoint, cleared on turn/chain boundary by the relevant call sites._allowed_mcp(PR37) — optional MCP server allowlist from agent profile.None= no per-agent restriction (inherits project config);list[str]= only these servers pass the per-agent check inrequire_mcp.router_config/retry_config(#1829 S3b / #1835) — published as ambient ContextVars for the LLM chokepoint (set_router_config/set_retry_config), guarded so they're only set when provided — a nested construction never clobbers an inherited ContextVar withNone. Runs spawned within this session inherit the ContextVar (propagation)._loop_driver(session.py refactor PR-3) —RouterLoopDriverowns the per-turn loop orchestration (run_turn, shrink/overflow, cap enforcement, cancel); an injectable seam (loop_driverparam) replaces the default construction, e.g. for tests._cancel_forward_targets(#2588) — additional cancel-forward targets.cancel_inflightalways cancels this session's OWN_loop_driver(the turn); it ALSO firesrequest_cancelon every callable registered here. Populated only transiently — e.g.run_pipeline_attachedregisters the spawned pipeline driver-session'srequest_cancelfor the duration of a sync attached run, so a Ctrl-C on THIS (the attached caller) session reaches the driver-session's cooperative cancel flag (the executor's step-boundarycancel_check). Empty for every ordinary turn, so the normal turn-cancel path is byte-identical when nothing is registered.