Skip to content

Bridge

The bridge (crates/session/src/tools.rs) is a thin adapter that turns CommandTool-derived intent enums into rig-compatible tools. Each intent variant becomes a separate tool. Tool calls dispatch through Session::dispatch() — the same reduce-recompile-commit-emit path used by Dart.

Defined in modality_core, implemented via #[derive(CommandTool)] on intent enums:

crates/core/src/command_tool.rs [22:31]
pub trait CommandTool: Sized + Send + 'static {
/// Tool definitions for all visible variants.
fn tool_definitions() -> Vec<ToolDefinition>;
/// Parse a tool call (name + JSON args) into the enum variant.
fn from_tool_call(name: &str, args: serde_json::Value) -> Result<Self, CommandToolError>;
/// All tool names.
fn tool_names() -> &'static [&'static str];
}

The derive macro generates tool names from snake_case variant names, descriptions from doc comments, and JSON schemas from field types. Variants marked #[tool(hidden)] are excluded from tool_definitions() and tool_names() but remain fully dispatchable through the session.

Some intent variants are UI-only operations that should not be exposed to AI (selection, tool switching, undo/redo). Marking them #[tool(hidden)] keeps them out of the AI tool set while preserving the single intent enum for all dispatch:

#[derive(CommandTool)]
pub enum WhiteboardIntent {
/// Add a text element to the canvas.
AddText { x: f64, y: f64, content: String },
/// Select elements by their placement IDs.
#[tool(hidden)]
SelectElements { ids: Vec<String> },
/// Undo the last operation.
#[tool(hidden)]
Undo,
}

One rig Tool per intent variant, generic over <M, I>:

pub struct SessionTool<M: Modality, I: ReduceIntent<M>> {
session: Arc<Mutex<Session<M, I>>>,
definition: ToolDefinition,
tool_name: String,
}

The M::Intent: CommandTool bound is added explicitly by the bridge, not required by Reducer. This keeps the core trait clean — only the AI bridge path needs CommandTool.

When a rig agent calls a tool:

sequenceDiagram
participant Agent as rig Agent
participant ST as SessionTool
participant S as Session
participant AS as AgentService
Agent->>ST: call(tool_name, args)
ST->>ST: M::Intent::from_tool_call(name, args)
ST->>S: dispatch(Command::Intent(I::wrap_modality(intent)))
Note over S: reduce -> recompile -> commit -> emit
S->>AS: fx.services().agent.tool_output(json)
ST->>S: drain_agent_events()
S-->>ST: Vec<AgentEvent>
ST-->>Agent: last ToolOutput or ToolError
// SessionTool::call():
let intent = M::Intent::from_tool_call(&self.tool_name, args)?;
let mut session = self.session.lock();
session.dispatch(Command::Intent(I::wrap_modality(intent)));
let events = session.drain_agent_events();
// Last ToolOutput/ToolError from events is the result

Write-only AI bridge. An mpsc channel that collects tool outputs from reduce functions:

pub struct AgentService {
tx: mpsc::Sender<AgentEvent>,
}
pub enum AgentEvent {
ToolOutput(serde_json::Value),
ToolError(String),
}
impl AgentService {
pub fn new() -> (Arc<Self>, Receiver<AgentEvent>);
pub fn tool_output(&self, val: impl Serialize);
pub fn tool_error(&self, msg: impl Into<String>);
pub fn noop() -> Arc<Self>; // for tests
}

Session creates the AgentService and passes the same Arc to the modality’s services. Both session-level and modality-level reduce functions write to it via fx.services().agent.tool_output(json!({...})). Session owns the receiver and drains events after dispatch to feed results back to the AI agent.

pub fn create_tools<M: Modality, I: ReduceIntent<M>>(
session: Arc<Mutex<Session<M, I>>>,
) -> Vec<Box<dyn ToolDyn>>
where
M::Intent: CommandTool + Sync,
{
M::Intent::tool_definitions()
.into_iter()
.map(|def| {
let tool = SessionTool::<M, I> {
session: session.clone(),
definition: def.clone(),
tool_name: def.name.clone(),
};
Box::new(tool) as Box<dyn ToolDyn>
})
.collect()
}

Generic over <M, I> — works for both leaf sessions (SessionIntent<M::Intent>) and parent sessions (ParentSessionIntent<M::Intent, CI>). Uses I::wrap_modality() to wrap the modality intent for dispatch through Session::dispatch.

For parent modalities like LessonPlan, child dispatch goes through ParentSessionIntent::Child:

// Parent session dispatching to a child whiteboard:
let intent = ParentSessionIntent::Child {
id: Some(slide_id),
intent: SessionIntent::Modality(wb_intent),
};
session.dispatch(Command::Intent(intent));

Each child Session<Whiteboard, SessionIntent<WhiteboardIntent>> in a Slot::Hot has its own SessionEphemeral (own AI state). During generation:

  1. Parent warms child: ParentSessionIntent::WarmChild { id } -> backend load -> ChildWarmed feedback
  2. Parent dispatches to child: ParentSessionIntent::Child { id, intent }
  3. Child does its own reduce cycle independently
  4. Parent recompiles after child completes

The bridge uses I::wrap_modality(intent) to wrap a raw modality intent into whatever intent type the session uses. This is what makes create_tools() generic:

// Leaf session: wraps as SessionIntent::Modality(intent)
impl ReduceIntent<M> for SessionIntent<M::Intent> {
fn wrap_modality(intent: M::Intent) -> Self {
SessionIntent::Modality(intent)
}
}
// Parent session: wraps as ParentSessionIntent::Base(SessionIntent::Modality(intent))
impl ReduceIntent<M> for ParentSessionIntent<M::Intent, CI> {
fn wrap_modality(intent: M::Intent) -> Self {
ParentSessionIntent::Base(SessionIntent::Modality(intent))
}
}
  • AI TraitsCommandTool bound, Describe for tool descriptions
  • Subagents — consume the tools created by create_tools()
  • Reducerdispatch() called by SessionTool::call()