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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1182,6 +1182,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
|||||||
name = "rutster"
|
name = "rutster"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ futures-util = { workspace = true }
|
|||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
dashmap = { workspace = true }
|
dashmap = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
|
|||||||
@@ -25,3 +25,4 @@
|
|||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod session_map;
|
pub mod session_map;
|
||||||
pub mod tap_engine;
|
pub mod tap_engine;
|
||||||
|
pub mod tool_registry;
|
||||||
|
|||||||
208
crates/rutster/src/tool_registry.rs
Normal file
208
crates/rutster/src/tool_registry.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user