Skip to main content

synwire_core/tools/
traits.rs

1//! Tool trait, name validation, and provider abstraction.
2
3use std::sync::Arc;
4
5use crate::BoxFuture;
6use crate::error::{SynwireError, ToolError};
7use crate::tools::types::{ToolOutput, ToolSchema};
8
9/// Trait for callable tools.
10///
11/// Implementations must be `Send + Sync` so tools can be shared across
12/// async tasks and threads.
13///
14/// # Cancel safety
15///
16/// The future returned by [`invoke`](Self::invoke) is **not cancel-safe**
17/// in general. If the tool performs side effects (file writes, API calls),
18/// dropping the future mid-execution may leave those effects partially
19/// applied. Callers should avoid dropping tool futures unless they are
20/// prepared to retry or roll back.
21///
22/// # Example
23///
24/// ```
25/// use synwire_core::tools::{Tool, ToolOutput, ToolSchema};
26/// use synwire_core::error::SynwireError;
27/// use synwire_core::BoxFuture;
28///
29/// struct Echo;
30///
31/// impl Tool for Echo {
32///     fn name(&self) -> &str { "echo" }
33///     fn description(&self) -> &str { "Echoes input back" }
34///     fn schema(&self) -> &ToolSchema {
35///         // In real code, store this in a field
36///         Box::leak(Box::new(ToolSchema {
37///             name: "echo".into(),
38///             description: "Echoes input back".into(),
39///             parameters: serde_json::json!({"type": "object"}),
40///         }))
41///     }
42///     fn invoke(
43///         &self,
44///         input: serde_json::Value,
45///     ) -> BoxFuture<'_, Result<ToolOutput, SynwireError>> {
46///         Box::pin(async move {
47///             Ok(ToolOutput {
48///                 content: input.to_string(),
49///                 ..Default::default()
50///             })
51///         })
52///     }
53/// }
54/// ```
55pub trait Tool: Send + Sync {
56    /// The tool's name.
57    fn name(&self) -> &str;
58
59    /// The tool's description.
60    fn description(&self) -> &str;
61
62    /// The tool's schema for argument validation.
63    fn schema(&self) -> &ToolSchema;
64
65    /// Invoke the tool with JSON arguments.
66    fn invoke(&self, input: serde_json::Value) -> BoxFuture<'_, Result<ToolOutput, SynwireError>>;
67}
68
69/// Validate a tool name against the pattern `^[a-zA-Z0-9_.\-]{1,64}$`.
70///
71/// Dots are permitted to support namespaced tool names (e.g. `code.search`,
72/// `debug.status`). Leading or trailing dots and consecutive dots are rejected
73/// to prevent ambiguous names.
74///
75/// # Errors
76///
77/// Returns [`SynwireError::Tool`] with [`ToolError::InvalidName`] if the name
78/// is empty, longer than 64 characters, contains disallowed characters, or
79/// has invalid dot placement.
80pub fn validate_tool_name(name: &str) -> Result<(), SynwireError> {
81    if name.is_empty() || name.len() > 64 {
82        return Err(SynwireError::Tool(ToolError::InvalidName {
83            name: name.into(),
84            reason: "name must be 1-64 characters".into(),
85        }));
86    }
87    if !name
88        .chars()
89        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
90    {
91        return Err(SynwireError::Tool(ToolError::InvalidName {
92            name: name.into(),
93            reason: "name must match [a-zA-Z0-9_.\\-]".into(),
94        }));
95    }
96    if name.starts_with('.') || name.ends_with('.') || name.contains("..") {
97        return Err(SynwireError::Tool(ToolError::InvalidName {
98            name: name.into(),
99            reason: "dots must separate non-empty segments".into(),
100        }));
101    }
102    Ok(())
103}
104
105// ---------------------------------------------------------------------------
106// ToolProvider trait
107// ---------------------------------------------------------------------------
108
109/// Abstracts over sources of tools.
110///
111/// Implementations include:
112/// - [`StaticToolProvider`](crate::tools::StaticToolProvider) — wraps a fixed list.
113/// - `CompositeToolProvider` — aggregates multiple providers.
114/// - `McpToolProvider` (in `synwire-mcp-adapters`) — sources tools from MCP servers.
115pub trait ToolProvider: Send + Sync {
116    /// Returns all tools available from this provider.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`SynwireError`] if tool discovery fails (e.g. network error).
121    fn discover_tools(&self) -> BoxFuture<'_, Result<Vec<Arc<dyn Tool>>, SynwireError>>;
122
123    /// Returns a single tool by exact name, or `None` if not found.
124    ///
125    /// # Errors
126    ///
127    /// Returns [`SynwireError`] if the lookup fails.
128    fn get_tool(&self, name: &str) -> BoxFuture<'_, Result<Option<Arc<dyn Tool>>, SynwireError>>;
129}
130
131#[cfg(test)]
132#[allow(clippy::unwrap_used)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn valid_names() {
138        validate_tool_name("search").unwrap();
139        validate_tool_name("my-tool").unwrap();
140        validate_tool_name("tool_123").unwrap();
141        validate_tool_name("A").unwrap();
142        // 64-char name
143        let long_name = "a".repeat(64);
144        validate_tool_name(&long_name).unwrap();
145    }
146
147    #[test]
148    fn rejects_empty_name() {
149        let err = validate_tool_name("").unwrap_err();
150        assert!(err.to_string().contains("1-64 characters"));
151    }
152
153    #[test]
154    fn rejects_too_long_name() {
155        let name = "a".repeat(65);
156        let err = validate_tool_name(&name).unwrap_err();
157        assert!(err.to_string().contains("1-64 characters"));
158    }
159
160    #[test]
161    fn rejects_special_characters() {
162        let err = validate_tool_name("my tool").unwrap_err();
163        assert!(err.to_string().contains("name must match"));
164    }
165
166    #[test]
167    fn accepts_dotted_names() {
168        validate_tool_name("my.tool").unwrap();
169        validate_tool_name("code.search").unwrap();
170        validate_tool_name("debug.status").unwrap();
171        validate_tool_name("a.b.c").unwrap();
172    }
173
174    #[test]
175    fn rejects_leading_dot() {
176        let err = validate_tool_name(".tool").unwrap_err();
177        assert!(err.to_string().contains("dots must separate"));
178    }
179
180    #[test]
181    fn rejects_trailing_dot() {
182        let err = validate_tool_name("tool.").unwrap_err();
183        assert!(err.to_string().contains("dots must separate"));
184    }
185
186    #[test]
187    fn rejects_consecutive_dots() {
188        let err = validate_tool_name("my..tool").unwrap_err();
189        assert!(err.to_string().contains("dots must separate"));
190    }
191}