rig-ecs
Experimental. The rig-ecs API may change in minor releases. CONTRACT.md states the runtime behaviour its tests pin.
Rig's effect bus and agent runtime in a Bevy World. Effects and handlers are
entities; systems dispatch work and publish outcomes; relationships carry causal
and conversation structure. App::new().add_plugins(rig_ecs::RigPlugin::default())
is a host; app.update() is one pass of RigSchedule, and the default runner
updates when a task raises bus::Wake. Handler tasks and application systems
retain their own execution costs; bounded collection does not make arbitrary
user work nonblocking.
The bus uses bevy_ecs and bevy_tasks. Agent components, request assembly,
steering and cassette-owned replay build on that bus. See CONTRACT.md for detailed
behavior and the provider comparison guide
for the scope of cross-runtime regression tests.
Concrete logs and replay adapters live in rig-cassette with its ecs feature;
this runtime never depends on that crate. The adapter does not require
rig-agent or native HTTP. Attach any rig_core::serve::Recorder with
bus::Recording::install, or retain an EffectLogRecorder through
rig_cassette::ecs::EffectLogResource::install.
For replay, add rig_cassette::ecs::ReplayPlugin after RigPlugin or
BusPlugin, then register with rig_cassette::ecs::Replay::register(&mut World, &EffectLog).
The plugin installs the recorded-delivery collector and idle-refusal diagnosis;
the runtime retains generic scheduling, delivery and observation mechanisms.
The run as a graph
Each utterance owns ordered ChildOf/Children content entities, each carrying
one reflected agent::content::parts::ContentPart enum. Query &ContentPart
(or &mut ContentPart) and match variants such as ContentPart::Text(text);
the former separate payload components are gone. Media metadata structs are
variant fields, not components. ContentPart::ToolResult owns ordered text,
image or JSON child entities; ToolResultStatus remains a separate component.
read_message reconstructs role-checked DTOs; write_message validates sources
before replacing the graph. Binary payloads remain shared through BinaryAssets.
Variant changes are not component additions/removals. For lifecycle-observed
changes, insert the replacement ContentPart and match variants in
On<Insert, ContentPart> / On<Discard, ContentPart> observers; these do not
observe in-place edits. Previously disjoint mutable payload queries now access
the same component: use one matching query, or a ParamSet.
Checkpoint format 2 stores these enum components; format 1 checkpoints are refused, with no legacy component adapter.
rig_ecs::checkpoint::save_world takes the world as reflected data: every
entity with a registered reflected component, components by type path, an
Entity in a component as that entity's checkpoint index, parents before
children in Children order. A host's own components travel with the entities
they sit on once the host registers the type with #[reflect(Component)] in
the world's AppTypeRegistry; an unregistered type path is refused on load.
In-flight state (Serving, Streaming, Handler, cache views) is not
reflected and never saved; relationship targets are rebuilt by their sources'
hooks. Resources, system-local state and live tasks remain host-owned. Bind
handlers before loading (a checkpoint handler bound to a key the world serves
merges into the world's, which wins); install application insertion observers
afterward to avoid reacting to partially restored state.
rig_cassette::ecs::identity::stamp_run captures supported effective
configuration, including run-over-agent overrides. That module's
check_replayable checks the requested run's exact
Scope; it does not search for another matching policy hash. Applications
must declare a nonempty agent::PolicyVersion for their custom systems,
ordering and otherwise-unhashed configuration. A missing declaration is
reported as unverified. This declaration is not an automatic code fingerprint
or a check of ambient credentials and external state. A rig-agent golden's
builder header is that runtime's own and is not an effective compatibility check.
check_replayable can run in a fresh world bound to the log's replayers.
Recorded model identity and capabilities stay authoritative, including native
output composition with tools. Reapply the same middleware before checking:
a replayer supplies the inner handler's recorded exchanges, and does not
execute a descriptor's named layers by itself. Changed semantic models,
capabilities, effective settings and application versions still change the
identity. Replayer registration includes every scoped required row, so an
advertised but unused tool remains available for scene reconstruction; an
unexpected call to it fails with divergence.
If a selected model is removed before dispatch or between turns, assembly
terminates the run with Failed(Failure::Provider(report)). A missing
relationship or descriptor reports HandlerUnavailable; a non-completion
binding reports its key and wrong family. An outstanding tool can finish
before the next assembly detects the missing model. This differs from
cancelling an in-flight operation.
The request the model sees is derived, never authored: a run entity, utterances ChildOf it in sibling (Children) order, documents as their own entities attached to a turn by link entities, tools as the handler entities the bus already has (granted by link entities), the model as a relationship, every setting a component — and one function, policy::fold_request, that fold_turn calls at RigSet::Assemble over the graph it walked, writing the wire CompletionRequest into the turn's PendingEffect. CONTRACT.md names the walk field by field with the golden that pins each; the world interpreter (crates/rig-cassette/tests/corpus/world.rs) exercises request assembly through the maintained corpus, including tools, memory and steering.
| Entity | Components |
|---|---|
| Agent | Owner, Preamble, Temperature, MaxTokens, AdditionalParams, Options, ProviderOptions, ToolChoiceSpec, Output { mode, schema }, OutputToolConfig, MaxTurns, DefaultMaxTurns, InvalidCalls; UsesModel → the model's handler entity; Grant link entities → tool handler entities; Context link entities → documents |
| Document | DocumentId, DocumentText, DocumentProps; attached to a turn by an Attachment link |
| Utterance | Utterance, Role, AssistantHead for assistants; ChildOf the run, with ordered content children |
| Content part | ContentPart; ChildOf an utterance or a ContentPart::ToolResult entity; optionally ToolResultStatus on a tool result |
| Run | Run, RunOf → agent, RunSeq, StreamRequested, Cursor, a RunPhase (LoadingMemory, Assembling, AwaitingModel, ResolvingTools) or an ending (Settled, Failed(Failure)), RunResult, Usage, OutputRetries, OutputToolName, the run's own overrides of the agent's settings, the bus's Scope, a Name |
| Turn | Turn, ChildOf the run; Advert links → the tools it advertised; Attachment links → its documents; Outputs (per tick for a stream); Reprompt; Batch while its tool calls are out; systems::{Fresh, Folded, Materialised} |
| Effect | the bus module's, ChildOf the turn: the completion, then one per call to a granted tool (ToolCallSlot says which call; the bus's ToolInputs carries the run's ToolContextSpec) — the batch is the turn's children, ToolPolicy { concurrency } on the run or the agent says how many fly at once |
| Invalid call | InvalidCall + Resolution, ChildOf the turn |
| the checkpoint | rig_ecs::checkpoint::{save_world, load_world}: the whole world, graph and effects alike |
OutputToolConfig optionally sets the output tool's reserved name, description,
and preamble augmentation. A run's component overrides the agent's. With a
schema, an explicit name commits tool output; a conflicting granted tool fails
before dispatch. Once minted, the name remains fixed for the run. Checkpoints
and replay identity retain this configuration.
The agent's sets, around the bus's:
| set | true before | written during |
|---|---|---|
RigSet::Advance |
a run in Assembling has no fresh turn |
a turn with its adverts and attachments, or Failed(MaxTurns) |
RigSet::Select |
a run may lack a model of its own | the agent's UsesModel, copied — a routing system before it gives the run another |
RigSet::Assemble |
a fresh turn's graph is complete | fold_turn folds the graph and spawns the effect; the run is AwaitingModel |
RigSet::Patch |
the folded effect is a PendingEffect |
the second steering slot: a user system rewrites the folded request |
RigSet::Release |
a turn's tool batch is out | release_batch releases its rig-ecs/batch owner up to the concurrency, in call order. Policies use bus::acquire_hold and bus::release_hold with distinct stable emitter names; Held remains until every owner releases. A pre-existing bare Held is retained as an independent unknown owner. |
BusSet::Gate … BusSet::Judge |
||
RigSet::Fold |
the effect may have streamed or landed | Outputs on the turn |
RigSet::Judge |
the turn's outputs are complete | a user system may rewrite them, or a tool child's EffectOutcome |
RigSet::Materialise |
a complete turn is unread, or its batch has landed | land_batch: one user utterance of the results in call order (CONTRACT §8), or a failure; then the chain record_usage, judge_invalid_calls, read_turn, materialise_assistant, materialise_batch, materialise_reprompt, materialise_answer: the assistant utterance, the answer, a reprompt, an invalid call, the tool batch, or a failure (CONTRACT §4) |
RigSet::Checkpoint |
committed graph writes are visible | the host's slot to inspect or save before the next advance |
RigSet::Settle |
a run settled or failed | append_memory (a remembering run's append); diagnostics::measure when a DiagnosticsStore exists; observers on Settled / Failed |
systems::diagnostics publishes bevy_diagnostic paths — EFFECTS_IN_FLIGHT, EFFECTS_PENDING, RUNS_LIVE — one measurement per pass, when the app has a DiagnosticsStore (RigPlugin adds DiagnosticsPlugin and TimePlugin if absent).
tests/tool_batch.rs pins the batch: two calls are two children dispatched in call order and one utterance of results; ToolPolicy sets how many fly at once; a Judge system's replacement reaches history while the record keeps the answer; a Gate denial is a skipped result and no record; a despawned child fails the run Cancelled; a system's Resolution::{Repair, Retry} renames or retries an invalid call (Skip and Ignore likewise). The corpus's nesting cases run through a key the world serves (Handlers::register_open), the lookup tool answered by a system that spawns its child ChildOf the call.
A run leaves its graph — turns, utterances, adverts, attachments, the settled effects and a stream's fold — in the world until the host takes it out: RunCommands::despawn_run (on World, or queued on Commands) despawns an ended run whole and refuses one still running (tests/run_lifetime.rs). A host that runs for long despawns the runs it is done reading; the world keeps nothing of a run by itself. The bus takes at least one effect per tick whatever command_capacity says.
The first steering slot is any system before Assemble: it edits the graph. tests/run_graph.rs pins the wins: an utterance despawned leaves the next request; one document entity feeds two runs; a grant link advertises a tool and its removal un-advertises it; a model swapped on the run changes the next key; a Patch system's rewrite reaches the handler and the record; a system before Assemble rewrites an utterance. tests/run_scene.rs pins that the graph is the state: a run checkpointed mid-turn resumes in a fresh world to the same second request.
Steering with systems
Application systems change components at explicit schedule boundaries. See CONTRACT §9 for the detailed mapping to agent hook actions and the corresponding regression tests.
| Intent | Boundary and operation |
|---|---|
| Start or stop a run | Observe On<Add, Run>; insert Cancelled on an unfinished run to stop it. Issued effects may continue draining. |
| Select a model | Set the run's UsesModel after Advance, before Select. |
| Shape a request | Merge RequestPatch on the fresh turn before Assemble, or edit the folded effect in Patch. |
| Patch, deny or hold dispatch | Change pending work in BusSet::Gate; use distinct owner names with acquire_hold/release_hold. |
| Replace an answer | Change EffectOutcome in BusSet::Judge or the completed turn's Outputs in RigSet::Judge; the record retains the handler's answer. |
| Retry or resolve an invalid call | Write Retry or Resolution before Materialise, within the supported turn/stream boundaries. |
| Observe completion | Observe Settled/Failed; await memory acknowledgements separately when the application requires them. |
Order systems that compose patches explicitly and apply deferred writes between
them. A policy declaration names the application's composition; it does not
fingerprint system implementation. Memory and retrieval are graph relationships:
Remembers/Conversation select persistence, and Retrieves/Retrieval attach
retrieved documents or advertise retrieved tools.
Handler replies and host polling
Serve::serve returns Reply::Outcome(Result<Outcome, ErrorReport>) or an
owned Reply::Stream(StreamEvents). Dispatch carries the effect ID, requested
delivery mode and scopes. Adapters retain their domain traits. Reply::written
provides a writer whose future and bounded private receiver are polled together;
deferred() provides an external resolver and the future a handler awaits.
The initial task prepares the reply and folds unary requests on the executor.
For streaming requests, one owned worker polls the stream into a bounded private
queue. Native parsing, writer work and streamed verdicts run on pool threads;
browser !Send work uses Bevy’s web executor and cannot preempt synchronous work
on the browser thread. Keep ticking to collect ready delivery and settle effects.
Collect drains at most 64 items per effect per pass, sharing
STREAM_WORK_PER_TICK (4,096) queue checks across the pass. It rotates through
dispatch order when that allowance runs out, resuming after the last served
Seq next pass. These limits bound streaming delivery work, not CPU time,
payload bytes, or the cost of user systems. Every pass gets a fresh allowance.
stream_capacity supplies the queue's shared slots, clamped to at least one;
the single sender has one additional reserved slot. The worker awaits each send
before polling again. The writer's own bridge is separate. The task lives in
the non-send Executions table under the effect's entity, beside its Serving
marker or Streaming { events, .. } component. Leaving InFlight — removing
it, despawning, dropping the world — cancels the task, including a worker
parked on a full queue. An active native
poll can finish before cancellation drops its future; closed recordings reject
its late observations. Handler replacement leaves already-owned work intact.
Streaming serial slots last until the reply ends and through layer work
after it.
Recording keeps the original handler answer and events through layer verdicts.
A recorded answer can survive cancellation while a verdict waits; recording an
original item does not establish consumer delivery. Both task and stream results
still publish tool output before reaching EffectOutcome and shared settlement.
Vocabulary
| Concept | API |
|---|---|
| a dispatch | commands.spawn(PendingEffect { key, kind }); PendingEffect::{new, typed, custom} |
| dispatch order | Seq, stamped on add from SeqCounter (global, reserved) |
| the effect's id | Issued after Dispatch; Reserved before it, for a checkpoint's or a log's id |
| taken, in flight | InFlight { key } plus Serving (initial task) or Streaming { events, fold, delivered }, the task a row in the non-send Executions (Tasks is the SystemParam over it); ServedBy(Entity) → the handler entity (its Serves the inverse) |
| a handler that is a system was asked | Asked<E>; the system answers with Answer<E> — or, for a key bound open (Handlers::register_open, any family), the effect entity itself, answered by submitting WorldOutcome |
| the answer | EffectOutcome(Result<Outcome, ErrorReport>); a stream's per-tick fold in Streamed { events, errors, text, outcome }, with every error and its item position retained independently of recording |
| the record closed | Landed { entity, id }, an entity event settle triggers on the effect; it bubbles up ChildOf (effect → turn → run → agent), original_event_target() the effect |
| held by a decision | Held |
| a program's scope | Scope(String) on an ancestor; read into the record |
| a tool call's context (beside the effect) | ToolInputs(ToolContext) on the effect entity, attached to the handler's Dispatch context; what the tool published lands as ToolOutputs(ToolContext) when the outcome does (Publishing holds the slot in flight) |
| a handler | an entity with Bound { key, descriptor } (immutable: every change is an insert) and a Name; the erased handler in the NonSend HandlerTable, marked by Handler on the same entity; HandlerIndex (key → entity), kept exact by Bound's hooks |
| the registry | Handlers (a SystemParam): register, register_erased, register_typed, register_world, register_open, deregister, descriptor, keys, descriptors; Handlers::with(world, ..) outside a system |
| host model assembly | provider-specific constructors or optional rig-core registry configuration → Model → ModelAdapter → Handlers::register_erased; no provider recipe or credential resolver in ECS |
| restoration requirements | Checkpoint::requirements() exposes saved descriptors; Checkpoint::validate(&World) validates saved data before host construction |
| a typed view | Typed<F>(Key<F>), wherever a system wants it |
| the driver | dispatch in BusSet::Dispatch; collect_tasks, collect_streams, settle in BusSet::Collect |
| the record | Recording (any rig_core::serve::Recorder); rig_cassette::ecs::EffectLogResource (an EffectLogRecorder installed as both); for every task-served handler, Dispatch installs a recording observer (WorldObserver, its slots in Observed) so a layer's discard and patch reach the record and the record keeps the innermost handler's answer |
| a checkpoint | rig_ecs::checkpoint::{save_world, load_world} |
| replay | rig_cassette::ecs::Replay::{register, load}, by id; requires its ReplayPlugin |
| the policy | Policy(ServingPolicy): intake per tick and serial keys; stream_capacity bounds driver delivery queues |
The schedule
bus::BusPlugin adds RigSchedule with four ordered sets after Update
(MainScheduleOrder::insert_after), then the generic RigEnd schedule.
It sets woken_runner(idle) as the app's runner: the schedule runs
once per app.update(), and the runner updates when Wake is raised or
every idle at most. Tasks raise Wake as they finish or deliver; a host
system that needs another pass raises wake.signal().
Cassette's ReplayPlugin installs idle replay diagnosis in RigEnd, running
when the pass raised nothing (Wake::generation unchanged).
The base bus uses bevy_ecs, bevy_tasks and a private futures delivery queue.
BusPlugin::install(&mut World) installs the runtime in a bare world driven by
world.run_schedule(RigSchedule). User systems belong in RigSchedule,
ordered against its sets, never beside the runner.
| set | true before | written during |
|---|---|---|
Gate |
pending effects are as spawned | a user system patches a PendingEffect, denies one (EffectOutcome(Err(..))), or holds one (Held) |
Dispatch |
every un-held, un-answered PendingEffect is a candidate |
the plugin takes them in Seq order up to the pass's intake: Issued, InFlight, ServedBy, Serving/Streaming/Asked; a record opens |
Collect |
handlers may have finished or streamed | the plugin writes Streamed, EffectOutcome; the record closes (settle); InFlight goes |
Judge |
this pass's outcomes have landed and are recorded | a user system may rewrite an EffectOutcome before anything after Judge reads it |
Decisions are program, never record: the record is what the handler answered, taken in Collect, between the two slots. A Gate denial never had InFlight, so it is no record; a Judge rewrite is Changed, not Added, so it is not re-recorded. Despawning an effect cancels it (its task drops, the record says Cancelled) and Bevy despawns its ChildOf descendants with it.
Serial serving and re-entrancy
Under ServingPolicy::serial_per_handler, Dispatch takes a key only when nothing is InFlight on it — a query over the handler's Serves. An effect whose ancestor (up ChildOf) is in flight on its own key could only wait for itself: it is refused before any dispatch with a Request report and no record.
Handlers that are systems
Handlers::register_world::<E>(key) binds a WorldEffect (a CustomEffect whose payload and answer are Send + Sync). A dispatch to the key lands as Asked<E> on the effect entity; a user system with any World access inserts Answer<E>; the plugin queues it for publication as EffectOutcome in Collect. Handlers::register_open(key, family) binds a key of any family to the world itself: the dispatch is taken and left on its entity, InFlight, for a system to answer by submitting WorldOutcome::new(outcome) — a tool a system serves, nesting what it needs as effects ChildOf the call (bus_world::a_system_serves_an_open_tool_key_and_nests_a_completion_under_it). The collector publishes answers in submission order; submissions after Collect become visible in the next pass. No task is required. Unary only: a system answers once. A handler that must reach the world is one of these; a handler served as a task cannot.
Regression tests and supported boundaries
The tests under tests exercise dispatch, cancellation, bounded intake,
streaming, registry replacement, world-served handlers, checkpoints, replay and WASM.
bus_scale covers large pending sets; bus_world covers Gate/Judge and nesting;
bus_scene covers save/load; run_identity covers scoped replay checks.
The producer-owned corpus in rig-cassette separately exercises the interpreters.
Passing those tests establishes their specific assertions, not arbitrary
application scheduling or whole-program equivalence.
World-served handlers answer unary effects; custom world streaming is unsupported. Checkpoints hold every registered reflected component; hosts save resources and other state explicitly. Nondeterministic operations belong in host handlers.
The witness: decisions beside the record
The effect log is the replay oracle: what a handler served and answered.
A Witnessing resource (rig_ecs::bus::Witnessing::install(world, sink),
any rig_core::observe::Witness; rig_core::observe::ObservationLog is the
bounded in-memory one) records what happened around those exchanges, as
typed Observations with a Subject (scope, dispatch order, effect id,
parent, key), a Stage (Gate, Dispatch, Handler, Collect, Judge,
Runtime, Host), an Emitter and an Action:
| site | fact |
|---|---|
a Gate system writes Held / removes it |
Held / Released (emitter unknown unless the policy emits) |
a Gate system answers an intent before dispatch |
Denied { reason } |
Dispatch |
Issued; Refused { handler_unavailable | reentrant | ids_exhausted } |
a layer (Intercept) denies |
Denied { layer_discarded } at Handler, the emitter named after the layer |
Collect |
StreamTruncated { delivered, tail, errors }, Landed { outcome }, Replaced when a layer's verdict differed from the record (emitter named after the layer that said it replaced; unknown for a difference no layer claimed), Cancelled for a despawn in flight |
a Judge system overwrites a settled outcome (by insert; an in-place Mut rewrite must emit for itself) |
Replaced { recorded, consumed }, whenever the whole value changed |
| the agent runtime | Ended { settled | max_turns | provider | cancelled | … } |
| Gemini unary and SSE drivers | Adapter send/status, usage, provider verdict/error envelope, EOF and closure facts at Handler, with execution-local operation and send ordinal; explicit invocation context takes precedence over the bus context |
| a host system | Witnessing::emit(subject, Stage::Host, Emitter::versioned(..), HostAction::action(..)) |
Attach bus::AdapterOperation to a pending completion before dispatch when
the host owns retry correlation. The bus binds its current effect, scope and
parent while retaining the supplied operation's witness and send counter.
The component's nonzero host attempt ordinal stays separate from the HTTP
send ordinal. Without it, the bus creates an operation for the individual
effect. The component is runtime-only and is not scene/effect-log state.
The observation API does not promise semantic execution comparison. Tests use
rig_core::test_utils::observations for deterministic fixture comparisons;
applications own their comparison and aggregation rules. Optional host-clock
stamps are diagnostic data. No run, handler or provider interval is calculated.
crates/rig-ecs/tests/bus_witness.rs and run_witness.rs cover the emitted facts.
Checkpoints preserve named hold owners and the batch scheduling marker together.
A restored batch releases only its own hold; independent policy holds remain in
force. Bare Held barriers remain unknown holds and require host reevaluation.
Observation enablement does not affect hold ownership or checkpoint correctness.
Replay delivery and streaming
Records remain in dispatch order. When EffectLogResource::install installs
an ECS recorder, the log also records header.deliveries: an ordered trace
of outcome insertions and stream item counts grouped by schedule pass.
Handler completion and collector delivery are separate boundaries. Replay
buffers ready handler data and exposes each recorded batch together, allowing
policy systems to run between batches. Concurrent live serving stays
concurrent. Coincident agent turns materialise and land tool batches in
RunSeq order, independent of irrelevant entity archetypes.
Use Replay::policy_visible() for policies that choose the first visible
answer or inspect partial Streamed state. Install an
EffectLogRecorder::keeping_stream_events() when recording streams for this
mode. It refuses missing delivery metadata or omitted stream bytes and
reports an inconsistent trace as ReplayFailure. The same policy must
reproduce recorded cancellations; cancelled losers are not given invented
outcome insertions. A replay that does not reproduce a required cancellation
fails explicitly.
Supported policy observation points are On<Add, EffectOutcome> and systems
ordered after the entire BusSet::Collect, with the same relevant ordering
live and on replay. Systems interleaved between individual collectors or
reading handler readiness/inboxes are outside this guarantee. Submit world
answers with WorldOutcome or typed Answer<E>; direct insertion of an
in-flight EffectOutcome records a header.delivery_limitations diagnostic
and causes policy replay to refuse the log. Gate denials and Judge replacements
retain their existing roles. A world answer submitted after Collect is
published in the following pass. WorldOutcome is transient like a ready
task; collect it before saving a checkpoint to retain the answer.
Replay::default() supports exchange consumers: it honors available delivery
batches, but a folded stream supplies only its final answer and recorded
cancellations are returned as errors. Logs without delivery metadata
replay exchanges without a policy-order guarantee. Keeping event bytes
preserves the event sequence; keeping delivery batches additionally preserves
which events partial-state policy sees together. header.stream_errors
retains error items at their original positions; a folded outcome cannot
recover those positions. One event per pass is not
an exact replacement for a live multi-event batch. Kept events and the trace
increase log size in proportion to recorded events and delivery batches.
These guarantees require the same declared program and relevant schedule
ordering. They do not reproduce wall-clock gaps, arbitrary resources,
system-local state, ambient inputs or external writes. Saved IDs permit
subset replay; a program that creates new effects must still reproduce its
causal dispatches. Generic custom and world-served streaming remain
unsupported: StreamWriter does not change which effect families stream.
Host assembly and restoration
The host owns model selection, credentials, endpoint/transport policy, SDK preparation and runtime lifetime. ECS executes the already-built handler. Store launch settings separately from execution checkpoints; a descriptor is not proof of equal credentials, endpoint or transport policy.
checkpoint::load_world(checkpoint, world, mode, handlers) takes an explicit
RestoreMode and already-built (HandlerKey, ErasedHandler) pairs. Strict
checks the original saved descriptors before aliasing; Replace explicitly
accepts descriptor changes within the same effect family. Supplied handlers
are installed even when their descriptors match (credential rotation is not
a no-op). Omitted keys must already be served in the destination. The entire
saved binding set and graph are validated before state or handlers are installed.
Use Strict and [] to retain matching preinstalled handlers, including replayers.
Every unfinished effect must have an original saved handler contract; a destination
binding does not invent one. Saving captures data for inspection, not proof of
resumability. Inspect requirements() and call validate(world) before assembly;
actual implementation compatibility is checked at load_world. Refusals are
CheckpointError variants a host can act on: MissingHandler { key } asks for
an implementation, DescriptorChanged { key } is accepted under Replace, and
FamilyChanged, UnresumableStream or UnsupportedFormat mean the checkpoint
cannot resume here.
host_resume demonstrates host construction, strict
reconstruction and independent effect replay with an explicitly offline transport.
Select replay before credential lookup, diagnostic-secret collection or SDK initialization. Old checkpoints containing removed provider-binding component paths are refused; move their launch settings into the host explicitly rather than silently dropping them. See the contract.
Stream and custom-answer snapshots
A completed stream's Streamed (events, errors, text, terminal fold) is
checkpoint data like any other component: it is restored before its answer is
inserted and its handler is never executed again. load_world returns a
result: it validates the whole checkpoint in a scratch world first and rejects
unfinished streams with already-delivered progress, leaving the destination
untouched. There is no generic provider cursor: save before progress or after
completion. Safe unanswered intents restart under their saved IDs (a taken but
unanswered effect loads as Reserved(id)).
Custom outcomes store the user's JSON value in Outcome::Custom { payload }.
Typed strings, scalars, arrays and objects round-trip without changing the
answer type, including objects with an outcome field of their own.
Memory finalization across snapshots
Settled means the model run produced its answer, not that external memory
has committed it. MemoryAppendScheduled persists the fact that finalization
created an append; its child effect carries the request, dispatch id and
outcome. Loading a snapshot before finalization schedules that append;
loading a queued, in-flight or completed append does not create another
operation.
An unanswered effect is retried on load under its saved id. The external
write may already have happened: a process can stop between the write and
collecting its answer. This is not exactly-once execution. A host needing
deduplication must durably associate an external idempotency key with the
operation (including a session/log namespace, since EffectId alone is not
globally unique), use an idempotent handler, or reconcile ambiguous writes
before resuming. Despawning or abandoning a world does not roll back writes.
Save only at a schedule boundary after deferred commands have been applied, and persist the checkpoint together with its matching log. Inserting components while loading invokes Bevy insertion observers and change detection; application observers must not interpret rehydration as a new business event. Install such observers after loading or explicitly guard them during restoration.
The prelude and the features
rig_ecs::prelude names what a user's systems need and nothing else: the sets (RigSet, BusSet), the components a user writes (Cancelled, RequestPatch, Retry, Resolution, Held, UsesModel, Grant, Context, Remembers, Retrieves) and the components a user reads (Streamed, Outputs, EffectOutcome, RunResult, Settled, Failed, Usage).
Every component of the bus and the graph derives Reflect; the rig-core values they hold reflect through opaque remote wrappers (bus::reflect, agent::reflect — serialized as their wire form, so an inspector shows an effect entity's payload as the log would); checkpoint::register_types (reflect::install_reflect) registers them all, and RigPlugin calls it. The checkpoint is canonical (entities in Children order, an Entity in a component as its index in the checkpoint), so a world and the world its checkpoint loads into export the same JSON (tests/reflect_scene.rs); every component round-trips through ReflectSerializer / ReflectDeserializer / FromReflect by value (tests/reflect_roundtrip.rs). The runtime-only components (Serving, Streaming, Handler, Publishing, Observed, Asked, Answer, Typed, and the asset handles) reflect nothing. rig-core takes no Bevy dependency.
assets (off by default): assets::PromptAsset (a .md / .txt file) and assets::ToolDefinitions (a .json array of { name, description, parameters }) are bevy_asset assets with loaders; PromptHandle / ToolsHandle on an agent become its Preamble and its Grants — one per definition, in file order, to the bound handler whose descriptor is the tool of that name; a definition nothing serves is not granted — the tick the asset loads, once (Applied<A>). assets::AssetsPlugin after bevy_asset::AssetPlugin; its systems run in Update in assets::AssetsSet, before RigSchedule. tests/assets_prompt.rs, examples/prompt_from_assets.rs (an in-memory source; a directory with the default one).
The examples, side by side
Runnable programs over scripted mocks in examples/support: agent_with_tools (Grant links and a run entity for dynamic_tools and prompt), human_in_the_loop (a system in BusSet::Gate reading stdin for AgentHook::on_dispatch: approve, deny with an EffectOutcome, abort with Cancelled), best_of_n (agent::fork n − 1 times, a judging system over the settled runs, for a parallel fan-out), streaming_ui (a streamed run and a system after RigSet::Fold on Changed<Streamed> for a polled stream), prompt_from_assets (the assets feature). cargo run -p rig-ecs --example <name> — none needs a key.
On wasm
Everything a system holds is Send + Sync on every target. The erased handler and the task are !Send on wasm, so on every target they live in the NonSend HandlerTable and Executions behind the Handler, Serving and Streaming component names — one storage, one code path — and a system that registers or dispatches runs on the main thread. tests/bus_wasm.rs drives the schedule by hand: bevy_app's runner on the web is frame-scheduled by the browser.
Cancellation after an original handler answer has been observed records a
DeliveryKind::Cancelled boundary instead of an outcome delivery. This preserves
that original answer (including one awaiting a layer verdict) without claiming
that the consumer received it. Policy replay must reproduce the cancellation;
exchange replay returns cancellation and leaves undelivered terminal items hidden.
Ordinary cancellation without an observed answer retains its existing encoding.