use std::collections::BTreeMap;
use std::fmt::Debug;
use async_trait::async_trait;
use serde::de::DeserializeOwned;
use serde_json::Value;
use super::{ToolClient, ToolError, ToolId};
#[async_trait]
pub trait Tool: DeserializeOwned + schemars::JsonSchema + Send + Sync + 'static {
const SERVER: &'static str;
const NAME: &'static str;
fn mutates() -> bool {
true
}
async fn call(self) -> Result<Value, ToolError>;
}
struct Registered {
description: String,
schema: Value,
mutates: bool,
#[allow(clippy::type_complexity)]
invoke: Box<
dyn Fn(Value) -> futures_core::future::BoxFuture<'static, Result<Value, ToolError>>
+ Send
+ Sync,
>,
}
#[derive(Default)]
pub struct ToolBox {
tools: BTreeMap<ToolId, Registered>,
}
impl Debug for ToolBox {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ToolBox")
.field("tools", &self.tools.keys().collect::<Vec<_>>())
.finish()
}
}
impl ToolBox {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with<T: Tool>(mut self) -> Self {
let schema = serde_json::to_value(schemars::schema_for!(T))
.expect("a schemars-generated schema must serialize to JSON");
let description = schema
.get("description")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let id = ToolId::new(T::SERVER, T::NAME);
assert!(
!self.tools.contains_key(&id),
"typed tool '{id}' was registered twice — silently replacing one body with \
another makes registration order decide which implementation runs"
);
self.tools.insert(
id,
Registered {
description,
schema,
mutates: T::mutates(),
invoke: Box::new(|args: Value| {
Box::pin(async move {
let parsed: T =
serde_json::from_value(args).map_err(|e| ToolError::Malformed {
tool: ToolId::new(T::SERVER, T::NAME),
detail: format!("arguments do not match the declared shape: {e}"),
})?;
parsed.call().await
})
}),
},
);
self
}
pub fn ids(&self) -> impl Iterator<Item = &ToolId> {
self.tools.keys()
}
#[cfg(feature = "manifest")]
pub fn check_against(&self, manifest: &crate::manifest::Manifest) -> Result<(), Vec<String>> {
let granted: BTreeMap<ToolId, &crate::manifest::ToolGrant> = manifest
.spec
.tools
.iter()
.filter_map(|g| ToolId::parse(&g.reference).map(|id| (id, g)))
.collect();
let mut problems = Vec::new();
for (id, registered) in &self.tools {
if registered.mutates && granted.get(id).is_some_and(|g| !g.mutates) {
problems.push(format!(
"'{id}' declares that it mutates and the manifest grants it as \
read-only — that exemption lets model-chosen arguments reach \
something that changes the world"
));
}
if let Some(grant) = granted.get(id)
&& grant.arguments.is_some()
{
problems.push(format!(
"'{id}' repeats its argument schema in the manifest — typed tools \
derive it from the Rust argument type, so the second copy can only \
drift; remove `arguments` from this grant"
));
}
}
for id in self.tools.keys() {
if !granted.contains_key(id) {
problems.push(format!(
"'{id}' is implemented but the manifest grants no such tool — \
this binary can do something its declaration does not admit"
));
}
}
for id in granted.keys() {
if !self.tools.contains_key(id) {
problems.push(format!(
"'{id}' is granted but nothing implements it — the model will \
be offered a tool that fails when chosen"
));
}
}
if problems.is_empty() {
Ok(())
} else {
Err(problems)
}
}
#[must_use]
pub fn declared(&self, id: &ToolId) -> Option<(&str, &Value, bool)> {
self.tools
.get(id)
.map(|t| (t.description.as_str(), &t.schema, t.mutates))
}
}
#[async_trait]
impl ToolClient for ToolBox {
async fn call(
&self,
tool: &ToolId,
arguments: &Value,
_provenance: Option<&crate::core::Provenance>,
) -> Result<Value, ToolError> {
let Some(registered) = self.tools.get(tool) else {
return Err(ToolError::Refused {
tool: tool.clone(),
detail: "this box offers no such tool".to_owned(),
});
};
(registered.invoke)(arguments.clone()).await
}
}