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_counted;
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 = match truncate_chars_counted(&result, 20_000) {
172            Some((head, dropped)) => format!("{}...\n[truncated {} chars]", head, dropped),
173            None => result,
174        };
175
176        Ok(if output.status.success() {
177            ToolResult::success(truncated)
178        } else {
179            ToolResult::error(format!(
180                "Exit {}: {}",
181                output.status.code().unwrap_or(-1),
182                truncated
183            ))
184        })
185    }
186}
187
188// ============================================================
189// create_tool — meta-tool for the AI to create new tools
190// ============================================================
191
192pub struct CreateToolTool {
193    policy: Arc<ExecutionPolicy>,
194}
195
196impl CreateToolTool {
197    pub fn new(policy: Arc<ExecutionPolicy>) -> Self {
198        Self { policy }
199    }
200}
201
202#[derive(Deserialize)]
203struct CreateToolArgs {
204    /// Tool name (lowercase, no spaces)
205    name: String,
206    /// Tool description
207    description: String,
208    /// JSON Schema for parameters
209    parameters: serde_json::Value,
210    /// Source code for the tool
211    code: String,
212    /// Language: "v" (default), "python", "shell"
213    language: Option<String>,
214}
215
216#[async_trait]
217impl Tool for CreateToolTool {
218    fn name(&self) -> &str {
219        "create_tool"
220    }
221
222    fn spec(&self) -> ToolSpec {
223        ToolSpec {
224            name: "create_tool".to_string(),
225            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(),
226            parameters: serde_json::json!({
227                "type": "object",
228                "properties": {
229                    "name": {
230                        "type": "string",
231                        "description": "Tool name (lowercase, alphanumeric + underscores)"
232                    },
233                    "description": {
234                        "type": "string",
235                        "description": "What the tool does"
236                    },
237                    "parameters": {
238                        "type": "object",
239                        "description": "JSON Schema for the tool's input parameters"
240                    },
241                    "code": {
242                        "type": "string",
243                        "description": "Source code for the tool implementation"
244                    },
245                    "language": {
246                        "type": "string",
247                        "enum": ["v", "python", "shell"],
248                        "description": "Implementation language (default: v)"
249                    }
250                },
251                "required": ["name", "description", "parameters", "code"]
252            }),
253        }
254    }
255
256    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
257        if !self.policy.allow_dynamic_tools {
258            return ExecutionPolicy::deny("Dynamic tool creation is disabled by policy");
259        }
260
261        let args: CreateToolArgs = serde_json::from_str(arguments)?;
262
263        // Validate name
264        if !args.name.chars().all(|c| c.is_alphanumeric() || c == '_') {
265            return Ok(ToolResult::error(
266                "Tool name must be alphanumeric + underscores only",
267            ));
268        }
269
270        let language = args.language.unwrap_or_else(|| "v".to_string());
271        let tool_dir = tools_dir().join(&args.name);
272
273        // Create directory
274        std::fs::create_dir_all(&tool_dir)?;
275
276        // Write spec.json
277        let spec = DynamicToolSpec {
278            name: args.name.clone(),
279            description: args.description.clone(),
280            parameters: args.parameters,
281            language: Some(language.clone()),
282        };
283        std::fs::write(
284            tool_dir.join("spec.json"),
285            serde_json::to_string_pretty(&spec)?,
286        )?;
287
288        // Write implementation
289        let filename = match language.as_str() {
290            "v" => "run.v",
291            "python" => "run.py",
292            "shell" => "run.sh",
293            _ => {
294                return Ok(ToolResult::error(
295                    "Unsupported language. Use: v, python, shell",
296                ))
297            }
298        };
299
300        std::fs::write(tool_dir.join(filename), &args.code)?;
301
302        // Make shell scripts executable
303        if language == "shell" {
304            #[cfg(unix)]
305            {
306                use std::os::unix::fs::PermissionsExt;
307                let perms = std::fs::Permissions::from_mode(0o755);
308                std::fs::set_permissions(tool_dir.join(filename), perms)?;
309            }
310        }
311
312        // Verify the tool can be loaded
313        match DynamicTool::load(&tool_dir, Arc::clone(&self.policy)) {
314            Some(t) => Ok(ToolResult::success(format!(
315                "✅ Tool '{}' created successfully!\n\
316                Language: {}\n\
317                Location: {}\n\
318                Parameters: {}\n\n\
319                Note: The tool is saved but requires a bot restart to be available in the current session. \
320                It will be auto-loaded on next startup.",
321                t.name, language, tool_dir.display(), serde_json::to_string_pretty(&t.parameters)?
322            ))),
323            None => Ok(ToolResult::error(format!(
324                "Tool created but failed to load. Check {} in {}",
325                filename,
326                tool_dir.display()
327            ))),
328        }
329    }
330}
331
332// ============================================================
333// list_custom_tools — see what tools have been created
334// ============================================================
335
336pub struct ListCustomToolsTool;
337
338impl ListCustomToolsTool {
339    pub fn new() -> Self {
340        Self
341    }
342}
343
344impl Default for ListCustomToolsTool {
345    fn default() -> Self {
346        Self::new()
347    }
348}
349
350#[async_trait]
351impl Tool for ListCustomToolsTool {
352    fn name(&self) -> &str {
353        "list_custom_tools"
354    }
355
356    fn spec(&self) -> ToolSpec {
357        ToolSpec {
358            name: "list_custom_tools".to_string(),
359            description: "List all custom tools created by the AI.".to_string(),
360            parameters: serde_json::json!({
361                "type": "object",
362                "properties": {}
363            }),
364        }
365    }
366
367    async fn execute(&self, _arguments: &str) -> anyhow::Result<ToolResult> {
368        let tools = DynamicTool::load_all(Arc::new(ExecutionPolicy::from_config(
369            &PolicyConfig::default(),
370        )));
371        if tools.is_empty() {
372            return Ok(ToolResult::success(
373                "No custom tools created yet.\n\
374                Use create_tool to make one!\n\n\
375                Example: create a V tool that fetches weather, a Python data processor, etc.",
376            ));
377        }
378
379        let mut output = format!("Custom tools ({}):\n\n", tools.len());
380        for t in &tools {
381            output.push_str(&format!(
382                "• {} ({}) — {}\n  Location: {}\n",
383                t.name,
384                t.language,
385                t.description,
386                t.tool_dir.display()
387            ));
388        }
389        Ok(ToolResult::success(output))
390    }
391}