rig-ecs 0.44.0

rig inside a Bevy World: effects as entities, handlers as entities, the driver as a system.
Documentation

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.