use futures::FutureExt;
use indexmap::IndexMap;
use std::any::Any;
use std::collections::HashSet;
use std::fmt;
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use super::{SharedState, Tool, ToolError, ToolSchema};
fn panic_message(payload: &(dyn Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&str>() {
return (*s).chars().take(500).collect();
}
if let Some(s) = payload.downcast_ref::<String>() {
return s.chars().take(500).collect();
}
"unknown panic".into()
}
#[derive(Clone, Default)]
pub struct ToolRegistry {
tools: IndexMap<String, Arc<dyn Tool>>,
}
impl ToolRegistry {
pub fn new() -> Self {
Self {
tools: IndexMap::new(),
}
}
pub fn register(&mut self, tool: impl Tool + 'static) -> &mut Self {
self.tools.insert(tool.schema().name, Arc::new(tool));
self
}
pub fn names(&self) -> Vec<String> {
self.tools.keys().cloned().collect()
}
pub fn remove(&mut self, name: &str) -> bool {
self.tools.shift_remove(name).is_some()
}
pub fn retain(&mut self, mut keep: impl FnMut(&str) -> bool) -> Vec<String> {
let mut removed = Vec::new();
self.tools.retain(|name, _| {
if keep(name) {
true
} else {
removed.push(name.clone());
false
}
});
removed
}
pub fn schemas(&self) -> Vec<ToolSchema> {
self.tools.values().map(|t| t.schema()).collect()
}
pub fn get(&self, name: &str) -> Option<&dyn Tool> {
self.tools.get(name).map(|t| t.as_ref())
}
pub async fn call(
&self,
name: &str,
arguments: &str,
state: &SharedState,
) -> Result<String, RegistryError> {
let Some(tool) = self.tools.get(name) else {
return Err(RegistryError::NotFound(name.to_string()));
};
let args = match serde_json::from_str(arguments) {
Ok(value) => value,
Err(e) => return Err(RegistryError::InvalidArguments(e.to_string())),
};
let result = AssertUnwindSafe(tool.call(args, state))
.catch_unwind()
.await
.map_err(|payload| {
let message = panic_message(payload.as_ref());
RegistryError::Execution {
name: name.to_string(),
source: ToolError::Execution(format!("panicked: {message}")),
}
})?;
result.map_err(|e| match e {
ToolError::InvalidArguments(msg) => RegistryError::InvalidArguments(msg),
other => RegistryError::Execution {
name: name.to_string(),
source: other,
},
})
}
pub fn subset(&self, names: &[&str]) -> Result<ToolRegistry, MissingTools> {
let wanted: HashSet<&str> = names.iter().copied().collect();
let mut tools = IndexMap::new();
let mut found = HashSet::new();
for (name, tool) in &self.tools {
if wanted.contains(name.as_str()) {
found.insert(name.clone());
tools.insert(name.clone(), tool.clone());
}
}
let missing: Vec<String> = names
.iter()
.filter(|n| !found.contains(**n))
.map(|n| (*n).to_string())
.collect();
if missing.is_empty() {
Ok(ToolRegistry { tools })
} else {
Err(MissingTools { names: missing })
}
}
}
impl fmt::Debug for ToolRegistry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_list().entries(self.names()).finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("tools not found in registry: {}", self.names.join(", "))]
pub struct MissingTools {
names: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum RegistryError {
#[error("tool not found: {0}")]
NotFound(String),
#[error("invalid arguments: {0}")]
InvalidArguments(String),
#[error("tool error: {name} failed: {source}")]
Execution {
name: String,
#[source]
source: ToolError,
},
}
impl MissingTools {
pub fn names(&self) -> &[String] {
&self.names
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tool::ToolError;
use std::error::Error;
use std::sync::atomic::{AtomicUsize, Ordering};
struct FakeTool {
name: &'static str,
output: &'static str,
fail: bool,
}
#[async_trait::async_trait]
impl Tool for FakeTool {
fn schema(&self) -> ToolSchema {
ToolSchema {
name: self.name.into(),
description: self.output.into(),
parameters: serde_json::json!({}),
}
}
async fn call(
&self,
_arguments: serde_json::Value,
_state: &SharedState,
) -> Result<String, ToolError> {
if self.fail {
Err(ToolError::Execution("boom".into()))
} else {
Ok(self.output.into())
}
}
}
struct CountingTool {
name: &'static str,
calls: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl Tool for CountingTool {
fn schema(&self) -> ToolSchema {
ToolSchema {
name: self.name.into(),
description: "counts calls".into(),
parameters: serde_json::json!({}),
}
}
async fn call(
&self,
_arguments: serde_json::Value,
_state: &SharedState,
) -> Result<String, ToolError> {
self.calls.fetch_add(1, Ordering::Relaxed);
Ok("ok".into())
}
}
fn echo(name: &'static str) -> FakeTool {
FakeTool {
name,
output: name,
fail: false,
}
}
fn registry() -> ToolRegistry {
let mut r = ToolRegistry::new();
r.register(echo("search"))
.register(echo("calculator"))
.register(echo("search"));
r
}
#[test]
fn names_in_registration_order_dedup() {
assert_eq!(registry().names(), vec!["search", "calculator"]);
}
#[test]
fn schemas_in_registration_order() {
let schemas = registry().schemas();
assert_eq!(schemas.len(), 2);
assert_eq!(schemas[0].name, "search");
assert_eq!(schemas[1].name, "calculator");
}
#[tokio::test]
async fn register_duplicate_replaces() {
let mut r = ToolRegistry::new();
r.register(FakeTool {
name: "a",
output: "first",
fail: false,
})
.register(FakeTool {
name: "a",
output: "second",
fail: false,
});
assert_eq!(r.names(), vec!["a"]);
assert_eq!(r.schemas()[0].description, "second");
assert_eq!(
r.call("a", "{}", &SharedState::new()).await.unwrap(),
"second"
);
}
#[tokio::test]
async fn get_returns_tool_with_error_semantics() {
let mut r = ToolRegistry::new();
r.register(FakeTool {
name: "a",
output: "",
fail: true,
});
let tool = r.get("a").expect("registered");
let result = tool.call(serde_json::json!({}), &SharedState::new()).await;
assert!(matches!(result, Err(ToolError::Execution(_))));
assert!(r.get("nope").is_none());
}
#[tokio::test]
async fn call_succeeds() {
assert_eq!(
registry()
.call("calculator", "{}", &SharedState::new())
.await
.unwrap(),
"calculator"
);
}
#[tokio::test]
async fn call_unknown_tool_returns_not_found() {
let err = registry()
.call("nope", "{}", &SharedState::new())
.await
.unwrap_err();
assert!(matches!(&err, RegistryError::NotFound(name) if name == "nope"));
assert_eq!(err.to_string(), "tool not found: nope");
}
#[tokio::test]
async fn call_invalid_json_returns_invalid_arguments() {
let err = registry()
.call("calculator", "not-json", &SharedState::new())
.await
.unwrap_err();
assert!(matches!(err, RegistryError::InvalidArguments(_)));
assert!(err.to_string().starts_with("invalid arguments:"));
}
#[tokio::test]
async fn call_structural_error_returns_invalid_arguments_not_execution() {
struct StrictTool;
#[async_trait::async_trait]
impl Tool for StrictTool {
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "strict".into(),
description: "requires a".into(),
parameters: serde_json::json!({
"type": "object",
"properties": { "a": { "type": "integer" } },
"required": ["a"],
}),
}
}
async fn call(
&self,
arguments: serde_json::Value,
_state: &SharedState,
) -> Result<String, ToolError> {
let a = arguments
.get("a")
.ok_or_else(|| ToolError::InvalidArguments("missing field `a`".into()))?;
Ok(a.to_string())
}
}
let mut r = ToolRegistry::new();
r.register(StrictTool);
let err = r
.call("strict", r#"{"b":1}"#, &SharedState::new())
.await
.unwrap_err();
assert!(matches!(&err, RegistryError::InvalidArguments(msg) if msg == "missing field `a`"));
assert_eq!(err.to_string(), "invalid arguments: missing field `a`");
}
#[tokio::test]
async fn call_execution_error_returns_execution_with_source() {
let mut r = ToolRegistry::new();
r.register(FakeTool {
name: "broken",
output: "",
fail: true,
});
let err = r
.call("broken", "{}", &SharedState::new())
.await
.unwrap_err();
assert_eq!(
err.to_string(),
"tool error: broken failed: execution failed: boom"
);
assert!(matches!(err.source(), Some(e) if e.to_string() == "execution failed: boom"));
}
#[tokio::test]
async fn tool_panic_is_captured_as_execution_error() {
struct PanickingTool;
#[async_trait::async_trait]
impl Tool for PanickingTool {
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "panic".into(),
description: "panics".into(),
parameters: serde_json::json!({}),
}
}
async fn call(
&self,
_arguments: serde_json::Value,
_state: &SharedState,
) -> Result<String, ToolError> {
panic!("boom")
}
}
let mut r = ToolRegistry::new();
r.register(PanickingTool);
let err = r
.call("panic", "{}", &SharedState::new())
.await
.unwrap_err();
assert!(
matches!(err, RegistryError::Execution { name, source: ToolError::Execution(msg) }
if name == "panic" && msg.contains("panicked") && msg.contains("boom"))
);
}
#[test]
fn retain_removes_by_prefix_in_registration_order() {
let mut r = ToolRegistry::new();
r.register(echo("fs__read"))
.register(echo("fs__write"))
.register(echo("calc"));
let removed = r.retain(|name| !name.starts_with("fs__"));
assert_eq!(removed, ["fs__read", "fs__write"]);
assert_eq!(r.names(), ["calc"]);
}
#[test]
fn retain_keep_all_returns_empty() {
let mut r = registry();
assert!(r.retain(|_| true).is_empty());
assert_eq!(r.names(), ["search", "calculator"]);
}
#[test]
fn subset_keeps_registration_order() {
let sub = registry().subset(&["calculator", "search"]).unwrap();
assert_eq!(sub.names(), vec!["search", "calculator"]);
}
#[tokio::test]
async fn subset_duplicate_name_takes_latest() {
let mut r = ToolRegistry::new();
r.register(FakeTool {
name: "a",
output: "first",
fail: false,
})
.register(FakeTool {
name: "a",
output: "second",
fail: false,
});
let sub = r.subset(&["a"]).unwrap();
assert_eq!(sub.names(), vec!["a"]);
assert_eq!(
sub.call("a", "{}", &SharedState::new()).await.unwrap(),
"second"
);
}
#[test]
fn subset_missing_names_error_with_list() {
let err = registry()
.subset(&["search", "nope", "calculator", "also-nope"])
.unwrap_err();
assert_eq!(err.names(), &["nope", "also-nope"]);
}
#[tokio::test]
async fn subset_shares_tool_instances() {
let calls = Arc::new(AtomicUsize::new(0));
let mut r = ToolRegistry::new();
r.register(CountingTool {
name: "counter",
calls: calls.clone(),
});
let sub = r.subset(&["counter"]).unwrap();
r.call("counter", "{}", &SharedState::new()).await.unwrap();
sub.call("counter", "{}", &SharedState::new())
.await
.unwrap();
assert_eq!(calls.load(Ordering::Relaxed), 2);
}
#[tokio::test]
async fn clone_shares_tool_instances() {
let calls = Arc::new(AtomicUsize::new(0));
let mut r = ToolRegistry::new();
r.register(CountingTool {
name: "counter",
calls: calls.clone(),
});
let r2 = r.clone();
r.call("counter", "{}", &SharedState::new()).await.unwrap();
r2.call("counter", "{}", &SharedState::new()).await.unwrap();
assert_eq!(calls.load(Ordering::Relaxed), 2);
}
#[tokio::test]
async fn call_passes_shared_state_to_tool() {
struct StateTool;
#[async_trait::async_trait]
impl Tool for StateTool {
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "state_tool".into(),
description: "read and write shared state".into(),
parameters: serde_json::json!({}),
}
}
async fn call(
&self,
_arguments: serde_json::Value,
state: &SharedState,
) -> Result<String, ToolError> {
state.with_mut::<usize>(|n| *n += 1);
Ok(format!("count={}", state.get::<usize>().unwrap_or(0)))
}
}
let state = SharedState::new();
state.insert(0usize);
let mut r = ToolRegistry::new();
r.register(StateTool);
assert_eq!(r.call("state_tool", "{}", &state).await.unwrap(), "count=1");
assert_eq!(r.call("state_tool", "{}", &state).await.unwrap(), "count=2");
}
}