1mod builtin;
13pub mod document_parser;
14mod process;
15mod registry;
16pub mod skill;
17pub mod task;
18mod types;
19
20pub(crate) use builtin::agentic_parse::AgenticParseTool;
21pub(crate) use builtin::agentic_search::AgenticSearchTool;
22pub use builtin::{register_agentic_tools, register_skill, register_task, register_task_with_mcp};
23pub use registry::ToolRegistry;
24pub use task::{
25 parallel_task_params_schema, task_params_schema, ParallelTaskParams, ParallelTaskTool,
26 TaskExecutor, TaskParams, TaskResult, TaskTool,
27};
28pub use types::{Tool, ToolContext, ToolEventSender, ToolOutput, ToolStreamEvent};
29
30use crate::file_history::{self, FileHistory};
31use crate::llm::ToolDefinition;
32use crate::permissions::{PermissionChecker, PermissionDecision};
33use anyhow::Result;
34use serde::{Deserialize, Serialize};
35use std::path::PathBuf;
36use std::sync::Arc;
37
38pub const MAX_OUTPUT_SIZE: usize = 100 * 1024; pub const MAX_READ_LINES: usize = 2000;
43
44pub const MAX_LINE_LENGTH: usize = 2000;
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ToolResult {
50 pub name: String,
51 pub output: String,
52 pub exit_code: i32,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub metadata: Option<serde_json::Value>,
55 #[serde(skip)]
57 pub images: Vec<crate::llm::Attachment>,
58}
59
60impl ToolResult {
61 pub fn success(name: &str, output: String) -> Self {
62 Self {
63 name: name.to_string(),
64 output,
65 exit_code: 0,
66 metadata: None,
67 images: Vec::new(),
68 }
69 }
70
71 pub fn error(name: &str, message: String) -> Self {
72 Self {
73 name: name.to_string(),
74 output: message,
75 exit_code: 1,
76 metadata: None,
77 images: Vec::new(),
78 }
79 }
80}
81
82impl From<ToolOutput> for ToolResult {
83 fn from(output: ToolOutput) -> Self {
84 Self {
85 name: String::new(),
86 output: output.content,
87 exit_code: if output.success { 0 } else { 1 },
88 metadata: output.metadata,
89 images: output.images,
90 }
91 }
92}
93
94pub struct ToolExecutor {
103 workspace: PathBuf,
104 registry: Arc<ToolRegistry>,
105 file_history: Arc<FileHistory>,
106 guard_policy: Option<Arc<dyn PermissionChecker>>,
107}
108
109impl ToolExecutor {
110 pub fn new(workspace: String) -> Self {
111 let workspace_path = PathBuf::from(&workspace);
112 let registry = Arc::new(ToolRegistry::new(workspace_path.clone()));
113
114 builtin::register_builtins(®istry);
116 builtin::register_batch(®istry);
118
119 Self {
120 workspace: workspace_path,
121 registry,
122 file_history: Arc::new(FileHistory::new(500)),
123 guard_policy: None,
124 }
125 }
126
127 pub fn set_guard_policy(&mut self, policy: Arc<dyn PermissionChecker>) {
128 self.guard_policy = Some(policy);
129 }
130
131 fn check_guard(&self, name: &str, args: &serde_json::Value) -> Result<()> {
132 if let Some(checker) = &self.guard_policy {
133 if checker.check(name, args) == PermissionDecision::Deny {
134 anyhow::bail!(
135 "Defense-in-depth: Tool '{}' is blocked by guard permission policy",
136 name
137 );
138 }
139 }
140 Ok(())
141 }
142
143 fn check_workspace_boundary(
144 name: &str,
145 args: &serde_json::Value,
146 ctx: &ToolContext,
147 ) -> Result<()> {
148 let path_field = match name {
149 "read" | "write" | "edit" | "patch" => Some("file_path"),
150 "ls" | "grep" | "glob" => Some("path"),
151 _ => None,
152 };
153
154 if let Some(field) = path_field {
155 if let Some(path_str) = args.get(field).and_then(|v| v.as_str()) {
156 let target = if std::path::Path::new(path_str).is_absolute() {
157 std::path::PathBuf::from(path_str)
158 } else {
159 ctx.workspace.join(path_str)
160 };
161
162 let canonical_workspace = ctx.workspace.canonicalize().map_err(|e| {
164 anyhow::anyhow!(
165 "Workspace boundary check failed: cannot canonicalize workspace '{}': {}",
166 ctx.workspace.display(),
167 e
168 )
169 })?;
170
171 let canonical_target = target.canonicalize().or_else(|_| {
173 target
174 .parent()
175 .and_then(|p| p.canonicalize().ok())
176 .ok_or_else(|| {
177 std::io::Error::new(std::io::ErrorKind::NotFound, "parent not found")
178 })
179 });
180
181 match canonical_target {
182 Ok(canonical) => {
183 if !canonical.starts_with(&canonical_workspace) {
184 anyhow::bail!(
185 "Workspace boundary violation: tool '{}' path '{}' escapes workspace '{}'",
186 name,
187 path_str,
188 ctx.workspace.display()
189 );
190 }
191 }
192 Err(_) => {
193 anyhow::bail!(
195 "Workspace boundary check failed: cannot resolve path '{}' for tool '{}'",
196 path_str,
197 name
198 );
199 }
200 }
201 }
202 }
203
204 Ok(())
205 }
206
207 pub fn workspace(&self) -> &PathBuf {
208 &self.workspace
209 }
210
211 pub fn registry(&self) -> &Arc<ToolRegistry> {
212 &self.registry
213 }
214
215 pub fn register_dynamic_tool(&self, tool: Arc<dyn Tool>) {
216 self.registry.register(tool);
217 }
218
219 pub fn unregister_dynamic_tool(&self, name: &str) {
220 self.registry.unregister(name);
221 }
222
223 pub fn unregister_tools_by_prefix(&self, prefix: &str) {
225 self.registry.unregister_by_prefix(prefix);
226 }
227
228 pub fn file_history(&self) -> &Arc<FileHistory> {
229 &self.file_history
230 }
231
232 fn capture_snapshot(&self, name: &str, args: &serde_json::Value) {
233 if let Some(file_path) = file_history::extract_file_path(name, args) {
234 let resolved = self.workspace.join(&file_path);
235 let path_to_read = if resolved.exists() {
236 resolved
237 } else if std::path::Path::new(&file_path).exists() {
238 std::path::PathBuf::from(&file_path)
239 } else {
240 self.file_history.save_snapshot(&file_path, "", name);
241 return;
242 };
243
244 match std::fs::read_to_string(&path_to_read) {
245 Ok(content) => {
246 self.file_history.save_snapshot(&file_path, &content, name);
247 tracing::debug!(
248 "Captured file snapshot for {} before {} (version {})",
249 file_path,
250 name,
251 self.file_history.list_versions(&file_path).len() - 1,
252 );
253 }
254 Err(e) => {
255 tracing::warn!("Failed to capture snapshot for {}: {}", file_path, e);
256 }
257 }
258 }
259 }
260
261 pub async fn execute(&self, name: &str, args: &serde_json::Value) -> Result<ToolResult> {
262 self.check_guard(name, args)?;
263 tracing::info!("Executing tool: {} with args: {}", name, args);
264 self.capture_snapshot(name, args);
265 let mut result = self.registry.execute(name, args).await;
266 if let Ok(ref mut r) = result {
267 self.attach_diff_metadata(name, args, r);
268 }
269 match &result {
270 Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
271 Err(e) => tracing::error!("Tool {} failed: {}", name, e),
272 }
273 result
274 }
275
276 pub async fn execute_with_context(
277 &self,
278 name: &str,
279 args: &serde_json::Value,
280 ctx: &ToolContext,
281 ) -> Result<ToolResult> {
282 self.check_guard(name, args)?;
283 Self::check_workspace_boundary(name, args, ctx)?;
284 tracing::info!("Executing tool: {} with args: {}", name, args);
285 self.capture_snapshot(name, args);
286 let mut result = self.registry.execute_with_context(name, args, ctx).await;
287 if let Ok(ref mut r) = result {
288 self.attach_diff_metadata(name, args, r);
289 }
290 match &result {
291 Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
292 Err(e) => tracing::error!("Tool {} failed: {}", name, e),
293 }
294 result
295 }
296
297 fn attach_diff_metadata(&self, name: &str, args: &serde_json::Value, result: &mut ToolResult) {
298 if !file_history::is_file_modifying_tool(name) {
299 return;
300 }
301 let Some(file_path) = file_history::extract_file_path(name, args) else {
302 return;
303 };
304 let meta = result.metadata.get_or_insert_with(|| serde_json::json!({}));
307 meta["file_path"] = serde_json::Value::String(file_path);
308 }
309
310 pub fn definitions(&self) -> Vec<ToolDefinition> {
311 self.registry.definitions()
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[tokio::test]
320 async fn test_tool_executor_creation() {
321 let executor = ToolExecutor::new("/tmp".to_string());
322 assert_eq!(executor.registry.len(), 12);
325 }
326
327 #[tokio::test]
328 async fn test_unknown_tool() {
329 let executor = ToolExecutor::new("/tmp".to_string());
330 let result = executor
331 .execute("unknown", &serde_json::json!({}))
332 .await
333 .unwrap();
334 assert_eq!(result.exit_code, 1);
335 assert!(result.output.contains("Unknown tool"));
336 }
337
338 #[tokio::test]
339 async fn test_builtin_tools_registered() {
340 let executor = ToolExecutor::new("/tmp".to_string());
341 let definitions = executor.definitions();
342
343 assert!(definitions.iter().any(|t| t.name == "bash"));
344 assert!(definitions.iter().any(|t| t.name == "read"));
345 assert!(definitions.iter().any(|t| t.name == "write"));
346 assert!(definitions.iter().any(|t| t.name == "edit"));
347 assert!(definitions.iter().any(|t| t.name == "grep"));
348 assert!(definitions.iter().any(|t| t.name == "glob"));
349 assert!(definitions.iter().any(|t| t.name == "ls"));
350 assert!(definitions.iter().any(|t| t.name == "patch"));
351 assert!(definitions.iter().any(|t| t.name == "web_fetch"));
352 assert!(definitions.iter().any(|t| t.name == "web_search"));
353 assert!(definitions.iter().any(|t| t.name == "batch"));
354 }
355
356 #[test]
357 fn test_tool_result_success() {
358 let result = ToolResult::success("test_tool", "output text".to_string());
359 assert_eq!(result.name, "test_tool");
360 assert_eq!(result.output, "output text");
361 assert_eq!(result.exit_code, 0);
362 assert!(result.metadata.is_none());
363 }
364
365 #[test]
366 fn test_tool_result_error() {
367 let result = ToolResult::error("test_tool", "error message".to_string());
368 assert_eq!(result.name, "test_tool");
369 assert_eq!(result.output, "error message");
370 assert_eq!(result.exit_code, 1);
371 assert!(result.metadata.is_none());
372 }
373
374 #[test]
375 fn test_tool_result_from_tool_output_success() {
376 let output = ToolOutput {
377 content: "success content".to_string(),
378 success: true,
379 metadata: None,
380 images: Vec::new(),
381 };
382 let result: ToolResult = output.into();
383 assert_eq!(result.output, "success content");
384 assert_eq!(result.exit_code, 0);
385 assert!(result.metadata.is_none());
386 }
387
388 #[test]
389 fn test_tool_result_from_tool_output_failure() {
390 let output = ToolOutput {
391 content: "failure content".to_string(),
392 success: false,
393 metadata: Some(serde_json::json!({"error": "test"})),
394 images: Vec::new(),
395 };
396 let result: ToolResult = output.into();
397 assert_eq!(result.output, "failure content");
398 assert_eq!(result.exit_code, 1);
399 assert_eq!(result.metadata, Some(serde_json::json!({"error": "test"})));
400 }
401
402 #[test]
403 fn test_tool_result_metadata_propagation() {
404 let output = ToolOutput::success("content")
405 .with_metadata(serde_json::json!({"_load_skill": true, "skill_name": "test"}));
406 let result: ToolResult = output.into();
407 assert_eq!(result.exit_code, 0);
408 let meta = result.metadata.unwrap();
409 assert_eq!(meta["_load_skill"], true);
410 assert_eq!(meta["skill_name"], "test");
411 }
412
413 #[test]
414 fn test_tool_executor_workspace() {
415 let executor = ToolExecutor::new("/test/workspace".to_string());
416 assert_eq!(executor.workspace().to_str().unwrap(), "/test/workspace");
417 }
418
419 #[test]
420 fn test_tool_executor_registry() {
421 let executor = ToolExecutor::new("/tmp".to_string());
422 let registry = executor.registry();
423 assert_eq!(registry.len(), 12);
426 }
427
428 #[test]
429 fn test_tool_executor_file_history() {
430 let executor = ToolExecutor::new("/tmp".to_string());
431 let history = executor.file_history();
432 assert_eq!(history.list_versions("nonexistent.txt").len(), 0);
433 }
434
435 #[test]
436 fn test_max_output_size_constant() {
437 assert_eq!(MAX_OUTPUT_SIZE, 100 * 1024);
438 }
439
440 #[test]
441 fn test_max_read_lines_constant() {
442 assert_eq!(MAX_READ_LINES, 2000);
443 }
444
445 #[test]
446 fn test_max_line_length_constant() {
447 assert_eq!(MAX_LINE_LENGTH, 2000);
448 }
449
450 #[test]
451 fn test_tool_result_clone() {
452 let result = ToolResult::success("test", "output".to_string());
453 let cloned = result.clone();
454 assert_eq!(result.name, cloned.name);
455 assert_eq!(result.output, cloned.output);
456 assert_eq!(result.exit_code, cloned.exit_code);
457 assert_eq!(result.metadata, cloned.metadata);
458 }
459
460 #[test]
461 fn test_tool_result_debug() {
462 let result = ToolResult::success("test", "output".to_string());
463 let debug_str = format!("{:?}", result);
464 assert!(debug_str.contains("test"));
465 assert!(debug_str.contains("output"));
466 }
467
468 #[tokio::test]
469 async fn test_execute_attaches_diff_metadata() {
470 use tempfile::TempDir;
471 let dir = TempDir::new().unwrap();
472 let file = dir.path().join("hello.txt");
473 std::fs::write(&file, "before content\n").unwrap();
474
475 let executor = ToolExecutor::new(dir.path().to_str().unwrap().to_string());
476 let args = serde_json::json!({
477 "file_path": "hello.txt",
478 "content": "after content\n"
479 });
480 let result = executor.execute("write", &args).await.unwrap();
481
482 let meta = result.metadata.expect("metadata should be present");
483 assert_eq!(meta["before"], "before content\n");
484 assert_eq!(meta["after"], "after content\n");
485 assert_eq!(meta["file_path"], "hello.txt");
486 }
487
488 #[tokio::test]
489 async fn test_execute_with_context_attaches_diff_metadata() {
490 use tempfile::TempDir;
491 let dir = TempDir::new().unwrap();
492 let canonical_dir = dir.path().canonicalize().unwrap();
493 let file = canonical_dir.join("ctx.txt");
494 std::fs::write(&file, "original\n").unwrap();
495
496 let executor = ToolExecutor::new(canonical_dir.to_str().unwrap().to_string());
497 let ctx = ToolContext {
498 workspace: canonical_dir.clone(),
499 session_id: None,
500 event_tx: None,
501 agent_event_tx: None,
502 search_config: None,
503 sandbox: None,
504 document_parsers: None,
505 };
506 let args = serde_json::json!({
507 "file_path": "ctx.txt",
508 "content": "updated\n"
509 });
510 let result = executor
511 .execute_with_context("write", &args, &ctx)
512 .await
513 .unwrap();
514 assert_eq!(result.exit_code, 0, "write tool failed: {}", result.output);
515
516 let meta = result.metadata.expect("metadata should be present");
517 assert_eq!(meta["before"], "original\n");
518 assert_eq!(meta["after"], "updated\n");
519 assert_eq!(meta["file_path"], "ctx.txt");
520 }
521}