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}