1use 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
21fn 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
27pub 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, 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 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 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 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 let output = match self.language.as_str() {
124 "v" => {
125 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 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
189pub 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 name: String,
207 description: String,
209 parameters: serde_json::Value,
211 code: String,
213 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 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 std::fs::create_dir_all(&tool_dir)?;
276
277 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 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 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 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
333pub 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}