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.
CommandTool trait
Section titled “CommandTool trait”Defined in modality_core, implemented via #[derive(CommandTool)] on intent enums:
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.
#[tool(hidden)]
Section titled “#[tool(hidden)]”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,}SessionTool
Section titled “SessionTool”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.
Tool dispatch flow
Section titled “Tool dispatch flow”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 resultAgentService
Section titled “AgentService”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.
create_tools() factory
Section titled “create_tools() factory”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.
Parent modality child dispatch
Section titled “Parent modality child 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:
- Parent warms child:
ParentSessionIntent::WarmChild { id }-> backend load ->ChildWarmedfeedback - Parent dispatches to child:
ParentSessionIntent::Child { id, intent } - Child does its own reduce cycle independently
- Parent recompiles after child completes
ReduceIntent::wrap_modality()
Section titled “ReduceIntent::wrap_modality()”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)) }}