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_counted;
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 = 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
188pub 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 name: String,
206 description: String,
208 parameters: serde_json::Value,
210 code: String,
212 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 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 std::fs::create_dir_all(&tool_dir)?;
275
276 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 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 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 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
332pub 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}