spec_ai/spec_ai_core/tools/
mod.rs1pub mod builtin;
2pub mod mcp;
3pub mod plugin_adapter;
4
5use anyhow::Result;
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashMap;
10use std::sync::{Arc, Mutex};
11use tracing::debug;
12
13use self::builtin::{
14 ActivateSkillTool, AudioTranscriptionTool, BashTool, CodeSearchTool, EchoTool, FileExtractTool,
15 FileReadTool, FileWriteTool, GenerateCodeTool, GraphTool, GrepTool, MathTool, PromptUserTool,
16 RgTool, SearchTool, ShellTool,
17};
18
19#[cfg(feature = "api")]
20use self::builtin::WebSearchTool;
21
22#[cfg(feature = "web-scraping")]
23use self::builtin::WebScraperTool;
24use crate::spec_ai_core::agent::model::ModelProvider;
25use crate::spec_ai_core::agent::safety::RunSafetyBudget;
26use crate::spec_ai_core::embeddings::EmbeddingsClient;
27use crate::spec_ai_core::persistence::Persistence;
28use crate::spec_ai_config::config::McpConfig;
29use std::path::PathBuf;
30
31pub use plugin_adapter::PluginToolAdapter;
32
33#[cfg(feature = "openai")]
34use async_openai::types::chat::ChatCompletionTool;
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct ToolResult {
39 pub success: bool,
41 pub output: String,
43 pub error: Option<String>,
45}
46
47#[derive(Clone, Default)]
49pub struct ToolExecutionContext {
50 pub safety: Option<RunSafetyBudget>,
51 pub delegation_depth: usize,
52}
53
54impl ToolResult {
55 pub fn success(output: impl Into<String>) -> Self {
57 Self {
58 success: true,
59 output: output.into(),
60 error: None,
61 }
62 }
63
64 pub fn failure(error: impl Into<String>) -> Self {
66 Self {
67 success: false,
68 output: String::new(),
69 error: Some(error.into()),
70 }
71 }
72}
73
74#[async_trait]
76pub trait Tool: Send + Sync {
77 fn name(&self) -> &str;
79
80 fn description(&self) -> &str;
82
83 fn parameters(&self) -> Value;
85
86 async fn execute(&self, args: Value) -> Result<ToolResult>;
88
89 async fn execute_with_context(
91 &self,
92 args: Value,
93 _context: ToolExecutionContext,
94 ) -> Result<ToolResult> {
95 self.execute(args).await
96 }
97}
98
99pub struct ToolRegistry {
101 tools: Mutex<HashMap<String, Arc<dyn Tool>>>,
102}
103
104impl ToolRegistry {
105 pub fn new() -> Self {
107 Self {
108 tools: Mutex::new(HashMap::new()),
109 }
110 }
111
112 #[allow(unused_variables)]
117 pub fn with_builtin_tools(
118 persistence: Option<Arc<Persistence>>,
119 embeddings: Option<EmbeddingsClient>,
120 code_model_provider: Option<Arc<dyn ModelProvider>>,
121 skills_dirs: Vec<PathBuf>,
122 ) -> Self {
123 let registry = Self::new();
124
125 registry.register(Arc::new(EchoTool::new()));
127 registry.register(Arc::new(MathTool::new()));
128 registry.register(Arc::new(FileReadTool::new()));
129 registry.register(Arc::new(FileExtractTool::new()));
130 registry.register(Arc::new(FileWriteTool::new()));
131 registry.register(Arc::new(PromptUserTool::new()));
132 registry.register(Arc::new(SearchTool::new()));
133 registry.register(Arc::new(GrepTool::new()));
134 registry.register(Arc::new(RgTool::new()));
135 registry.register(Arc::new(CodeSearchTool::new()));
136 registry.register(Arc::new(BashTool::new()));
137 registry.register(Arc::new(ShellTool::new()));
138 if !skills_dirs.is_empty() {
139 registry.register(Arc::new(ActivateSkillTool::new(skills_dirs)));
140 }
141 if let Some(provider) = code_model_provider {
142 registry.register(Arc::new(GenerateCodeTool::new(provider)));
143 }
144
145 #[cfg(feature = "api")]
147 registry.register(Arc::new(WebSearchTool::new().with_embeddings(embeddings)));
148
149 #[cfg(feature = "web-scraping")]
151 registry.register(Arc::new(WebScraperTool::new()));
152
153 if let Some(persistence) = persistence {
154 registry.register(Arc::new(GraphTool::new(persistence.clone())));
155 registry.register(Arc::new(AudioTranscriptionTool::with_persistence(
156 persistence,
157 )));
158 } else {
159 registry.register(Arc::new(AudioTranscriptionTool::new()));
160 }
161
162 tracing::debug!("ToolRegistry created with {} tools", registry.len());
163 for name in registry.list() {
164 tracing::debug!(" - Tool: {}", name);
165 }
166
167 registry
168 }
169
170 pub fn register(&self, tool: Arc<dyn Tool>) {
172 let name = tool.name().to_string();
173 self.tools.lock().unwrap().insert(name, tool);
174 }
175
176 pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
178 self.tools.lock().unwrap().get(name).cloned()
179 }
180
181 pub fn list(&self) -> Vec<String> {
183 self.tools
184 .lock()
185 .unwrap()
186 .keys()
187 .map(|s| s.to_string())
188 .collect()
189 }
190
191 pub fn has(&self, name: &str) -> bool {
193 self.tools.lock().unwrap().contains_key(name)
194 }
195
196 pub async fn execute(&self, name: &str, args: Value) -> Result<ToolResult> {
198 self.execute_with_context(name, args, ToolExecutionContext::default())
199 .await
200 }
201
202 pub async fn execute_with_context(
204 &self,
205 name: &str,
206 args: Value,
207 context: ToolExecutionContext,
208 ) -> Result<ToolResult> {
209 let tool = self
210 .get(name)
211 .ok_or_else(|| anyhow::anyhow!("Tool not found: {}", name))?;
212
213 debug!("Executing tool '{}'", name);
214 let result = tool.execute_with_context(args, context).await;
215 match &result {
216 Ok(res) => {
217 debug!(
218 "Tool '{}' completed: success={}, error={:?}",
219 name, res.success, res.error
220 );
221 }
222 Err(err) => {
223 debug!("Tool '{}' failed to execute: {}", name, err);
224 }
225 }
226 result
227 }
228
229 pub fn len(&self) -> usize {
231 self.tools.lock().unwrap().len()
232 }
233
234 pub fn is_empty(&self) -> bool {
236 self.tools.lock().unwrap().is_empty()
237 }
238
239 pub fn load_plugins(
248 &mut self,
249 dir: &std::path::Path,
250 allow_override: bool,
251 ) -> anyhow::Result<crate::spec_ai_plugin::LoadStats> {
252 use crate::spec_ai_plugin::{PluginLoader, expand_tilde};
253
254 let expanded_dir = expand_tilde(dir);
255
256 let mut loader = PluginLoader::new();
257 let stats = loader.load_directory(&expanded_dir)?;
258
259 for (tool_ref, plugin_name) in loader.all_tools() {
261 let adapter = match PluginToolAdapter::new(tool_ref, plugin_name) {
262 Ok(a) => a,
263 Err(e) => {
264 tracing::warn!(
265 "Failed to create adapter for tool from {}: {}",
266 plugin_name,
267 e
268 );
269 continue;
270 }
271 };
272
273 let tool_name = adapter.name().to_string();
274
275 if self.has(&tool_name) {
277 if allow_override {
278 tracing::info!(
279 "Plugin tool '{}' from '{}' overriding built-in tool",
280 tool_name,
281 plugin_name
282 );
283 } else {
284 tracing::warn!(
285 "Plugin tool '{}' from '{}' would override built-in, skipping (set allow_override_builtin=true to allow)",
286 tool_name,
287 plugin_name
288 );
289 continue;
290 }
291 }
292
293 tracing::debug!(
294 "Registering plugin tool '{}' from '{}'",
295 tool_name,
296 plugin_name
297 );
298 self.register(Arc::new(adapter));
299 }
300
301 Ok(stats)
302 }
303
304 pub async fn load_mcp_servers(&self, config: &McpConfig) -> anyhow::Result<()> {
306 if !config.enabled {
307 return Ok(());
308 }
309
310 let mut manager = crate::spec_ai_core::tools::mcp::McpManager::new();
311 for (name, server_config) in &config.servers {
312 if let Err(e) = manager
313 .connect_stdio(
314 &server_config.command,
315 &server_config.args,
316 &server_config.env,
317 )
318 .await
319 {
320 tracing::error!("Failed to connect to MCP server '{}': {}", name, e);
321 }
322 }
323
324 for adapter in manager.list_tools().await {
325 tracing::info!("Registering MCP tool: {}", adapter.name());
326 self.register(Arc::new(adapter));
327 }
328
329 Ok(())
330 }
331
332 #[cfg(any(feature = "openai", feature = "mlx", feature = "lmstudio"))]
337 pub fn to_openai_tools(&self) -> Vec<ChatCompletionTool> {
338 use crate::spec_ai_core::agent::function_calling::tool_to_openai_function;
339
340 let tools = self.tools.lock().unwrap();
341 tools
342 .values()
343 .map(|tool| {
344 tool_to_openai_function(tool.name(), tool.description(), &tool.parameters())
345 })
346 .collect()
347 }
348}
349
350impl Default for ToolRegistry {
351 fn default() -> Self {
352 Self::new()
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 struct DummyTool;
361
362 #[async_trait]
363 impl Tool for DummyTool {
364 fn name(&self) -> &str {
365 "dummy"
366 }
367
368 fn description(&self) -> &str {
369 "A dummy tool for testing"
370 }
371
372 fn parameters(&self) -> Value {
373 serde_json::json!({
374 "type": "object",
375 "properties": {}
376 })
377 }
378
379 async fn execute(&self, _args: Value) -> Result<ToolResult> {
380 Ok(ToolResult::success("dummy output"))
381 }
382 }
383
384 #[tokio::test]
385 async fn test_register_and_get_tool() {
386 let registry = ToolRegistry::new();
387 let tool = Arc::new(DummyTool);
388
389 registry.register(tool.clone());
390
391 assert!(registry.has("dummy"));
392 assert!(registry.get("dummy").is_some());
393 assert_eq!(registry.len(), 1);
394 }
395
396 #[tokio::test]
397 async fn test_list_tools() {
398 let registry = ToolRegistry::new();
399 registry.register(Arc::new(DummyTool));
400
401 let tools = registry.list();
402 assert_eq!(tools.len(), 1);
403 assert!(tools.contains(&"dummy".to_string()));
404 }
405
406 #[tokio::test]
407 async fn test_execute_tool() {
408 let registry = ToolRegistry::new();
409 registry.register(Arc::new(DummyTool));
410 let result = registry.execute("dummy", Value::Null).await.unwrap();
411 assert!(result.success);
412 assert_eq!(result.output, "dummy output");
413 }
414
415 #[tokio::test]
416 async fn test_execute_nonexistent_tool() {
417 let registry = ToolRegistry::new();
418 let result = registry.execute("nonexistent", Value::Null).await;
419 assert!(result.is_err());
420 }
421
422 #[tokio::test]
423 async fn test_tool_result_success() {
424 let result = ToolResult::success("test output");
425 assert!(result.success);
426 assert_eq!(result.output, "test output");
427 assert!(result.error.is_none());
428 }
429
430 #[tokio::test]
431 async fn test_tool_result_failure() {
432 let result = ToolResult::failure("test error");
433 assert!(!result.success);
434 assert_eq!(result.error, Some("test error".to_string()));
435 }
436}