Skip to main content

apollo/tools/
dynamic.rs

1//! Dynamic tool system — AI can create, list, and execute custom tools at runtime.
2//!
3//! Tools are stored in ~/.apollo/tools/<name>/
4//!   spec.json  — tool definition (name, description, parameters)
5//!   run.v      — V language implementation (preferred, fast compile)
6//!   run.py     — Python fallback
7//!   run.sh     — Shell fallback
8//!
9//! The AI uses `create_tool` to write new tools, which are immediately available.
10
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16use super::traits::*;
17use crate::config::PolicyConfig;
18use crate::policy::ExecutionPolicy;
19use crate::text::truncate_chars;
20
21/// Directory where dynamic tools live
22fn tools_dir() -> PathBuf {
23    let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
24    PathBuf::from(home).join(".apollo/tools")
25}
26
27/// A dynamic tool loaded from disk
28pub struct DynamicTool {
29    pub name: String,
30    pub description: String,
31    pub parameters: serde_json::Value,
32    pub tool_dir: PathBuf,
33    pub language: String, // "v", "python", "shell"
34    policy: Arc<ExecutionPolicy>,
35}
36
37#[derive(Serialize, Deserialize)]
38struct DynamicToolSpec {
39    name: String,
40    description: String,
41    parameters: serde_json::Value,
42    language: Option<String>,
43}
44
45impl DynamicTool {
46    /// Load a dynamic tool from its directory
47    pub fn load(dir: &Path, policy: Arc<ExecutionPolicy>) -> Option<Self> {
48        let spec_path = dir.join("spec.json");
49        let spec_str = std::fs::read_to_string(&spec_path).ok()?;
50        let spec: DynamicToolSpec = serde_json::from_str(&spec_str).ok()?;
51
52        // Determine language from what exists
53        let language = if dir.join("run.v").exists() {
54            "v".to_string()
55        } else if dir.join("run.py").exists() {
56            "python".to_string()
57        } else if dir.join("run.sh").exists() {
58            "shell".to_string()
59        } else {
60            return None;
61        };
62
63        Some(Self {
64            name: spec.name,
65            description: spec.description,
66            parameters: spec.parameters,
67            tool_dir: dir.to_path_buf(),
68            language,
69            policy,
70        })
71    }
72
73    /// Load all dynamic tools from the tools directory
74    pub fn load_all(policy: Arc<ExecutionPolicy>) -> Vec<Self> {
75        let dir = tools_dir();
76        if !dir.exists() {
77            return Vec::new();
78        }
79
80        let mut tools = Vec::new();
81        if let Ok(entries) = std::fs::read_dir(&dir) {
82            for entry in entries.flatten() {
83                if entry.path().is_dir() {
84                    if let Some(tool) = Self::load(&entry.path(), Arc::clone(&policy)) {
85                        tools.push(tool);
86                    }
87                }
88            }
89        }
90        tools
91    }
92}
93
94#[async_trait]
95impl Tool for DynamicTool {
96    fn name(&self) -> &str {
97        &self.name
98    }
99
100    fn spec(&self) -> ToolSpec {
101        ToolSpec {
102            name: self.name.clone(),
103            description: self.description.clone(),
104            parameters: self.parameters.clone(),
105        }
106    }
107
108    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
109        if !self.policy.allow_dynamic_tools {
110            return ExecutionPolicy::deny("Dynamic tool execution is disabled by policy");
111        }
112
113        let run_file = match self.language.as_str() {
114            "v" => "run.v",
115            "python" => "run.py",
116            "shell" => "run.sh",
117            _ => return Ok(ToolResult::error("Unknown tool language")),
118        };
119
120        let run_path = self.tool_dir.join(run_file);
121
122        // Build command based on language
123        let output = match self.language.as_str() {
124            "v" => {
125                // V: compile and run (fast — ~0.3s compile)
126                tokio::process::Command::new("v")
127                    .arg("run")
128                    .arg(&run_path)
129                    .arg(arguments)
130                    .current_dir(&self.tool_dir)
131                    .stdout(std::process::Stdio::piped())
132                    .stderr(std::process::Stdio::piped())
133                    .output()
134                    .await?
135            }
136            "python" => {
137                tokio::process::Command::new("python3")
138                    .arg(&run_path)
139                    .arg(arguments)
140                    .current_dir(&self.tool_dir)
141                    .stdout(std::process::Stdio::piped())
142                    .stderr(std::process::Stdio::piped())
143                    .output()
144                    .await?
145            }
146            "shell" => {
147                tokio::process::Command::new("bash")
148                    .arg(&run_path)
149                    .arg(arguments)
150                    .current_dir(&self.tool_dir)
151                    .stdout(std::process::Stdio::piped())
152                    .stderr(std::process::Stdio::piped())
153                    .output()
154                    .await?
155            }
156            _ => return Ok(ToolResult::error("Unknown language")),
157        };
158
159        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
160        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
161
162        let result = if stdout.is_empty() && !stderr.is_empty() {
163            stderr
164        } else if !stderr.is_empty() {
165            format!("{}\n{}", stdout, stderr)
166        } else {
167            stdout
168        };
169
170        // Truncate
171        let truncated = if result.len() > 20_000 {
172            format!("{}...\n[truncated]", truncate_chars(&result, 20_000))
173        } else {
174            result
175        };
176
177        Ok(if output.status.success() {
178            ToolResult::success(truncated)
179        } else {
180            ToolResult::error(format!(
181                "Exit {}: {}",
182                output.status.code().unwrap_or(-1),
183                truncated
184            ))
185        })
186    }
187}
188
189// ============================================================
190// create_tool — meta-tool for the AI to create new tools
191// ============================================================
192
193pub struct CreateToolTool {
194    policy: Arc<ExecutionPolicy>,
195}
196
197impl CreateToolTool {
198    pub fn new(policy: Arc<ExecutionPolicy>) -> Self {
199        Self { policy }
200    }
201}
202
203#[derive(Deserialize)]
204struct CreateToolArgs {
205    /// Tool name (lowercase, no spaces)
206    name: String,
207    /// Tool description
208    description: String,
209    /// JSON Schema for parameters
210    parameters: serde_json::Value,
211    /// Source code for the tool
212    code: String,
213    /// Language: "v" (default), "python", "shell"
214    language: Option<String>,
215}
216
217#[async_trait]
218impl Tool for CreateToolTool {
219    fn name(&self) -> &str {
220        "create_tool"
221    }
222
223    fn spec(&self) -> ToolSpec {
224        ToolSpec {
225            name: "create_tool".to_string(),
226            description: "Create a new custom tool. The tool becomes immediately available. Write the implementation in V (preferred), Python, or shell. The code receives arguments as a JSON string via argv[1] (V/Python) or $1 (shell).".to_string(),
227            parameters: serde_json::json!({
228                "type": "object",
229                "properties": {
230                    "name": {
231                        "type": "string",
232                        "description": "Tool name (lowercase, alphanumeric + underscores)"
233                    },
234                    "description": {
235                        "type": "string",
236                        "description": "What the tool does"
237                    },
238                    "parameters": {
239                        "type": "object",
240                        "description": "JSON Schema for the tool's input parameters"
241                    },
242                    "code": {
243                        "type": "string",
244                        "description": "Source code for the tool implementation"
245                    },
246                    "language": {
247                        "type": "string",
248                        "enum": ["v", "python", "shell"],
249                        "description": "Implementation language (default: v)"
250                    }
251                },
252                "required": ["name", "description", "parameters", "code"]
253            }),
254        }
255    }
256
257    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
258        if !self.policy.allow_dynamic_tools {
259            return ExecutionPolicy::deny("Dynamic tool creation is disabled by policy");
260        }
261
262        let args: CreateToolArgs = serde_json::from_str(arguments)?;
263
264        // Validate name
265        if !args.name.chars().all(|c| c.is_alphanumeric() || c == '_') {
266            return Ok(ToolResult::error(
267                "Tool name must be alphanumeric + underscores only",
268            ));
269        }
270
271        let language = args.language.unwrap_or_else(|| "v".to_string());
272        let tool_dir = tools_dir().join(&args.name);
273
274        // Create directory
275        std::fs::create_dir_all(&tool_dir)?;
276
277        // Write spec.json
278        let spec = DynamicToolSpec {
279            name: args.name.clone(),
280            description: args.description.clone(),
281            parameters: args.parameters,
282            language: Some(language.clone()),
283        };
284        std::fs::write(
285            tool_dir.join("spec.json"),
286            serde_json::to_string_pretty(&spec)?,
287        )?;
288
289        // Write implementation
290        let filename = match language.as_str() {
291            "v" => "run.v",
292            "python" => "run.py",
293            "shell" => "run.sh",
294            _ => {
295                return Ok(ToolResult::error(
296                    "Unsupported language. Use: v, python, shell",
297                ))
298            }
299        };
300
301        std::fs::write(tool_dir.join(filename), &args.code)?;
302
303        // Make shell scripts executable
304        if language == "shell" {
305            #[cfg(unix)]
306            {
307                use std::os::unix::fs::PermissionsExt;
308                let perms = std::fs::Permissions::from_mode(0o755);
309                std::fs::set_permissions(tool_dir.join(filename), perms)?;
310            }
311        }
312
313        // Verify the tool can be loaded
314        match DynamicTool::load(&tool_dir, Arc::clone(&self.policy)) {
315            Some(t) => Ok(ToolResult::success(format!(
316                "✅ Tool '{}' created successfully!\n\
317                Language: {}\n\
318                Location: {}\n\
319                Parameters: {}\n\n\
320                Note: The tool is saved but requires a bot restart to be available in the current session. \
321                It will be auto-loaded on next startup.",
322                t.name, language, tool_dir.display(), serde_json::to_string_pretty(&t.parameters)?
323            ))),
324            None => Ok(ToolResult::error(format!(
325                "Tool created but failed to load. Check {} in {}",
326                filename,
327                tool_dir.display()
328            ))),
329        }
330    }
331}
332
333// ============================================================
334// list_custom_tools — see what tools have been created
335// ============================================================
336
337pub struct ListCustomToolsTool;
338
339impl ListCustomToolsTool {
340    pub fn new() -> Self {
341        Self
342    }
343}
344
345impl Default for ListCustomToolsTool {
346    fn default() -> Self {
347        Self::new()
348    }
349}
350
351#[async_trait]
352impl Tool for ListCustomToolsTool {
353    fn name(&self) -> &str {
354        "list_custom_tools"
355    }
356
357    fn spec(&self) -> ToolSpec {
358        ToolSpec {
359            name: "list_custom_tools".to_string(),
360            description: "List all custom tools created by the AI.".to_string(),
361            parameters: serde_json::json!({
362                "type": "object",
363                "properties": {}
364            }),
365        }
366    }
367
368    async fn execute(&self, _arguments: &str) -> anyhow::Result<ToolResult> {
369        let tools = DynamicTool::load_all(Arc::new(ExecutionPolicy::from_config(
370            &PolicyConfig::default(),
371        )));
372        if tools.is_empty() {
373            return Ok(ToolResult::success(
374                "No custom tools created yet.\n\
375                Use create_tool to make one!\n\n\
376                Example: create a V tool that fetches weather, a Python data processor, etc.",
377            ));
378        }
379
380        let mut output = format!("Custom tools ({}):\n\n", tools.len());
381        for t in &tools {
382            output.push_str(&format!(
383                "• {} ({}) — {}\n  Location: {}\n",
384                t.name,
385                t.language,
386                t.description,
387                t.tool_dir.display()
388            ));
389        }
390        Ok(ToolResult::success(output))
391    }
392}