Skip to content

Effect bus: the crate split (rig-bus, rig-effect-log, registry to rig-agent) - #2445

Merged
gold-silver-copper merged 4 commits into
feat/effect-busfrom
feat/effect-bus-split
Sep 3, 2026
Merged

Effect bus: the crate split (rig-bus, rig-effect-log, registry to rig-agent)#2445
gold-silver-copper merged 4 commits into
feat/effect-busfrom
feat/effect-bus-split

Conversation

@gold-silver-copper

@gold-silver-copper gold-silver-copper commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Effect bus: the crate split (PR #2445)

Stacked on #2443 (feat/effect-bus); base branch feat/effect-bus, lands by fast-forward push (the repository is squash-only), retarget to main when #2443 merges. Executing prompt: effect-bus/effect-bus-crate-split-pr.md (audited 2026-09-03 against 5a8958620). Toolchain 1.94.0. Four code commits and this description.

What the PR does

Draws the crate line where the dependency edges already were. rig-core keeps the vocabulary and what a handler author implements; a new rig-bus is the runtime; a new rig-effect-log is record and replay; the tool registry moves to rig-agent, the only crate that drives it. Nothing in rig-core names a type from the two new crates, and a guard says so. A Bevy runtime (rig-bevy, next) imports rig-core and rig-bus and nothing agent-shaped.

Crate layout as built

rig-core ──► rig-bus ──► rig-effect-log ──► rig-agent ──► rig (facade)
   │            │                              ▲
   │            └──────────────────────────────┘
   └───────────────────────────────────────────┘
crate holds depends on
rig-core effect (the vocabulary: EffectKind, Outcome, FamilyDescriptor, Family, Key<F>, EffectRecord, model_key/tool_key) and serve (Serve, OutcomeSink, the one erasure ErasedHandler, serve_inline, StreamWriter, the adapters, the driver seam) providers only
rig-bus Bus, Dispatcher, Registrar, BusDriver, BusConfig, Recorder, Pending, EffectStream, Handle and the typed views, Typed, wrap_stream; the sync shim and the loom models rig-core, futures, tracing, serde
rig-effect-log EffectLog, LogHeader, EFFECT_LOG_FORMAT, stable_hash, EffectLogRecorder, EffectLogReplayer rig-core, rig-bus, serde
rig-agent the engine, and now the tool registry: RegisteredTool, ToolSet, ToolDispatch, dispatch_tool, ToolCatalog, ToolLease beside ToolServer all three

Why the line is here. The vocabulary must sit with the domain types (orphan rule on Family impls and the From<XError> for ErrorReport conversions). The adapters implement Serve over rig-core's six traits, so they stay with Serve. The runtime knows only the vocabulary and ErrorReport. The registry was the one type that straddled the line: it holds an ErasedHandler and mints a Key, and its only driver is ToolServer.

Two seams made explicit.

  • The driver seam (rig_core::serve): OutcomeSink::unary, OutcomeSink::stream, OutcomeSink::with_tap, OnOutcome/OnEvent, ErasedHandler::handle, HandlerFuture, StreamTap, finish_unary, stream_truncated. What a bus driver builds to hand a handler its sink and observe what it answers. rig-bus is one such driver; an ECS schedule is another. Public, documented, not hidden.
  • The recorder seam (rig_bus::Recorder): handlers, begin, keep_events, event, resolve. BusDriver::record_to takes any Recorder + Clone + Send + Sync and holds it as closures, the way the sink holds its taps, so the driver names no recorder type. EffectLogRecorder implements it.

Key<F> is vocabulary. A typed key is a HandlerKey plus a type-level proof of the family; it holds nothing of the runtime, so it lives in rig_core::effect. This is what lets the registry compile without the runtime and is why the split has no cycle.

Guards, as they follow the code

guard now
one_erasure scans rig-core, rig-bus, rig-effect-log, rig-agent; the erasure file is rig-core/src/serve/handler.rs; the typed-view impl scan reads rig-bus's handle.rs; the no-unsafe scan covers all of rig-bus and rig-core's serve/
stream_ids allows serve/handler.rs and serve/writer.rs
bevy_bus_host the fixture's rig side must be exactly ["rig-bus", "rig-core"]
dependency_graph rig-bus and rig-effect-log carry neither tokio nor reqwest; rig-core depends on neither; rig-bus does not depend on rig-effect-log
provider_layout Exempt rows for both new packages
CI the loom job runs -p rig-bus -p rig-agent; the wasm matrix checks rig-bus and rig-effect-log

Migration (public breaks)

was is
rig_core::bus::{Key, model_key, tool_key} rig_core::effect::…
rig_core::tool::{RegisteredTool, ToolSet, ToolDispatch, dispatch_tool, ToolCatalog, ToolLease} rig_agent::tool::… (the ToolSet/dispatch_tool paths already resolved there)
rig_core::bus::{Bus, BusConfig, BusDriver, Dispatcher, Registrar, Pending, EffectStream, Handle, ModelHandle, ToolHandle, MemoryHandle, IndexHandle, EmbedHandle, RerankHandle, Typed, Completion, ToolCall, Retrieval, wrap_stream} rig_bus::…
rig_core::bus::{Serve, OutcomeSink, SinkClosed, ErasedHandler, serve_inline, events_from_response, StreamWriter} and rig_core::bus::adapters rig_core::serve::…
rig_core::effect::{EffectLog, LogHeader, EFFECT_LOG_FORMAT, stable_hash} rig_effect_log::…
rig_core::bus::{EffectLogRecorder, EffectLogReplayer} rig_effect_log::…
BusDriver::record_to(EffectLogRecorder) record_to(impl Recorder + Clone + Send + Sync)
facade rig::bus, rig::effect_log (named modules like rig::run); rig::tool::{RegisteredTool, …} under the agent feature

Additive: rig_core::tool::LivenessFn, DynamicTool::into_parts, PortableDynamicTool::into_parts (the seam a registry needs); rig_core::serve::StreamTap and the sink constructors above.

Bench

Unary dispatch, release: median 625 ns, p90 708 ns (the 50 µs threshold test moved with the runtime; a crate boundary is not a call boundary).

Risks, as they stand

  • The driver seam is three sink constructors plus the tap types. A second driver may want one OutcomeSink::for_driver(reply, tap); recorded as a follow-up, not done.
  • cargo publish --dry-run of rig-bus and rig-effect-log fails against the registry's rig-core (no serve, no effect published yet), as rig-agent's does on rig-reqwest today. Publishing order is rig-core, rig-bus, rig-effect-log, rig-agent.
  • The record/replay tests over a live bus now live in rig-effect-log with copies of rig-bus's test helpers (a dev-dependency back onto rig-bus builds the crate twice and the trait impls stop lining up). Two copies of ~150 lines of helpers.

Audit trail

Commit map

commit subject finding
e29c116c8 S1 refactor(core): a typed key is vocabulary CI's doc job caught two [HandlerKey] links in bus/mod.rs that the removed import had been resolving; fixed in S2 (the local gate had run per-crate docs; the fix-forward rule applied)
fdd43d264 S2 feat(agent)!: the tool registry is the agent's RegisteredTool::from_dynamic reached into DynamicTool's private fields — into_parts is the seam; rig-agent already re-exported ToolSet/dispatch_tool, so the break is smaller than drafted; the facade gates the registry under agent
6ef12d19e S3 feat!: rig-bus, the runtime as its own crate OutcomeSink::writer is an inherent method, so StreamWriter stays in rig-core (the prompt had it moving); the sink's tap held the driver's StreamTap — moved into the handler module (audit ruling 1a); Recorder held as closures; the trybuild diagnostic now spells rig_core::serve::handler::Serve
742f12c2e S4 feat!: rig-effect-log, record and replay as their own crate a dev-dependency cycle rig-bus → rig-effect-log → rig-bus builds rig-bus twice under test and EffectLogRecorder: Recorder no longer unifies — the seven record/replay tests moved to the log crate with copied helpers

What the split did not find

No hidden coupling beyond the two the audit named (the sink's tap, the writer's inherent method). Every test count held: rig-core + rig-bus + rig-effect-log + rig-agent + rig-verify + rig-derive at 2461, the root guards at 207 (one new dependency-graph test), all six loom models green.

Gate

Fast local gate per commit: fmt, clippy on the workspace, rustdoc -D warnings on the workspace (the CI command, after S1's miss), nextest on the six crates, the root tests/core guards on every commit, the wasm check on rig-core/rig-bus/rig-effect-log/rig-agent, the four fixtures under -D warnings with regenerated lockfiles, loom and the bench on S3 and S4, the publish dry-runs recorded. CI is the gate of record.

`Key<F>` is a `HandlerKey` plus a type-level proof of the family under
it; it holds nothing of the runtime. It moves from `bus` to `effect`
beside `HandlerKey`, with `model_key`/`tool_key`. The two doc sentences
that linked `Dispatcher::bind`/`handle` are prose now — the vocabulary
cannot link downstream once the runtime is its own crate, which is what
this move prepares (S1 of effect-bus-crate-split-pr.md).

No behaviour change; paths only. Public break: `rig_core::bus::{Key,
model_key, tool_key}` are `rig_core::effect::{Key, model_key, tool_key}`.
`RegisteredTool`, `ToolSet`, `ToolDispatch`, `dispatch_tool`,
`ToolCatalog` and `ToolLease` move from `rig_core::tool` to
`rig_agent::tool` (`registry`, `catalog`), next to `ToolServer`, their
only driver. rig-core's tool module keeps tool authoring: `Tool`,
`ToolEmbedding`, `ErasedTool`, `DynamicTool`, the portable module,
`ToolContext`, the output and result types. The seam a registry needs
goes public: `LivenessFn`, `DynamicTool::into_parts`,
`PortableDynamicTool::into_parts`. The registry tests move with the
registry, paths only. The facade exposes the registry under `agent`.

Migration: `rig_core::tool::{RegisteredTool, ToolSet, ToolDispatch,
ToolCatalog, ToolLease, dispatch_tool}` → `rig_agent::tool::…` (the
`ToolSet`/`dispatch_tool` paths already resolved there).

Also fixes the two `bus/mod.rs` intra-doc links to `HandlerKey` that
S1 left unqualified (CI's doc job caught them).
The effect bus runtime — Bus, Dispatcher, Registrar, BusDriver, BusConfig,
Pending, EffectStream, Handle and the typed views, Typed, wrap_stream, the
sync shim, the loom models — moves from rig_core::bus to a new crate,
rig-bus, depending on rig-core and nothing else rig-shaped (futures,
tracing, serde). rig-core keeps the vocabulary and, under a new `serve`
module, what a handler author implements: Serve, OutcomeSink, the one
erasure ErasedHandler, serve_inline, the adapters, and StreamWriter
(an inherent method on OutcomeSink cannot live downstream, so the writer
stays with the sink; the prompt had it moving).

Two seams are now explicit. The sink's driver-facing constructors
(OutcomeSink::unary/stream/with_tap, ErasedHandler::handle, StreamTap,
finish_unary, stream_truncated) are public in rig_core::serve: what a bus
driver builds to hand a handler its sink — rig-bus is one such driver, an
ECS schedule is another. The driver records through a `Recorder` trait
(handlers, begin, keep_events, event, resolve), held as closures like the
sink's taps, so the driver names no recorder type; EffectLogRecorder and
EffectLogReplayer ride in rig-bus for this commit (the replayer needs
BusDriver) and move to rig-effect-log next.

Every guard follows the code: one-erasure scans rig-bus, its erasure file
is serve/handler.rs, the no-unsafe scan covers rig-bus and rig-core's
serve module; stream_ids allows serve/{handler,writer}.rs; the Bevy
fixture's rig side is rig-core and rig-bus; the dependency-graph guard
asserts rig-bus is runtime-free and rig-core does not depend on it;
provider_layout exempts the new package. CI: the loom job runs rig-bus
and rig-agent; the wasm matrix checks rig-bus. The facade exposes
rig::bus as a named module like rig::run.

Bench, release: unary dispatch median 625ns (p90 708ns). Publish
dry-run of rig-bus fails on the registry's rig-core (no `serve`, no
`effect` published yet), as rig-agent's does on rig-reqwest.

Migration: rig_core::bus::{Bus, Dispatcher, Registrar, BusDriver,
BusConfig, Pending, EffectStream, Handle, *Handle, Typed, wrap_stream,
EffectLogRecorder, EffectLogReplayer} → rig_bus::…;
rig_core::bus::{Serve, OutcomeSink, SinkClosed, ErasedHandler,
serve_inline, events_from_response, StreamWriter, adapters} →
rig_core::serve::…
EffectLog, LogHeader, EFFECT_LOG_FORMAT and stable_hash leave
rig_core::effect; EffectLogRecorder (a rig_bus::Recorder) and
EffectLogReplayer leave rig-bus. The new crate depends on rig-core and
rig-bus and is the persistence story over both: what a host that saves
and restores in-flight effects depends on. EffectRecord stays vocabulary
in rig-core. rig-agent's record_effects/take_effect_log/run_spec_hash/
check_replayable keep their signatures over the new paths.

The record/replay tests over a live bus move from rig-bus's tests to the
log crate (a dev-dependency back onto rig-bus builds the crate twice and
the trait impls no longer line up, so the tests live where the types
do); the helpers they use are copies. The log round-trip test moves with
EffectLog. The guards scan four crates; the dependency-graph guard
asserts rig-effect-log is runtime-free and sits above rig-bus, and
neither rig-core nor rig-bus depends on it; provider_layout exempts the
package; the wasm matrix checks it; the facade exposes rig::effect_log.
The trybuild diagnostic re-pinned (the impl list no longer names the
replayer). Publish dry-run fails on the registry's missing rig-bus, as
expected until rig-bus publishes.

Migration: rig_core::effect::{EffectLog, LogHeader, EFFECT_LOG_FORMAT,
stable_hash} and rig_bus::{EffectLogRecorder, EffectLogReplayer} →
rig_effect_log::…
@gold-silver-copper gold-silver-copper changed the title Effect bus: the crate split (rig-bus, rig-effect-log) Effect bus: the crate split (rig-bus, rig-effect-log, registry to rig-agent) Sep 3, 2026
@gold-silver-copper
gold-silver-copper marked this pull request as ready for review September 3, 2026 08:36
@gold-silver-copper
gold-silver-copper merged commit 0c0e84b into feat/effect-bus Sep 3, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant