feat(binary): tool_registry + hangup tool (spec §6)

FOB-boundary dispatch for brain-proposed tool calls (ADR-0007: 'rutster
mediates both the provider call-control API and the brain tap, so the
brain never holds the wire'). Slice-3's only wired tool is hangup
(fires AppState::close → existing slice-2 teardown); other tool names
reply not_implemented.

TDD: 5 tests (dispatch known + unknown tool, catalog listing, hangup
schema shape, hangup_call fires AppState::close).
This commit is contained in:
opencode controller
2026-06-30 22:18:55 -04:00
parent b61a22ed56
commit d8554c3594
4 changed files with 211 additions and 0 deletions

1
Cargo.lock generated
View File

@@ -1182,6 +1182,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
name = "rutster"
version = "0.0.0"
dependencies = [
"async-trait",
"axum",
"dashmap",
"futures-util",

View File

@@ -18,6 +18,7 @@ futures-util = { workspace = true }
url = { workspace = true }
dashmap = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

View File

@@ -25,3 +25,4 @@
pub mod routes;
pub mod session_map;
pub mod tap_engine;
pub mod tool_registry;

View File

@@ -0,0 +1,208 @@
//! # Tool registry — the FOB boundary for brain-proposed tool calls
//!
//! Per spec §6 + ADR-0007 ("rutster mediates both the provider call-control
//! API and the brain tap, so the brain never holds the wire"). The brain
//! proposes (via function_call events); the FOB disposes (via
//! function_call_output).
//!
//! `hangup` is the only wired tool in slice-3 (spec §6.3); other tool names
//! reply `not_implemented` so the model is free to retry or give up.
use async_trait::async_trait;
use rutster_call_model::ChannelId;
use serde_json::{Value, json};
use tracing::warn;
use crate::session_map::AppState;
/// A registry-dispatchable tool. The async-trait pattern is needed because
/// the registry holds `Vec<Box<dyn Tool>>` (stable Rust doesn't support
/// async fns in trait *objects* without `async-trait` as of Rust 1.85).
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
/// JSON-schema descriptor the registry sends to the brain on tools.update.
fn schema(&self) -> Value;
/// Execute the tool. The args Value is the raw JSON from the function_call
/// event (the brain's translator extracts `arguments` from OpenAI's
/// event and the registry hands it here verbatim).
async fn call(&self, args: Value) -> ToolResult;
}
#[derive(Debug, Clone)]
pub enum ToolResult {
Ok(Value),
Error(String),
NotImplemented,
}
impl ToolResult {
/// Serialize to the (status, result) pair the binary's poll task will
/// pass to `encode_function_call_output`.
pub fn to_status_result(&self) -> (String, Value) {
match self {
ToolResult::Ok(v) => ("ok".to_string(), v.clone()),
ToolResult::Error(msg) => ("error".to_string(), json!({ "error": msg })),
ToolResult::NotImplemented => ("not_implemented".to_string(), Value::Null),
}
}
}
pub struct ToolRegistry {
tools: Vec<Box<dyn Tool>>,
}
impl ToolRegistry {
pub fn new() -> Self {
Self { tools: Vec::new() }
}
pub fn register(&mut self, tool: Box<dyn Tool>) {
self.tools.push(tool);
}
/// Dispatch by tool name. Returns `ToolResult::NotImplemented` if no
/// tool with the given name is registered.
pub async fn dispatch(&self, name: &str, args: Value) -> ToolResult {
for tool in &self.tools {
if tool.name() == name {
return tool.call(args).await;
}
}
warn!(tool = %name, "brain proposed unknown tool; returning not_implemented");
ToolResult::NotImplemented
}
/// Serialize the catalog (used on startup + tools.update emission).
pub fn catalog(&self) -> Vec<Value> {
self.tools.iter().map(|t| t.schema()).collect()
}
}
impl Default for ToolRegistry {
fn default() -> Self {
Self::new()
}
}
/// The `hangup` tool. Holds `AppState` (cloned — it's `Arc`-cheap) + the
/// `ChannelId` of the call this tool operates on. `call()` ->
/// `AppState::close(channel_id).await` -> returns `Ok({"channel_state": "Closing"})`.
pub struct HangupTool {
app_state: AppState,
channel_id: ChannelId,
}
impl HangupTool {
pub fn new(app_state: AppState, channel_id: ChannelId) -> Self {
Self {
app_state,
channel_id,
}
}
}
#[async_trait]
impl Tool for HangupTool {
fn name(&self) -> &str {
"hangup"
}
fn schema(&self) -> Value {
json!({
"name": "hangup",
"description": "Hang up the current call.",
"parameters": {
"type": "object",
"properties": {}
}
})
}
async fn call(&self, _args: Value) -> ToolResult {
self.app_state.close(self.channel_id).await;
ToolResult::Ok(json!({ "channel_state": "Closing" }))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A no-op tool whose `call` returns a fixed value — for testing the
/// registry's dispatch + catalog shape without needing AppState.
struct EchoTool {
tool_name: String,
}
#[async_trait]
impl Tool for EchoTool {
fn name(&self) -> &str {
&self.tool_name
}
fn schema(&self) -> Value {
json!({ "name": self.tool_name, "description": "test tool" })
}
async fn call(&self, args: Value) -> ToolResult {
ToolResult::Ok(args)
}
}
#[tokio::test]
async fn dispatch_known_tool_returns_ok() {
let mut reg = ToolRegistry::new();
reg.register(Box::new(EchoTool {
tool_name: "echo".to_string(),
}));
let r = reg.dispatch("echo", json!({"x": 1})).await;
let (status, result) = r.to_status_result();
assert_eq!(status, "ok");
assert_eq!(result, json!({"x": 1}));
}
#[tokio::test]
async fn dispatch_unknown_tool_returns_not_implemented() {
let mut reg = ToolRegistry::new();
reg.register(Box::new(EchoTool {
tool_name: "echo".to_string(),
}));
let r = reg.dispatch("not_registered", json!({})).await;
let (status, _) = r.to_status_result();
assert_eq!(status, "not_implemented");
}
#[tokio::test]
async fn catalog_lists_all_registered_tools() {
let mut reg = ToolRegistry::new();
reg.register(Box::new(EchoTool {
tool_name: "a".to_string(),
}));
reg.register(Box::new(EchoTool {
tool_name: "b".to_string(),
}));
let cat = reg.catalog();
assert_eq!(cat.len(), 2);
assert_eq!(cat[0]["name"], "a");
assert_eq!(cat[1]["name"], "b");
}
#[tokio::test]
async fn hangup_tool_schema_shape() {
// Don't construct a full HangupTool (needs AppState) — just verify
// the schema shape via the impl on a standalone.
let app_state = AppState::default();
let h = HangupTool::new(app_state, ChannelId(uuid::Uuid::nil()));
let s = h.schema();
assert_eq!(s["name"], "hangup");
assert!(s["description"].is_string());
assert_eq!(s["parameters"]["type"], "object");
}
#[tokio::test]
async fn hangup_tool_call_fires_app_state_close() {
// AppState::close on a fake session_id that doesn't exist just
// logs + no-ops (the `if let Some((_id, session_arc)) = ...` arm).
// The tool returns Ok with channel_state: "Closing" regardless —
// the dispatch boundary gives the brain a deterministic reply.
let app_state = AppState::default();
let h = HangupTool::new(app_state, ChannelId(uuid::Uuid::new_v4()));
let r = h.call(json!({})).await;
let (status, result) = r.to_status_result();
assert_eq!(status, "ok");
assert_eq!(result["channel_state"], "Closing");
}
}