use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ToolError {
#[error("tool not found: {0}")]
ToolNotFound(String),
#[error("invalid input for tool: {0}")]
InvalidInput(String),
#[error("tool execution error: {0}")]
ExecutionError(String),
}
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolProvenance {
#[default]
Native,
McpRemote { server: String },
}
#[derive(Debug, Clone)]
pub struct ToolResult {
pub content: String,
pub is_error: bool,
}
impl ToolResult {
pub fn success(content: impl Into<String>) -> Self {
Self {
content: content.into(),
is_error: false,
}
}
pub fn error(content: impl Into<String>) -> Self {
Self {
content: content.into(),
is_error: true,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum ToolNature {
#[default]
Read,
Write,
Execute,
Network,
}
#[derive(
Debug,
Clone,
Copy,
Default,
PartialEq,
Eq,
Hash,
PartialOrd,
Ord,
Serialize,
Deserialize,
JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum ToolFamily {
#[default]
File,
Search,
CodeIntelligence,
Git,
Network,
Shell,
Extension,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ToolPresentationPolicy {
pub include_all: bool,
pub include_always_on: bool,
#[serde(default)]
pub families: Vec<ToolFamily>,
}
impl ToolPresentationPolicy {
#[must_use]
pub fn full() -> Self {
Self {
include_all: true,
include_always_on: true,
families: Vec::new(),
}
}
#[must_use]
pub fn always_on() -> Self {
Self {
include_all: false,
include_always_on: true,
families: Vec::new(),
}
}
#[must_use]
pub fn with_families(families: impl IntoIterator<Item = ToolFamily>) -> Self {
Self {
include_all: false,
include_always_on: true,
families: families.into_iter().collect(),
}
}
#[must_use]
pub fn allows_tool(&self, tool: &dyn AgentTool) -> bool {
self.include_all
|| (self.include_always_on && tool.is_always_on())
|| self.families.contains(&tool.family())
}
#[must_use]
pub fn family_set(&self) -> HashSet<ToolFamily> {
self.families.iter().copied().collect()
}
}
impl Default for ToolPresentationPolicy {
fn default() -> Self {
Self::full()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum ToolResourceKind {
Path,
Domain,
Command,
Remote,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ToolPermissionFacet {
pub nature: ToolNature,
#[serde(default)]
pub resource: Option<String>,
#[serde(default)]
pub resource_kind: Option<ToolResourceKind>,
#[serde(default)]
pub description: Option<String>,
}
impl ToolPermissionFacet {
pub fn new(nature: ToolNature) -> Self {
Self {
nature,
resource: None,
resource_kind: None,
description: None,
}
}
pub fn with_resource(
nature: ToolNature,
resource: impl Into<String>,
resource_kind: ToolResourceKind,
) -> Self {
Self {
nature,
resource: Some(resource.into()),
resource_kind: Some(resource_kind),
description: None,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
}
#[async_trait]
pub trait AgentTool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters(&self) -> Value;
async fn execute(&self, input: Value) -> ToolResult;
fn is_read_only(&self) -> bool {
false
}
fn nature(&self) -> ToolNature {
if self.is_read_only() {
ToolNature::Read
} else {
ToolNature::Write
}
}
fn family(&self) -> ToolFamily {
ToolFamily::Extension
}
fn is_always_on(&self) -> bool {
false
}
fn permission_profile(&self, _input: &Value) -> Vec<ToolPermissionFacet> {
vec![ToolPermissionFacet::new(self.nature())]
}
fn summary_fields(&self) -> &'static [&'static str] {
&[]
}
fn provenance(&self) -> ToolProvenance {
ToolProvenance::Native
}
}
#[derive(Default)]
pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn AgentTool>>,
}
impl ToolRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, tool: Arc<dyn AgentTool>) {
self.tools.insert(tool.name().to_owned(), tool);
}
pub fn get(&self, name: &str) -> Option<&dyn AgentTool> {
self.tools.get(name).map(|t| t.as_ref())
}
pub fn list(&self) -> Vec<&dyn AgentTool> {
self.tools.values().map(|t| t.as_ref()).collect()
}
pub fn validate_input(&self, name: &str, input: &Value) -> Result<(), ToolError> {
let tool = self
.get(name)
.ok_or_else(|| ToolError::ToolNotFound(name.to_owned()))?;
let params = tool.parameters();
if !input.is_object() {
return Err(ToolError::InvalidInput(format!(
"expected object for tool '{name}', got {}",
input_type_name(input)
)));
}
if let Some(schema_obj) = params.as_object()
&& let Some(Value::Array(required)) = schema_obj.get("required")
&& let Some(input_obj) = input.as_object()
{
for req in required {
if let Some(req_key) = req.as_str()
&& !input_obj.contains_key(req_key)
{
return Err(ToolError::InvalidInput(format!(
"missing required field '{req_key}' for tool '{name}'"
)));
}
}
}
Ok(())
}
}
fn input_type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
#[macro_export]
macro_rules! tool_parameters {
($type:ty) => {{
let schema = schemars::schema_for!($type);
serde_json::to_value(schema).unwrap_or(serde_json::Value::Object(Default::default()))
}};
}
#[cfg(test)]
#[allow(warnings)]
#[allow(warnings)]
#[allow(warnings)]
#[allow(warnings)]
mod tests {
use super::*;
use schemars::JsonSchema;
use serde::Deserialize;
struct MockTool {
tool_name: String,
tool_description: String,
read_only: bool,
family: ToolFamily,
always_on: bool,
}
impl MockTool {
fn new(name: &str, description: &str) -> Self {
Self {
tool_name: name.to_owned(),
tool_description: description.to_owned(),
read_only: true,
family: ToolFamily::Extension,
always_on: false,
}
}
fn with_family(mut self, family: ToolFamily) -> Self {
self.family = family;
self
}
fn always_on(mut self) -> Self {
self.always_on = true;
self
}
}
#[async_trait]
impl AgentTool for MockTool {
fn name(&self) -> &str {
&self.tool_name
}
fn description(&self) -> &str {
&self.tool_description
}
fn parameters(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "A message to echo"
}
},
"required": ["message"]
})
}
async fn execute(&self, input: Value) -> ToolResult {
if let Some(msg) = input.get("message").and_then(Value::as_str) {
ToolResult::success(format!("echo: {msg}"))
} else {
ToolResult::error("missing 'message' field".to_owned())
}
}
fn is_read_only(&self) -> bool {
self.read_only
}
fn family(&self) -> ToolFamily {
self.family
}
fn is_always_on(&self) -> bool {
self.always_on
}
}
#[derive(JsonSchema, Deserialize)]
#[allow(dead_code)]
struct GreetParams {
name: String,
#[serde(default)]
formal: bool,
}
#[allow(dead_code)]
struct TypedMockTool;
#[async_trait]
impl AgentTool for TypedMockTool {
fn name(&self) -> &str {
"greet"
}
fn description(&self) -> &str {
"Greet someone by name"
}
fn parameters(&self) -> Value {
tool_parameters!(GreetParams)
}
async fn execute(&self, input: Value) -> ToolResult {
let name = input.get("name").and_then(Value::as_str).unwrap_or("World");
ToolResult::success(format!("Hello, {name}!"))
}
}
#[test]
fn test_register_and_get_tool() {
let mut registry = ToolRegistry::new();
let tool = Arc::new(MockTool::new("echo", "Echoes a message"));
registry.register(tool);
let retrieved = registry.get("echo");
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().name(), "echo");
}
#[test]
fn test_tool_not_found() {
let registry = ToolRegistry::new();
assert!(registry.get("nonexistent").is_none());
let result = registry.validate_input("nonexistent", &serde_json::json!({}));
assert!(matches!(result, Err(ToolError::ToolNotFound(_))));
}
#[test]
fn test_list_tools() {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(MockTool::new("echo", "Echoes a message")));
registry.register(Arc::new(MockTool::new("reverse", "Reverses a string")));
let tools = registry.list();
assert_eq!(tools.len(), 2);
}
#[test]
fn test_validate_input_valid() {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(MockTool::new("echo", "Echoes a message")));
let input = serde_json::json!({ "message": "hello" });
assert!(registry.validate_input("echo", &input).is_ok());
}
#[test]
fn test_validate_input_missing_required() {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(MockTool::new("echo", "Echoes a message")));
let input = serde_json::json!({});
let result = registry.validate_input("echo", &input);
assert!(matches!(result, Err(ToolError::InvalidInput(_))));
assert!(
result
.unwrap_err()
.to_string()
.contains("missing required field 'message'")
);
}
#[test]
fn test_validate_input_not_object() {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(MockTool::new("echo", "Echoes a message")));
let input = serde_json::json!("not an object");
let result = registry.validate_input("echo", &input);
assert!(matches!(result, Err(ToolError::InvalidInput(_))));
}
#[tokio::test]
async fn test_tool_execute() {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(MockTool::new("echo", "Echoes a message")));
let tool = registry.get("echo").unwrap();
let result = tool
.execute(serde_json::json!({ "message": "hello" }))
.await;
assert!(!result.is_error);
assert_eq!(result.content, "echo: hello");
}
#[tokio::test]
async fn test_tool_execute_error() {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(MockTool::new("echo", "Echoes a message")));
let tool = registry.get("echo").unwrap();
let result = tool.execute(serde_json::json!({})).await;
assert!(result.is_error);
}
#[test]
fn test_tool_is_read_only() {
let tool = MockTool::new("echo", "Echoes a message");
assert!(tool.is_read_only());
}
#[test]
fn test_tool_parameters_macro() {
let schema = tool_parameters!(GreetParams);
assert!(schema.is_object());
let obj = schema.as_object().unwrap();
assert!(obj.contains_key("properties"));
}
#[test]
fn test_register_replaces_existing() {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(MockTool::new("echo", "Original")));
registry.register(Arc::new(MockTool::new("echo", "Replacement")));
let tool = registry.get("echo").unwrap();
assert_eq!(tool.description(), "Replacement");
}
#[test]
fn test_tool_result_helpers() {
let success = ToolResult::success("ok");
assert!(!success.is_error);
assert_eq!(success.content, "ok");
let error = ToolResult::error("failed");
assert!(error.is_error);
assert_eq!(error.content, "failed");
}
#[test]
fn test_tool_presentation_policy_selects_always_on_baseline() {
let baseline = MockTool::new("read", "Read file").always_on();
let shell = MockTool::new("bash", "Run command").with_family(ToolFamily::Shell);
let policy = ToolPresentationPolicy::always_on();
assert!(policy.allows_tool(&baseline));
assert!(!policy.allows_tool(&shell));
}
#[test]
fn test_tool_presentation_policy_selects_explicit_family() {
let git = MockTool::new("git_status", "Git status").with_family(ToolFamily::Git);
let network = MockTool::new("web_search", "Search web").with_family(ToolFamily::Network);
let policy = ToolPresentationPolicy::with_families([ToolFamily::Git]);
assert!(policy.allows_tool(&git));
assert!(!policy.allows_tool(&network));
assert!(policy.family_set().contains(&ToolFamily::Git));
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ToolProtocol {
#[default]
Native,
TalosStrict,
Compat,
}
impl ToolProtocol {
pub fn parse(s: &str) -> Option<Self> {
match s {
"native" => Some(ToolProtocol::Native),
"talos-strict" | "talos_xml_json_strict" => Some(ToolProtocol::TalosStrict),
"compat" | "compatibility" => Some(ToolProtocol::Compat),
_ => None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ToolProtocolConfig {
pub protocol: ToolProtocol,
pub strict_prompt: bool,
pub stream_filter: bool,
pub schema_validate: bool,
}
impl ToolProtocolConfig {
pub fn for_protocol(protocol: ToolProtocol) -> Self {
match protocol {
ToolProtocol::Native => ToolProtocolConfig {
protocol,
strict_prompt: false,
stream_filter: false,
schema_validate: false,
},
ToolProtocol::TalosStrict => ToolProtocolConfig {
protocol,
strict_prompt: true,
stream_filter: true,
schema_validate: true,
},
ToolProtocol::Compat => ToolProtocolConfig {
protocol,
strict_prompt: false,
stream_filter: true,
schema_validate: false,
},
}
}
}