Runtime Bridge
induscode::runtime_bridgeis the per-turn routing seam that decides where an assistant turn is actually produced: by theindusagiframework gateway, or by an external child coding-agent (a spawnedclaude/codexCLI, or a peerindusagiagent over JSON-RPC). The load-bearing fact of the Rust port: there is noAssistantMessageEventStreamand nostreamSimple— the framework's streaming currency is the gateway'sChannelofEmission, and the runtime reaches a model through exactly one seam, theindusagi::runtime::ModelInvokertrait. So the TS broker that decoratedstreamSimplebecomes the Rust [RuntimeBroker] that is aModelInvoker.
The broker wraps the gateway-backed invoker and, for runtime-annotated models, produces a bridge-backed Channel<Emission> instead. Both arms hand back the same Channel, so the conductor's turn driver cannot tell which path produced the turn — the exact Rust analogue of the TS "indistinguishable to the caller" guarantee. Every external wire dialect is collapsed into one provider-neutral NormalizedEvent union, which a single EmissionSink translates into framework Emissions, so the per-bridge push idiom lives in exactly one place.
Table of Contents
- Where It Sits
- The Routing Decision
- The Synthetic Endpoint and the Spec Side-Table
- Normalized Events and the Sink
- Bridges and the Shared Driver
- The Three Shipped Dialects
- The Injectable Child Transport
- Auth as Data
- Resume Links
- Cancellation Is Law
- Module Map
- Public Surface
- Examples
- Parity Notes
- On the Framework
Where It Sits
The runtime bridge is the Rust port of indus-code-rebuild/src/runtime-bridge (~2.3k LOC; PLAN/55). It builds directly on the indusagi framework and consumes its vocabulary verbatim from indusagi::llmgateway::contract — Channel, Emission, ModelCard, Conversation, StreamOptions, StopReason, plus GatewayError / GatewayErrorKind / gateway_error — and never re-derives event shapes. It writes through indusagi::llmgateway::streaming::channel_of, reuses indusagi::llmgateway::conversion::fold_reply to reconstruct terminal replies, and honours the framework indusagi::core::CancellationToken threaded through StreamOptions.cancel.
The single most important difference from the TS edition (mod.rs §2): Rust has no streamSimple to decorate. The runtime reaches a model only through indusagi::runtime::ModelInvoker (the AgentDeps.invoke_model injection point; the conductor re-exports it as conductor::contract::ModelInvoker). So the broker is a ModelInvoker: it wraps a gateway-backed Arc<dyn ModelInvoker> and overrides it for runtime-annotated models.
Within induscode it has one cross-subsystem touch point: the broker's spec resolution consumes a SpecTable (a BTreeMap<String, ExternalRuntimeSpec> keyed by canonical model id) built by the launch catalog/model-pick layer, and its resume persistence is bound by the conductor's transcript store at assembly time. The crate is induscode; the bins are indusr and indusagir.
The Routing Decision
The product never calls a bridge or the gateway directly. It hands the broker to the agent as AgentDeps.invoke_model and the framework calls ModelInvoker::invoke(conversation, options) once per turn; the broker picks the path. Routing is data, not control flow.
A [RuntimeBroker] is bound to one [ModelCard] at agent-construction time (broker.rs §; PLAN/55 §4.2 OQ2: ModelInvoker::invoke has no model argument — one agent = one model), and routes that single card on each invoke. The pure-decision helper is route():
pub fn route(&self) -> RuntimeRoute<'_> {
// No runtime annotation in the side-table ⇒ a plain HTTP-provider model.
let spec = match crate::runtime_bridge::bridges::spec_from_card(&self.card, &self.spec_table) {
Some(spec) => spec,
None => return RuntimeRoute::Framework,
};
// Annotated, but no bridge registered for the adapter ⇒ still framework.
match self.bridges.get(&spec.adapter) {
Some(bridge) => RuntimeRoute::External { bridge: bridge.as_ref(), spec },
None => RuntimeRoute::Framework,
}
}
The two outcomes of RuntimeRoute<'a>:
| Route | Carries | Behaviour |
|---|---|---|
RuntimeRoute::External |
bridge: &'a dyn RuntimeBridge + spec: ExternalRuntimeSpec |
The broker builds a ChildTransport, attaches the resume tap, and drives bridge.run_exchange(...). |
RuntimeRoute::Framework |
— | The turn falls through to the wrapped gateway-backed ModelInvoker unchanged. |
ModelInvoker::invoke re-resolves the same decision by hand (rather than holding a RuntimeRoute borrow), then dispatches. There are three distinct fall-throughs to the framework arm, every one mirroring TS broker parity:
- No spec in the side-table → plain HTTP-provider model →
self.framework.invoke(...). - Annotated but no bridge registered for the adapter →
self.framework.invoke(...). - External route decided, but no
transport_factorywired →self.framework.invoke(...)(parity withbroker.ts:280— the no-transportFactoryfallthrough).
Only past all three does the broker resolve a persisted resume token, build the transport via the factory, attach the best-effort resume tap, and call bridge.run_exchange(self.card.clone(), conversation, exchange_opts, transport). Every branch returns the same Channel.
The Synthetic Endpoint and the Spec Side-Table
A runtime-backed model is annotated, not re-catalogued. The catalog/matcher own the model list; this layer attaches an optional [ExternalRuntimeSpec] alongside the [ModelCard]. Unlike the TS layer (which rewrote Model.baseUrl and decoded it back), the Rust broker resolves the spec from a side-table keyed by canonical id (PLAN/55 §4.2 OQ1, which the contract explicitly sanctions), because Rust cannot abuse a base_url field the same way:
pub type SpecTable = BTreeMap<String, ExternalRuntimeSpec>;
pub fn spec_from_card(
card: &ModelCard,
table: &BTreeMap<String, ExternalRuntimeSpec>,
) -> Option<ExternalRuntimeSpec> {
table.get(&card.id).cloned()
}
The bridge:<adapter> scheme survives only as a stable, non-HTTP display/identity address:
| Symbol | Value / Shape | Role |
|---|---|---|
RUNTIME_ENDPOINT_SCHEME |
pub const … = "bridge:" |
The synthetic non-HTTP scheme stamped onto a runtime model's display address. |
runtime_endpoint(adapter) |
format!("bridge:{}", adapter.as_str()) |
The single sanctioned way to mint the convention. |
annotate_card(card, spec) |
fresh ModelCard |
Rewrites card.base_url = Some(runtime_endpoint(&spec.adapter)); the input is never mutated (a fresh value is returned). |
spec_from_card(card, table) |
Option<ExternalRuntimeSpec> |
The side-table lookup the broker recognizes a runtime model with. |
The [ExternalRuntimeSpec] is the frozen annotation. Only adapter is required (it selects which bridge owns the exchange); every transport field is optional:
pub struct ExternalRuntimeSpec {
pub adapter: RuntimeAdapterId, // which bridge owns the exchange
pub auth_mode: RuntimeAuthMode, // serde rename = "authMode"
pub binary_path: Option<String>, // serde rename = "binaryPath"
pub args: Vec<String>, // prepended to the bridge's protocol flags
pub env: BTreeMap<String, String>, // deterministic ordering
pub delegate: Option<String>, // for a peer bridge fronting a downstream source
}
RuntimeAdapterId mirrors the TS (string & {}) open union: the three shipped ids (ClaudeCli, CodexCli, IndusagiCli) keep exhaustive matching ergonomic, while an untagged Custom(String) arm lets an addon register a further adapter without editing the enum. The kebab-case wire tags ("claude-cli", "codex-cli", "indusagi-cli") preserve the TS string-literal values byte-for-byte; as_str() returns the stable slug.
Normalized Events and the Sink
Every external runtime speaks a different wire dialect, but the broker should not care. Each bridge is reduced to a parser that maps its vocabulary onto a small, closed, provider-neutral union, and one [EmissionSink] translates those events into framework Emissions. This is the seam that removes per-bridge push* duplication.
pub enum NormalizedEvent {
Text { delta: String },
Thinking { delta: String },
ToolCall { id: String, name: String, arguments: serde_json::Value },
Resume { resume_token: String },
Finish { reason: FinishReason },
Failed { error: BridgeFailure },
}
The terminal success reasons are a subset of the framework StopReason, projected by FinishReason::to_stop_reason:
FinishReason |
→ StopReason |
TS literal |
|---|---|---|
Stop |
StopReason::Complete |
"stop" |
Length |
StopReason::MaxOutput |
"length" |
ToolUse |
StopReason::ToolCalls |
"toolUse" |
The Rust EmissionSink is far thinner than the TS sink.ts (313 lines → a fraction): because fold_reply (conversion::reduce) already reconstructs the terminal Reply from the emission stream with the canonical block order (thinking → text → tool calls), there is no accumulating AssistantMessage, no content-block index bookkeeping, and no lazy *_start/*_end lifecycle. The sink just maps each event onto one-or-two Emissions pushed onto a tokio::sync::mpsc::UnboundedSender<Emission> that backs a channel_of factory:
| Method | Effect |
|---|---|
text(delta) |
Push Emission::Text { delta } (buffered for the fold). |
thinking(delta) |
Push Emission::Thinking { delta } (buffered). |
tool_call(id, name, arguments) |
Push Emission::ToolCallStart { id, name } + Emission::ToolCallDelta { id, args_delta } (args serialized as a JSON string); fold_reply reassembles the single Block::ToolCall. |
emit(event) |
Dispatch one raw NormalizedEvent onto the matching method; a Resume is a no-op. |
finish_success(reason) |
Push Emission::Stop { stop } then a terminal Emission::Done { reply } built by fold_reply(&model_id, buffer.iter()). Terminal, idempotent. |
finish_error(error) |
Push a terminal Emission::Error { error }; BridgeFailure::aborted selects GatewayErrorKind::Aborted vs GatewayErrorKind::Transport. Terminal, idempotent. |
A settled flag guards against emissions after a terminal Done/Error (the TS settled flag); post-settle pushes are silently dropped. Terminals are not buffered (they are envelopes, not deltas the fold consumes). A tx.send failure (the consumer hung up) is swallowed — it must never mask the streamed outcome. The bound model_id is threaded so the folded Done.reply carries the right model field. A Resume event emits no Emission (a stream no-op; the token is persisted out of band by the broker's tap — see the resume_is_a_channel_no_op test).
Bridges and the Shared Driver
A [RuntimeBridge] is one external-runtime adapter — the strategy that produces a turn for a model whose spec names it. The trait is dialect-specialist; the lifecycle wiring common to all of them is factored into drive_exchange (_drive.ts):
pub trait RuntimeBridge: Send + Sync {
fn adapter(&self) -> &RuntimeAdapterId;
fn run_exchange(
&self,
model: ModelCard,
conversation: Conversation,
opts: ExchangeOptions,
transport: Box<dyn ChildTransport>,
) -> Channel;
fn requires_credential(&self, spec: &ExternalRuntimeSpec) -> bool {
matches!(spec.auth_mode, RuntimeAuthMode::ApiKey)
}
}
The transport is a parameter (not constructed internally) precisely so tests pass a fake and no real CLI is spawned. run_exchange returns the Channel synchronously (it is populated asynchronously as the child emits), matching gateway::stream.
drive_exchange(model, opts, transport, opening_request, parse) is the shared transport → parser → sink loop every bridge reuses. It builds an EmissionSink over an unbounded mpsc that backs the returned Channel, and the loop:
- subscribes the
ChildParsertotransport.on_message(each inboundChildMessageis fed to the sink; a terminalParseStep::Donesettles the exchange); - sends the
opening_request(a send failure ends the exchange in error); - races the settle against
opts.stream.cancelviatokio::select!(an aborted exchange yields a typedBridgeFailure::aborted, never a hang); - always calls
transport.close().awaiton the way out.
The transport is move-only / single-use, so it is take()n on the first channel iteration (stashed behind a Mutex<Option<(Box<dyn ChildTransport>, ChildParser)>>); a rare re-iteration yields a single typed BridgeFailure::error("external runtime exchange is not re-runnable"), keeping the channel well-formed (parity with the TS stream being consumed once). Inside the loop the sink lives behind an Arc<Mutex<EmissionSink>> because the synchronous on_message callback (invoked off-thread by the transport) and the async loop both drive it; a shared finished flag makes settle_success / settle_error idempotent (the TS finished guard).
A parser is ChildParser = Box<dyn FnMut(&ChildMessage, &mut EmissionSink) -> ParseStep + Send>. ParseStep has two values: Continue (events emitted, stream stays open) and Done (the payload was terminal — the driver finishes successfully if the sink has not already settled). Dialect parsers narrow opaque payloads with the shared helpers as_record (value.as_object()) and str_field.
The Three Shipped Dialects
Three bridges ship, each a RuntimeBridge impl with a built-in [ExternalRuntimeSpec] in builtin_runtime_specs():
| Bridge struct | Adapter | Default binary / args | Wire dialect |
|---|---|---|---|
ClaudeCliBridge |
claude-cli |
claude --output-format stream-json --verbose |
Anthropic stream-json content blocks |
CodexCliBridge |
codex-cli |
codex --json |
OpenAI --json turn/item events |
IndusagiCliBridge |
indusagi-cli |
indusagi --rpc |
Peer JSON-RPC notifications + runExchange response |
All three ship RuntimeAuthMode::ExternalCli (the spawned child owns its own login). builtin_runtime_spec(adapter) is the by-id lookup.
Dialect specifics worth knowing:
claude_cli_parserrecords the last-seenstop_reason(message_delta) somessage_stop/resultfinishes with it (claude_stop_reasonmaps"max_tokens"→Length,"tool_use"→ToolUse, elseStop). It surfaces aResumefromsystem/subtype:"init"/session_id, emits the tool call from thecontent_block_startsnapshot (intentionally droppinginput_json_deltachunks), and routestext_delta/thinking_deltato the sink. Anerrorpayload settlesfinish_error+Done.codex_cli_parsertrackssaw_tool_callsoturn.completed/response.completedsettle asToolUseonly if a tool call was seen, elseStop. It streamsresponse.output_text.deltaas text andresponse.reasoning_text.delta/response.reasoning.deltaas thinking; on a completed item (response.output_item.done/item.completed) it emits aToolCallfor afunction_call/tool_callitem (codex_is_tool_item), else falls back to the item's wholetext.codex_parse_argumentsaccepts anargumentsstring-or-object (empty/unparseable →{}), and the tool-call id preferscall_id, thenid, then the tool name. Athread.startedsurfaces aResume(thethread_id), and anerror/turn.failedpayload settlesfinish_error+Done.indusagi_cli_parseris stateless. A frame carrying amethodis a notification, dispatched byindusagi_handle_notification(stream/text,stream/thinking,stream/toolCall,session/resume,stream/done— whosereasonmaps viaindusagi_finish_reason"length"→Length,"toolUse"→ToolUse, elseStop— andstream/error, which honours anabortedparam to pickBridgeFailure::abortedvserror). A frame with nomethodis a response: anerrorkey settlesfinish_error, aresultkey is a terminalDoneif streaming notifications did not already finish.IndusagiCliBridge::new(spec)builds a peer bridge bound to a spec so itsdelegateis forwarded in the openingrunExchange(the JSON-RPCmethodis theINDUSAGI_RPC_METHOD = "runExchange"const, theiddefaults to the session id);IndusagiCliBridge::default()carries no delegate.
The peer is built but only the CLI bridges (claude / codex) are wired into the default catalog at 1.0; the peer ships behind the same cli-bridges transport seam.
The Injectable Child Transport
The bridge ↔ child boundary is the injectable ChildTransport trait — never a hard-coded spawn. No tokio::process is pulled into the default build; the production transport is gated behind the cli-bridges feature, and tests pass a fake, so no real claude / codex binary is launched.
#[async_trait::async_trait]
pub trait ChildTransport: Send + Sync {
async fn send(&self, request: ChildRequest) -> Result<(), TransportError>;
fn on_message(&self, listener: MessageListener) -> Disposer;
async fn close(&self);
}
| Member | Role |
|---|---|
send |
Relay one ChildRequest to the child (write a prompt to stdin / issue a JSON-RPC call). Errors with TransportError after close. |
on_message |
Register a MessageListener (Box<dyn FnMut(ChildMessage) + Send>) for inbound messages; returns a Disposer (Box<dyn FnOnce() + Send>). |
close |
Terminate the child and release the transport. Idempotent; kill-on-close is the production factory's job under cli-bridges. |
ChildMessage and ChildRequest carry an opaque serde_json::Value (payload / body) — whatever the underlying protocol relays. The broker reaches a transport only through a ChildTransportFactory = Arc<dyn Fn(TransportContext) -> Box<dyn ChildTransport> + Send + Sync>, fed a TransportContext { spec, model, opts, resume } so a production factory can launch and wire the child without the broker touching a process. When the broker has None for the factory, every external decision falls through to the framework route.
Auth as Data
Authentication policy is data, not control flow. RuntimeAuthMode is ExternalCli | ApiKey, and requires_credential answers the single question "does this model need a key on disk before being offered?":
ExternalCli→false. The spawned child owns its own login (its own keychain), so the model is offered as available with an empty credential vault. All three shipped specs areExternalCli.ApiKey→true. The runtime still resolves a key from the credential vault, same as a normal HTTP provider; only the transport differs.
The broker delegates requires_credential to the owning bridge's predicate when one is registered, and falls back to matches!(spec.auth_mode, RuntimeAuthMode::ApiKey) when no bridge is present — so it answers strictly the runtime credential question. See Auth for the disk-backed vault this policy gates.
Resume Links
An underlying CLI session can be reattached across exchanges. When a child surfaces a session id / thread id, the broker persists it through an injected RuntimeLinkStore:
pub const RUNTIME_LINK_ENTRY: &str = "external-runtime-link";
pub struct RuntimeLink {
pub source: String, // the model's provider/source slug
pub bridge: String, // the adapter id, e.g. "claude-cli"
pub resume_token: String, // serde rename = "resumeToken"
pub at: String, // ISO-8601 capture instant
}
pub fn runtime_source_key(source: &str, model_id: &str, bridge: &str) -> String {
format!("{source}|{model_id}|{bridge}") // "source|model|bridge"
}
RuntimeLinkStore is best-effort and append-only: save(link) is fire-and-forget (errors are logged, never propagated — a save failure must never fault a turn, PLAN/55 §4.3 R4), and find(source_key) -> Option<String> resolves a persisted token synchronously.
Two halves run on each external exchange:
- Reattach.
resolve_resume(spec)keysRuntimeLinkStore::findbyruntime_source_key(source_slug, card.id, adapter). The resolved token flows intoExchangeOptions.resume, theTransportContext.resume, and the bridge's opening request body. Thesource_slugis the bound card'sProviderIdserialized to its kebab-case wire literal ("anthropic","openai", …). - Persist.
attach_resume_tap(transport, spec)registers a listener ontransport.on_messagethat reads the same inbound messages as the parser, independent of the bridge's own parsing.resume_token_of(payload)recognizes the cross-dialect token shapes:
fn resume_token_of(payload: &serde_json::Value) -> Option<String> {
// Peer JSON-RPC: { method: "session/resume", params: { resumeToken } }
// Anthropic: { type: "system", subtype: "init", session_id }
// OpenAI --json: { type: "thread.started", thread_id }
// anything else → None
}
It is permissive (a stale token just yields a fresh session) and uses an Arc<Mutex<bool>> latch so it persists exactly one token per exchange (the TS saved latch). The tap is a no-op when no store is wired. The conductor's transcript store binds the production RuntimeLinkStore (the record is logged under the external-runtime-link tag); the in-memory test fake lives in the broker test module.
Cancellation Is Law
A cancelled exchange is always a typed outcome, never a panic or a hang. The contract's BridgeFault is serde-tagged and exhaustive with no _ arm (so a new variant is a compile error at every match site):
pub enum BridgeFault {
UnknownAdapter { adapter: String },
Transport { message: String },
Parse { message: String },
Aborted,
Runtime { message: String },
}
A route that fails before it produces a stream still hands back a well-formed channel via fault_channel(fault) -> Channel, which mints a single Emission::Error with the matching GatewayErrorKind (Aborted → Aborted, Transport/Runtime → Transport, Parse → Parse, UnknownAdapter → Unsupported) — parity with the framework GatewayInvoker's error-channel fallback. In drive_exchange, an already-cancelled or mid-flight-cancelled token settles BridgeFailure::aborted("exchange aborted") → GatewayErrorKind::Aborted, and the transport is still torn down (the an_already_cancelled_exchange_settles_as_a_typed_aborted_error test asserts both the typed error and transport.close()).
Module Map
| Module | Holds |
|---|---|
contract.rs |
The frozen type seam: ExternalRuntimeSpec, RuntimeAdapterId, RuntimeAuthMode, RUNTIME_ENDPOINT_SCHEME / runtime_endpoint, the NormalizedEvent union + FinishReason + BridgeFailure, the ChildTransport trait + ChildMessage / ChildRequest / ChildTransportFactory / TransportContext / TransportError / Disposer / MessageListener, ExchangeOptions, ChildParser / ParseStep, the RuntimeBridge trait, RuntimeRoute, RuntimeLink / RuntimeLinkStore / RUNTIME_LINK_ENTRY / runtime_source_key, and the typed BridgeFault + fault_channel. |
sink.rs |
The one EmissionSink impl: NormalizedEvent → Emission mapping, the fold buffer, the settled guard, text / thinking / tool_call / emit / finish_success / finish_error. |
bridges.rs |
builtin_runtime_specs / builtin_runtime_spec / spec_from_card / annotate_card; the shared drive_exchange loop + as_record / str_field; the three bridge structs (ClaudeCliBridge / CodexCliBridge / IndusagiCliBridge) and their parsers (claude_cli_parser / codex_cli_parser / indusagi_cli_parser). |
broker.rs |
RuntimeBroker (the impl ModelInvoker) + SpecTable, register / with_transport_factory / with_link_store, route / requires_credential / resolve_resume / attach_resume_tap / source_slug, the resume tap's free helpers resume_token_of / build_link / now_iso8601, and the wire_tracing_sink stub. |
mod.rs |
The public routing surface re-export (mirrors runtime-bridge/index.ts). |
Public Surface
The subsystem is consumed through induscode::runtime_bridge. The crate root re-exports:
| Name | Kind | Source | Purpose |
|---|---|---|---|
RuntimeBroker |
struct | broker.rs |
The router that is a ModelInvoker; sole entry the agent wires via AgentDeps.invoke_model. |
SpecTable |
type alias | broker.rs |
BTreeMap<String, ExternalRuntimeSpec> — the canonical-id → spec side-table. |
RuntimeBridge |
trait | contract.rs |
One external-runtime adapter: adapter(), run_exchange(...), requires_credential(spec). |
ExternalRuntimeSpec |
struct | contract.rs |
The frozen runtime annotation. |
RuntimeAdapterId |
enum | contract.rs |
ClaudeCli / CodexCli / IndusagiCli / Custom(String). |
RuntimeAuthMode |
enum | contract.rs |
ExternalCli / ApiKey. |
RUNTIME_ENDPOINT_SCHEME / runtime_endpoint |
const / fn | contract.rs |
The bridge: scheme and the bridge:<adapter> minter. |
NormalizedEvent |
enum | contract.rs |
Text / Thinking / ToolCall / Resume / Finish / Failed. |
FinishReason |
enum | contract.rs |
Stop / Length / ToolUse; to_stop_reason(). |
BridgeFailure |
struct | contract.rs |
{ message, aborted }; error(..) / aborted(..) constructors. |
BridgeFault |
enum | contract.rs |
The exhaustive, serde-tagged routing fault; fault_channel(fault). |
ChildTransport |
trait | contract.rs |
The injectable child boundary. |
ChildMessage / ChildRequest |
struct | contract.rs |
The opaque-payload envelopes. |
ChildTransportFactory |
type alias | contract.rs |
Arc<dyn Fn(TransportContext) -> Box<dyn ChildTransport> + Send + Sync>. |
TransportContext |
struct | contract.rs |
{ spec, model, opts, resume } fed to the factory. |
ChildParser / ParseStep |
type alias / enum | contract.rs |
The per-dialect parser surface (Continue / Done). |
ExchangeOptions |
struct | contract.rs |
Composes framework StreamOptions + session_id / cwd / resume. |
RuntimeRoute |
enum | contract.rs |
External { bridge, spec } / Framework. |
RuntimeLink / RuntimeLinkStore / RUNTIME_LINK_ENTRY |
struct / trait / const | contract.rs |
Resume-token persistence under the external-runtime-link tag. |
runtime_source_key |
fn | contract.rs |
Composes the source|model|bridge reuse key. |
EmissionSink |
struct | sink.rs |
The single NormalizedEvent → Emission translator. |
Not re-exported at the crate root but pub in bridges.rs: builtin_runtime_specs, builtin_runtime_spec, spec_from_card, annotate_card, drive_exchange, the three *_cli_parser builders, and ClaudeCliBridge / CodexCliBridge / IndusagiCliBridge.
Examples
Wire the broker into an agent as the ModelInvoker, layering in the optional collaborators:
use std::sync::Arc;
use induscode::runtime_bridge::{RuntimeBroker, SpecTable};
use induscode::runtime_bridge::bridges::{ClaudeCliBridge, CodexCliBridge};
let mut broker = RuntimeBroker::new(card, gateway_invoker, Arc::new(spec_table))
.with_transport_factory(my_factory) // Arc<dyn Fn(TransportContext) -> Box<dyn ChildTransport>>
.with_link_store(my_link_store); // Arc<dyn RuntimeLinkStore>
broker.register(Arc::new(ClaudeCliBridge::default()));
broker.register(Arc::new(CodexCliBridge::default()));
// Hand it to the agent; the conductor cannot tell which path a turn took:
let invoker: Arc<dyn indusagi::runtime::ModelInvoker> = Arc::new(broker);
Write a custom bridge as a pure parser over the shared driver:
use induscode::runtime_bridge::{
NormalizedEvent, ParseStep, RuntimeAdapterId, RuntimeBridge,
ChildTransport, ExchangeOptions, EmissionSink,
};
use induscode::runtime_bridge::bridges::drive_exchange;
use indusagi::llmgateway::contract::{Channel, Conversation, ModelCard};
struct MyBridge { adapter: RuntimeAdapterId }
impl RuntimeBridge for MyBridge {
fn adapter(&self) -> &RuntimeAdapterId { &self.adapter }
fn run_exchange(&self, model: ModelCard, _c: Conversation,
opts: ExchangeOptions, transport: Box<dyn ChildTransport>) -> Channel {
let parse = Box::new(|m: &_, sink: &mut EmissionSink| {
// map this dialect's payload onto NormalizedEvents...
sink.emit(NormalizedEvent::Finish { reason: Default::default() });
ParseStep::Done
});
drive_exchange(model, opts, transport, serde_json::json!({"type":"turn"}), parse)
}
}
RuntimeAdapterId::Custom("my-cli".into()) lets a model whose SpecTable entry names that adapter route to register(Arc::new(MyBridge { .. })).
Parity Notes
This is a complete, fully-tested library layer. Its tests live alongside the source (#[cfg(test)] in sink.rs, broker.rs, bridges.rs) using a scripted FakeTransport, a FakeFramework ModelInvoker, and an in-memory FakeLinkStore — no real binary is spawned anywhere. Several deliberate behaviours are worth flagging against the TS and Python editions:
- Side-table, not
baseUrldecode. Where TS rewroteModel.baseUrltobridge:<adapter>and decoded it back, Rust resolves the spec from a canonical-idSpecTable(PLAN/55 §4.2 OQ1). Thebridge:scheme survives only as a display address (annotate_card). - No
streamSimpleto decorate. The broker is aModelInvoker; both arms hand back aChannel<Emission>. There is noAssistantMessageEventStream. - Folded reply, no in-place accumulator. The sink defers reconstruction to
fold_replyat finish time rather than maintaining a frozenAssistantMessagesnapshot per push, so it sheds the TSOpenBlock/ensureOpen/closeOpenmachinery entirely. Block order is canonical (thinking → text → tool calls). - Cancellation is a typed
Aborted. A cancelled exchange settlesGatewayErrorKind::Abortedand still closes the transport — never a panic, never a hang. cli-bridges-gated OS seam. The productionChildTransportFactory(the onlytokio::processcode) is feature-gated; the default build and the entire test suite run over fakes.
The tracing-sink wiring (wire_tracing_sink, broker.rs) is a documented scaffold stub: the projection onto indusagi::tracing::trace_agent_run is filled by the conductor port. See Parity for the broader translation record.
On the Framework
Everything below the routing seam belongs to the indusagi framework. The network fall-through is the framework runtime's ModelInvoker, backed by the LLM gateway via gateway::stream (channel_of + fold_reply from indusagi::llmgateway); the Channel / Emission currency, the ModelCard / Conversation / StreamOptions / StopReason types, the GatewayError family, and the CancellationToken are all framework surfaces consumed verbatim from indusagi::core and indusagi::llmgateway. The runtime bridge adds exactly one thing on top: a typed choice, per turn, between that gateway path and an external child runtime — with both paths returning the same Channel<Emission>.
