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