1use std::sync::Arc;
2
3use async_trait::async_trait;
4use bamboo_agent_core::{
5 normalize_tool_name, parse_tool_args_best_effort, Tool, ToolCall, ToolError,
6 ToolExecutionContext, ToolExecutor, ToolResult, ToolSchema,
7};
8use bamboo_domain::tool_names::{normalize_builtin_alias, resolve_alias};
9
10use crate::guide::{context::GuideBuildContext, EnhancedPromptBuilder, ToolGuide};
11use crate::permission::{check_permissions, PermissionChecker, PermissionError};
12use crate::tools::{
13 BashOutputTool, BashTool, ConclusionWithOptionsTool, EditTool, EnterPlanModeTool,
14 ExitPlanModeTool, GetFileInfoTool, GlobTool, GrepTool, JsReplTool, KillShellTool,
15 NotebookEditTool, ReadTool, RequestPermissionsTool, SessionNoteTool, SleepTool, TaskTool,
16 ToolRegistry, UpdateGoalTool, WebFetchTool, WebSearchTool, WorkspaceTool, WriteTool,
17};
18use bamboo_llm::Config;
19use tokio::sync::RwLock;
20
21fn preview_for_log(value: &str, max_chars: usize) -> String {
22 let mut iter = value.chars();
23 let mut preview = String::new();
24 for _ in 0..max_chars {
25 match iter.next() {
26 Some(ch) => preview.push(ch),
27 None => break,
28 }
29 }
30 if iter.next().is_some() {
31 preview.push_str("...");
32 }
33 preview.replace('\n', "\\n").replace('\r', "\\r")
34}
35
36fn copy_legacy_arg_if_missing(
37 args: &mut serde_json::Map<String, serde_json::Value>,
38 from: &str,
39 to: &str,
40) {
41 if args.contains_key(to) {
42 return;
43 }
44 if let Some(value) = args.get(from).cloned() {
45 args.insert(to.to_string(), value);
46 }
47}
48
49fn normalize_legacy_builtin_args(
50 raw_tool_name: &str,
51 args: &mut serde_json::Map<String, serde_json::Value>,
52) {
53 match raw_tool_name {
54 "read_file" | "write_file" | "Read" | "Write" | "apply_patch" => {
55 copy_legacy_arg_if_missing(args, "path", "file_path");
56 }
57 "execute_command" | "Bash" => {
58 copy_legacy_arg_if_missing(args, "cmd", "command");
59 }
60 "list_directory" | "Glob" => {
61 let should_default_pattern = raw_tool_name == "list_directory"
62 || args.contains_key("path")
63 || args.contains_key("recursive");
64 if should_default_pattern && !args.contains_key("pattern") {
65 let recursive = args
66 .get("recursive")
67 .and_then(serde_json::Value::as_bool)
68 .unwrap_or(false);
69 let pattern = if recursive { "**/*" } else { "*" };
70 args.insert(
71 "pattern".to_string(),
72 serde_json::Value::String(pattern.to_string()),
73 );
74 }
75 args.remove("recursive");
76 }
77 _ => {}
78 }
79}
80
81fn resolve_registered_tool_name(registry: &ToolRegistry, raw_tool_name: &str) -> String {
82 if registry.get(raw_tool_name).is_some() {
83 return raw_tool_name.to_string();
84 }
85
86 let aliased = normalize_builtin_alias(raw_tool_name);
87 if registry.get(aliased).is_some() {
88 return aliased.to_string();
89 }
90
91 resolve_alias(aliased).unwrap_or(aliased).to_string()
92}
93
94pub struct BuiltinToolExecutor {
96 registry: ToolRegistry,
97 permission_checker: Option<Arc<dyn PermissionChecker>>,
98}
99
100impl BuiltinToolExecutor {
101 pub fn new() -> Self {
103 let registry = ToolRegistry::new();
104 Self::register_builtin_tools(®istry, None);
105 Self {
106 registry,
107 permission_checker: None,
108 }
109 }
110
111 pub fn new_with_permissions(permission_checker: Arc<dyn PermissionChecker>) -> Self {
113 let registry = ToolRegistry::new();
114 Self::register_builtin_tools(®istry, None);
115 Self {
116 registry,
117 permission_checker: Some(permission_checker),
118 }
119 }
120
121 pub fn new_with_config(config: Arc<RwLock<Config>>) -> Self {
126 let registry = ToolRegistry::new();
127 Self::register_builtin_tools(®istry, Some(config));
128 Self {
129 registry,
130 permission_checker: None,
131 }
132 }
133
134 pub fn new_with_config_and_permissions(
136 config: Arc<RwLock<Config>>,
137 permission_checker: Arc<dyn PermissionChecker>,
138 ) -> Self {
139 let registry = ToolRegistry::new();
140 Self::register_builtin_tools(®istry, Some(config));
141 Self {
142 registry,
143 permission_checker: Some(permission_checker),
144 }
145 }
146
147 pub fn with_registry(registry: ToolRegistry) -> Self {
149 Self {
150 registry,
151 permission_checker: None,
152 }
153 }
154
155 pub fn registry(&self) -> &ToolRegistry {
157 &self.registry
158 }
159
160 fn register_builtin_tools(registry: &ToolRegistry, config: Option<Arc<RwLock<Config>>>) {
162 let _ = config;
163 let _ = registry.register(ConclusionWithOptionsTool::new());
165 let _ = registry.register(BashTool::new());
166 let _ = registry.register(BashOutputTool::new());
167 let _ = registry.register(EditTool::new());
168 let _ = registry.register(EnterPlanModeTool::new());
169 let _ = registry.register(ExitPlanModeTool::new());
170 let _ = registry.register(GetFileInfoTool::new());
172 let _ = registry.register(GlobTool::new());
173 let _ = registry.register(GrepTool::new());
174 let _ = registry.register(UpdateGoalTool::new());
175 let _ = registry.register(JsReplTool::new());
176 let _ = registry.register(KillShellTool::new());
177 let _ = registry.register(SessionNoteTool::new());
178 let _ = registry.register(NotebookEditTool::new());
179 let _ = registry.register(ReadTool::new());
180 let _ = registry.register(RequestPermissionsTool::new());
181 let _ = registry.register(SleepTool::new());
182 let _ = registry.register(TaskTool::new());
183 let _ = registry.register(WebFetchTool::new());
184 let _ = registry.register(WebSearchTool::new());
185 let _ = registry.register(WorkspaceTool::new());
187 let _ = registry.register(WriteTool::new());
188 }
189
190 pub fn tool_schemas() -> Vec<ToolSchema> {
192 let registry = ToolRegistry::new();
193 Self::register_builtin_tools(®istry, None);
194 registry.list_tools()
195 }
196
197 pub fn register_tool<T: Tool + 'static>(&self, tool: T) -> Result<(), ToolError> {
199 self.registry
200 .register(tool)
201 .map_err(|e| ToolError::Execution(e.to_string()))
202 }
203
204 pub fn register_tool_with_guide<T, G>(&self, tool: T, guide: G) -> Result<(), ToolError>
206 where
207 T: Tool + 'static,
208 G: ToolGuide + 'static,
209 {
210 self.registry
211 .register_with_guide(tool, guide)
212 .map_err(|e| ToolError::Execution(e.to_string()))
213 }
214
215 pub fn get_guide(&self, tool_name: &str) -> Option<Arc<dyn ToolGuide>> {
217 self.registry.get_guide(tool_name)
218 }
219
220 pub fn build_enhanced_prompt(&self, context: GuideBuildContext) -> String {
222 EnhancedPromptBuilder::build(Some(&self.registry), &self.registry.list_tools(), &context)
223 }
224}
225
226fn permission_error_to_tool_error(error: PermissionError) -> ToolError {
227 match error {
228 PermissionError::CheckFailed(_) => ToolError::InvalidArguments(error.to_string()),
229 _ => ToolError::Execution(error.to_string()),
230 }
231}
232
233impl Default for BuiltinToolExecutor {
234 fn default() -> Self {
235 Self::new()
236 }
237}
238
239#[async_trait]
240impl ToolExecutor for BuiltinToolExecutor {
241 async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
242 self.execute_with_context(call, ToolExecutionContext::none(&call.id))
243 .await
244 }
245
246 async fn execute_with_context(
247 &self,
248 call: &ToolCall,
249 ctx: ToolExecutionContext<'_>,
250 ) -> Result<ToolResult, ToolError> {
251 let args_raw = call.function.arguments.trim();
252 let (mut args, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
253 if let Some(warning) = parse_warning {
254 tracing::warn!(
255 "Builtin tool argument parsing fallback applied: session_id={:?}, tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", warning={}",
256 ctx.session_id,
257 call.id,
258 call.function.name,
259 args_raw.len(),
260 preview_for_log(args_raw, 180),
261 warning
262 );
263 }
264
265 let raw_tool_name = normalize_tool_name(&call.function.name);
266 if let Some(args_obj) = args.as_object_mut() {
267 normalize_legacy_builtin_args(raw_tool_name, args_obj);
268 }
269
270 let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
271
272 let tool = self
274 .registry
275 .get(&tool_name)
276 .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", tool_name)))?;
277
278 if let Some(permission_checker) = &self.permission_checker {
279 if let Some(contexts) =
280 check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
281 {
282 let force_ask = permission_checker.requires_forced_confirmation(&tool_name, &args);
287 for context in contexts {
288 if ctx.bypass_permissions && !force_ask {
289 continue;
290 }
291 let resource = context.resource.clone();
292 let decision = if force_ask {
295 permission_checker.check_or_request_forced(context).await
296 } else {
297 permission_checker.check_or_request(context).await
298 };
299 match decision {
300 Ok(true) => {}
301 Ok(false) => {
302 return Err(ToolError::Execution(format!(
303 "Permission denied for: {}",
304 resource
305 )));
306 }
307 Err(PermissionError::ConfirmationRequired {
308 permission_type,
309 resource: _,
310 }) => {
311 if let Some(proxy) = crate::approval::current_approval_proxy() {
322 let approved = proxy
323 .request_approval(crate::approval::ApprovalAsk {
324 tool_name: tool_name.clone(),
325 permission: permission_type.description().to_string(),
326 resource: resource.clone(),
327 })
328 .await;
329 if approved {
330 continue;
333 }
334 return Err(ToolError::Execution(format!(
335 "Permission denied by host for: {}",
336 resource
337 )));
338 }
339
340 if let Some(tx) = ctx.event_tx {
347 let _ = tx
349 .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
350 tool_call_id: call.id.clone(),
351 tool_name: tool_name.clone(),
352 parameters: args.clone(),
353 })
354 .await;
355
356 let question = format!(
357 "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
358 tool_name,
359 permission_type.description(),
360 resource
361 );
362 let payload = serde_json::json!({
363 "status": "awaiting_permission_approval",
364 "question": question,
365 "permission_type": permission_type,
366 "resource": resource,
367 "options": ["Approve", "Deny"],
368 "allow_custom": false,
369 });
370 return Ok(ToolResult {
371 success: true,
372 result: payload.to_string(),
373 display_preference: Some("request_permissions".to_string()),
374 images: Vec::new(),
375 });
376 }
377
378 return Err(ToolError::Execution(format!(
381 "Permission approval required for: {}",
382 resource
383 )));
384 }
385 Err(other) => {
386 return Err(permission_error_to_tool_error(other));
387 }
388 }
389 }
390 }
391 }
392
393 tool.execute_with_context(args, ctx).await
394 }
395
396 fn list_tools(&self) -> Vec<ToolSchema> {
397 self.registry.list_tools()
398 }
399
400 fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
401 self.registry
402 .get(tool_name)
403 .map(|tool| tool.mutability())
404 .unwrap_or_else(|| crate::classify_tool(tool_name))
405 }
406
407 fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
408 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
409 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
410 self.registry
411 .get(&canonical)
412 .map(|tool| tool.call_mutability(&args))
413 .unwrap_or_else(|| self.tool_mutability(&canonical))
414 }
415
416 fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
417 let canonical = resolve_registered_tool_name(&self.registry, tool_name);
418 self.registry
419 .get(&canonical)
420 .map(|tool| tool.concurrency_safe())
421 .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
422 }
423
424 fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
425 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
426 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
427 self.registry
428 .get(&canonical)
429 .map(|tool| tool.call_concurrency_safe(&args))
430 .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
431 }
432}
433
434pub struct BuiltinToolExecutorBuilder {
436 registry: ToolRegistry,
437 permission_checker: Option<Arc<dyn PermissionChecker>>,
438}
439
440impl BuiltinToolExecutorBuilder {
441 pub fn new() -> Self {
443 Self {
444 registry: ToolRegistry::new(),
445 permission_checker: None,
446 }
447 }
448
449 pub fn with_default_tools(self) -> Self {
451 BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
452 self
453 }
454
455 pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
457 match name {
458 "Read" => self.registry.register(ReadTool::new()),
459 "Write" => self.registry.register(WriteTool::new()),
460 "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
462 "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
463 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
464 }
465 .map_err(|e| ToolError::Execution(e.to_string()))?;
466 Ok(self)
467 }
468
469 pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
471 match name {
472 "Bash" => self.registry.register(BashTool::new()),
473 "BashOutput" => self.registry.register(BashOutputTool::new()),
474 "KillShell" => self.registry.register(KillShellTool::new()),
475 "Task" => self.registry.register(TaskTool::new()),
476 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
477 }
478 .map_err(|e| ToolError::Execution(e.to_string()))?;
479 Ok(self)
480 }
481
482 pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
484 self.registry
485 .register(tool)
486 .map_err(|e| ToolError::Execution(e.to_string()))?;
487 Ok(self)
488 }
489
490 pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
492 self.permission_checker = Some(checker);
493 self
494 }
495
496 pub fn build(self) -> BuiltinToolExecutor {
498 BuiltinToolExecutor {
499 registry: self.registry,
500 permission_checker: self.permission_checker,
501 }
502 }
503}
504
505impl Default for BuiltinToolExecutorBuilder {
506 fn default() -> Self {
507 Self::new()
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use bamboo_agent_core::AgentEvent;
515 use bamboo_agent_core::FunctionCall;
516 use bamboo_agent_core::ToolExecutionContext;
517 use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
518 use serde_json::json;
519 use std::sync::Arc;
520 use tokio::fs;
521 use tokio::sync::mpsc;
522
523 use crate::tools::WriteTool;
524
525 fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
526 ToolCall {
527 id: "call_1".to_string(),
528 tool_type: "function".to_string(),
529 function: FunctionCall {
530 name: name.to_string(),
531 arguments: args.to_string(),
532 },
533 }
534 }
535
536 fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
537 ToolCall {
538 id: "call_1".to_string(),
539 tool_type: "function".to_string(),
540 function: FunctionCall {
541 name: name.to_string(),
542 arguments: raw_args.to_string(),
543 },
544 }
545 }
546
547 fn make_executor(
548 permission_checker: Option<Arc<dyn PermissionChecker>>,
549 ) -> BuiltinToolExecutor {
550 let builder = BuiltinToolExecutorBuilder::new()
551 .with_tool(WriteTool::new())
552 .expect("register Write tool");
553
554 let builder = match permission_checker {
555 Some(checker) => builder.with_permission_checker(checker),
556 None => builder,
557 };
558
559 builder.build()
560 }
561
562 #[test]
563 fn test_normalize_tool_ref_accepts_claude_style_names() {
564 assert_eq!(
565 normalize_tool_ref("default::Bash"),
566 Some("Bash".to_string())
567 );
568 }
569
570 #[test]
571 fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
572 assert_eq!(
573 normalize_tool_ref("default::fileExists"),
574 Some("FileExists".to_string())
575 );
576 assert_eq!(
577 normalize_tool_ref("default::getCurrentDir"),
578 Some("GetCurrentDir".to_string())
579 );
580 assert_eq!(
581 normalize_tool_ref("default::getFileInfo"),
582 Some("GetFileInfo".to_string())
583 );
584 assert_eq!(
585 normalize_tool_ref("default::setWorkspace"),
586 Some("SetWorkspace".to_string())
587 );
588 assert_eq!(
589 normalize_tool_ref("default::sleep"),
590 Some("Sleep".to_string())
591 );
592 }
593
594 #[test]
595 fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
596 assert_eq!(
597 normalize_tool_ref("default::execute_command"),
598 Some("Bash".to_string())
599 );
600 assert_eq!(
601 normalize_tool_ref("default::file_exists"),
602 Some("FileExists".to_string())
603 );
604 assert_eq!(
605 normalize_tool_ref("default::get_current_dir"),
606 Some("GetCurrentDir".to_string())
607 );
608 assert_eq!(
609 normalize_tool_ref("default::get_file_info"),
610 Some("GetFileInfo".to_string())
611 );
612 assert_eq!(
613 normalize_tool_ref("default::list_directory"),
614 Some("Glob".to_string())
615 );
616 assert_eq!(
617 normalize_tool_ref("default::memory_note"),
618 Some("memory_note".to_string())
619 );
620 assert_eq!(
621 normalize_tool_ref("default::read_file"),
622 Some("Read".to_string())
623 );
624 assert_eq!(
625 normalize_tool_ref("default::set_workspace"),
626 Some("SetWorkspace".to_string())
627 );
628 assert_eq!(
629 normalize_tool_ref("default::write_file"),
630 Some("Write".to_string())
631 );
632 }
633
634 #[test]
635 fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
636 for alias in [
637 "default::spawn_session",
638 "default::sub_session",
639 "default::sub_task",
640 "default::team_agent",
641 "default::child_session",
642 ] {
643 assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
644 }
645 }
646
647 #[test]
648 fn test_normalize_tool_ref_accepts_server_overlay_tools() {
649 assert_eq!(normalize_tool_ref("compress_context"), None);
650 assert_eq!(
651 normalize_tool_ref("default::read_skill_resource"),
652 Some("read_skill_resource".to_string())
653 );
654 }
655
656 #[tokio::test]
657 async fn test_executor_accepts_legacy_read_file_path_argument() {
658 let dir = tempfile::tempdir().unwrap();
659 let file_path = dir.path().join("legacy-read.txt");
660 fs::write(&file_path, "legacy read content").await.unwrap();
661
662 let executor = BuiltinToolExecutor::new();
663 let call = make_tool_call("read_file", json!({"path": file_path}));
664
665 let result = executor.execute(&call).await.unwrap();
666 assert!(result.success);
667 assert!(result.result.contains("legacy read content"));
668 }
669
670 #[tokio::test]
671 async fn test_executor_accepts_legacy_list_directory_without_pattern() {
672 let dir = tempfile::tempdir().unwrap();
673 let file_path = dir.path().join("legacy-list.txt");
674 fs::write(&file_path, "legacy list content").await.unwrap();
675
676 let executor = BuiltinToolExecutor::new();
677 let call = make_tool_call("list_directory", json!({"path": dir.path()}));
678
679 let result = executor.execute(&call).await.unwrap();
680 assert!(result.success);
681 assert!(result.result.contains("legacy-list.txt"));
682 }
683
684 #[tokio::test]
685 async fn test_executor_accepts_canonical_read_with_path_argument() {
686 let dir = tempfile::tempdir().unwrap();
687 let file_path = dir.path().join("canonical-read.txt");
688 fs::write(&file_path, "canonical read content")
689 .await
690 .unwrap();
691
692 let executor = BuiltinToolExecutor::new();
693 let call = make_tool_call("Read", json!({"path": file_path}));
694
695 let result = executor.execute(&call).await.unwrap();
696 assert!(result.success);
697 assert!(result.result.contains("canonical read content"));
698 }
699
700 #[tokio::test]
701 async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
702 let dir = tempfile::tempdir().unwrap();
703 let file_path = dir.path().join("canonical-list.txt");
704 fs::write(&file_path, "canonical list content")
705 .await
706 .unwrap();
707
708 let executor = BuiltinToolExecutor::new();
709 let call = make_tool_call("Glob", json!({"path": dir.path()}));
710
711 let result = executor.execute(&call).await.unwrap();
712 assert!(result.success);
713 assert!(result.result.contains("canonical-list.txt"));
714 }
715
716 #[test]
717 fn test_executor_workspace_mutability_depends_on_path_argument() {
718 let executor = BuiltinToolExecutor::new();
719 let get_call = make_tool_call("Workspace", json!({}));
720 let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
721
722 assert_eq!(
723 executor.call_mutability(&get_call),
724 crate::ToolMutability::ReadOnly
725 );
726 assert!(executor.call_concurrency_safe(&get_call));
727
728 assert_eq!(
729 executor.call_mutability(&set_call),
730 crate::ToolMutability::Mutating
731 );
732 assert!(!executor.call_concurrency_safe(&set_call));
733 }
734
735 #[tokio::test]
736 async fn test_executor_recovers_truncated_json_arguments() {
737 let dir = tempfile::tempdir().unwrap();
738 let path = dir.path().join("recovered-write.txt");
739
740 let malformed_args = format!(
742 r#"{{"file_path":"{}","content":"recovered content""#,
743 path.display()
744 );
745
746 let executor = BuiltinToolExecutor::new();
747 let call = make_tool_call_with_raw_args("Write", &malformed_args);
748
749 let result = executor
750 .execute(&call)
751 .await
752 .expect("truncated JSON should be auto-repaired");
753 assert!(result.success);
754
755 let written = fs::read_to_string(&path)
756 .await
757 .expect("file should be written");
758 assert_eq!(written, "recovered content");
759 }
760
761 #[test]
762 fn test_normalize_tool_ref_rejects_unknown_tool() {
763 assert_eq!(normalize_tool_ref("default::search"), None);
764 }
765
766 #[test]
767 fn test_executor_does_not_expose_legacy_tools() {
768 let executor = BuiltinToolExecutor::new();
769 let tool_names: Vec<String> = executor
770 .list_tools()
771 .into_iter()
772 .map(|schema| schema.function.name)
773 .collect();
774
775 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
776 assert!(!tool_names.iter().any(|name| name == legacy));
777 }
778 }
779
780 #[test]
781 fn test_critical_tool_schemas_match_claude_shapes() {
782 let executor = BuiltinToolExecutor::new();
783 let tools = executor.list_tools();
784
785 let get_params = |name: &str| {
786 tools
787 .iter()
788 .find(|tool| tool.function.name == name)
789 .unwrap()
790 .function
791 .parameters
792 .clone()
793 };
794
795 let grep = get_params("Grep");
796 assert_eq!(grep["required"], json!(["pattern"]));
797 assert_eq!(
798 grep["properties"]["output_mode"]["enum"],
799 json!(["content", "files_with_matches", "count"])
800 );
801 assert!(grep["properties"]["-A"].is_object());
802 assert!(grep["properties"]["-B"].is_object());
803 assert!(grep["properties"]["-C"].is_object());
804 assert!(grep["properties"]["-n"].is_object());
805 assert!(grep["properties"]["-i"].is_object());
806
807 let edit = get_params("Edit");
808 assert_eq!(edit["required"], json!(["file_path"]));
809 assert_eq!(edit["properties"]["old_string"]["type"], "string");
810 assert_eq!(edit["properties"]["new_string"]["type"], "string");
811 assert_eq!(edit["properties"]["patch"]["type"], "string");
812 assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
813 assert!(edit.get("oneOf").is_none());
814
815 assert_eq!(edit["properties"]["patch"]["type"], "string");
818 assert_eq!(edit["properties"]["line_number"]["type"], "integer");
819
820 let bash = get_params("Bash");
821 assert_eq!(bash["required"], json!(["command"]));
822 assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
823 assert_eq!(bash["properties"]["workdir"]["type"], "string");
824
825 let bash_output = get_params("BashOutput");
826 assert_eq!(bash_output["required"], json!(["bash_id"]));
827 assert_eq!(bash_output["properties"]["filter"]["type"], "string");
828 }
829
830 #[test]
831 fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
832 let executor = BuiltinToolExecutor::new();
833 let tools = executor.list_tools();
834 let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
835
836 for tool in tools {
837 let params = &tool.function.parameters;
838 assert_eq!(
839 params["type"], "object",
840 "tool '{}' parameters must be a top-level object schema",
841 tool.function.name
842 );
843 for key in forbidden {
844 assert!(
845 params.get(key).is_none(),
846 "tool '{}' parameters contains forbidden top-level keyword '{}'",
847 tool.function.name,
848 key
849 );
850 }
851 }
852 }
853
854 #[test]
855 fn test_executor_has_all_builtin_tools() {
856 let executor = BuiltinToolExecutor::new();
857 let tools = executor.list_tools();
858
859 assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
860
861 let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
862 for tool_name in BUILTIN_TOOL_NAMES {
863 assert!(tool_names.contains(&tool_name.to_string()));
864 }
865 }
866
867 #[test]
868 fn test_executor_builds_enhanced_prompt() {
869 let executor = BuiltinToolExecutor::new();
870 let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
871 assert!(prompt.contains("## Tool Usage Guidelines"));
872 assert!(prompt.contains("**Read**"));
873 }
874
875 #[test]
876 fn test_executor_builder_empty() {
877 let executor = BuiltinToolExecutorBuilder::new().build();
878 assert!(executor.list_tools().is_empty());
879 }
880
881 #[test]
882 fn test_executor_builder_with_default_tools() {
883 let executor = BuiltinToolExecutorBuilder::new()
884 .with_default_tools()
885 .build();
886 assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
887 }
888
889 #[test]
890 fn test_executor_builder_with_specific_tool() {
891 let executor = BuiltinToolExecutorBuilder::new()
892 .with_filesystem_tool("Read")
893 .unwrap()
894 .build();
895
896 let tools = executor.list_tools();
897 assert_eq!(tools.len(), 1);
898 assert_eq!(tools[0].function.name, "Read");
899 }
900
901 #[tokio::test]
902 async fn test_executor_skips_permission_checks_without_checker() {
903 let executor = make_executor(None);
904 let path = "/tmp/executor_permission_none.txt";
905 let _ = fs::remove_file(path).await;
906
907 let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
908 let result = executor.execute(&call).await.expect("execute tool");
909
910 assert!(result.success);
911 let _ = fs::remove_file(path).await;
912 }
913
914 #[tokio::test]
915 async fn test_executor_with_permission_checker_enforces_checks() {
916 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
917 let executor = make_executor(Some(checker));
918 let path = "/tmp/executor_permission_denied.txt";
919 let _ = fs::remove_file(path).await;
920
921 let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
922 let result = executor.execute(&call).await;
923
924 assert!(matches!(result, Err(ToolError::Execution(_))));
925 assert!(fs::metadata(path).await.is_err());
926 }
927
928 #[tokio::test]
929 async fn test_bypass_permissions_skips_checker() {
930 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
933 let executor = make_executor(Some(checker));
934 let dir = tempfile::tempdir().unwrap();
935 let path = dir.path().join("bypass_allows_write.txt");
936 let path_str = path.to_str().unwrap();
937
938 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
939 let ctx = ToolExecutionContext {
940 session_id: Some("s-bypass"),
941 tool_call_id: &call.id,
942 event_tx: None,
943 available_tool_schemas: None,
944 bypass_permissions: true,
945 };
946 let result = executor.execute_with_context(&call, ctx).await;
947
948 assert!(result.is_ok(), "bypass should allow the write: {result:?}");
949 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
950 }
951
952 #[tokio::test]
953 async fn test_forced_ask_rule_overrides_bypass() {
954 let config = Arc::new(crate::permission::PermissionConfig::new());
958 config.set_ask_rules(["Write(/etc/**)".to_string()]);
959 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
960 let executor = make_executor(Some(checker));
961
962 let call = make_tool_call(
963 "Write",
964 json!({"file_path": "/etc/forced.conf", "content": "x"}),
965 );
966 let ctx = ToolExecutionContext {
967 session_id: Some("s-forced"),
968 tool_call_id: &call.id,
969 event_tx: None,
970 available_tool_schemas: None,
971 bypass_permissions: true,
972 };
973 let result = executor.execute_with_context(&call, ctx).await;
974
975 assert!(
976 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("approval required")),
977 "forced ask rule should block under bypass: {result:?}"
978 );
979 assert!(fs::metadata("/etc/forced.conf").await.is_err());
980 }
981
982 struct HostStub {
985 approve: bool,
986 }
987
988 #[async_trait]
989 impl crate::approval::ApprovalProxy for HostStub {
990 async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
991 self.approve
992 }
993 }
994
995 #[tokio::test]
996 async fn approval_proxy_grant_lets_gated_tool_proceed() {
997 let dir = tempfile::tempdir().unwrap();
1002 let path = dir.path().join("approved.txt");
1003 let path_str = path.to_str().unwrap().to_string();
1004 let config = Arc::new(crate::permission::PermissionConfig::new());
1005 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1006 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1007 let executor = make_executor(Some(checker));
1008
1009 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1010 let ctx = ToolExecutionContext {
1011 session_id: Some("s-worker"),
1012 tool_call_id: &call.id,
1013 event_tx: None,
1014 available_tool_schemas: None,
1015 bypass_permissions: false,
1016 };
1017
1018 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
1019 let result = crate::approval::with_approval_proxy(
1020 Some(proxy),
1021 executor.execute_with_context(&call, ctx),
1022 )
1023 .await;
1024
1025 assert!(
1026 result.is_ok(),
1027 "host grant should let the write through: {result:?}"
1028 );
1029 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1030 }
1031
1032 #[tokio::test]
1033 async fn approval_proxy_deny_fails_gated_tool_closed() {
1034 let dir = tempfile::tempdir().unwrap();
1037 let path = dir.path().join("denied.txt");
1038 let path_str = path.to_str().unwrap().to_string();
1039 let config = Arc::new(crate::permission::PermissionConfig::new());
1040 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1041 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1042 let executor = make_executor(Some(checker));
1043
1044 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
1045 let ctx = ToolExecutionContext {
1046 session_id: Some("s-worker"),
1047 tool_call_id: &call.id,
1048 event_tx: None,
1049 available_tool_schemas: None,
1050 bypass_permissions: false,
1051 };
1052
1053 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
1054 let result = crate::approval::with_approval_proxy(
1055 Some(proxy),
1056 executor.execute_with_context(&call, ctx),
1057 )
1058 .await;
1059
1060 assert!(
1061 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
1062 "host deny should fail the tool closed: {result:?}"
1063 );
1064 assert!(fs::metadata(&path).await.is_err());
1065 }
1066
1067 #[tokio::test]
1068 async fn tool_can_stream_events_via_execute_with_context() {
1069 struct StreamingTool;
1070
1071 #[async_trait]
1072 impl Tool for StreamingTool {
1073 fn name(&self) -> &str {
1074 "streaming_tool"
1075 }
1076
1077 fn description(&self) -> &str {
1078 "streams one token"
1079 }
1080
1081 fn parameters_schema(&self) -> serde_json::Value {
1082 json!({"type":"object","properties":{}})
1083 }
1084
1085 async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult, ToolError> {
1086 Ok(ToolResult {
1087 success: true,
1088 result: "ok".to_string(),
1089 display_preference: None,
1090 images: Vec::new(),
1091 })
1092 }
1093
1094 async fn execute_with_context(
1095 &self,
1096 args: serde_json::Value,
1097 ctx: ToolExecutionContext<'_>,
1098 ) -> Result<ToolResult, ToolError> {
1099 ctx.emit(AgentEvent::Token {
1100 content: "stream".to_string(),
1101 })
1102 .await;
1103 self.execute(args).await
1104 }
1105 }
1106
1107 let executor = BuiltinToolExecutor::new();
1108 executor
1109 .register_tool(StreamingTool)
1110 .expect("register streaming tool");
1111
1112 let (tx, mut rx) = mpsc::channel(8);
1113 let call = make_tool_call("streaming_tool", json!({}));
1114
1115 let result = executor
1116 .execute_with_context(
1117 &call,
1118 ToolExecutionContext {
1119 session_id: Some("s1"),
1120 tool_call_id: &call.id,
1121 event_tx: Some(&tx),
1122 available_tool_schemas: None,
1123 bypass_permissions: false,
1124 },
1125 )
1126 .await
1127 .expect("execute tool");
1128
1129 assert!(result.success);
1130 assert_eq!(result.result, "ok");
1131
1132 let ev = rx.recv().await.expect("expected streamed event");
1133 assert!(
1134 matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
1135 );
1136 }
1137
1138 #[tokio::test]
1139 async fn removed_legacy_tools_return_not_found() {
1140 let executor = BuiltinToolExecutor::new();
1141
1142 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1143 let call = make_tool_call(legacy, json!({}));
1144 let result = executor.execute(&call).await;
1145 assert!(matches!(result, Err(ToolError::NotFound(_))));
1146 }
1147 }
1148
1149 #[tokio::test]
1150 async fn executor_prefers_exact_tool_name_before_builtin_alias() {
1151 struct CustomSpawnSessionTool;
1152
1153 #[async_trait]
1154 impl Tool for CustomSpawnSessionTool {
1155 fn name(&self) -> &str {
1156 "spawn_session"
1157 }
1158
1159 fn description(&self) -> &str {
1160 "custom tool for regression coverage"
1161 }
1162
1163 fn parameters_schema(&self) -> serde_json::Value {
1164 json!({"type":"object","properties":{}})
1165 }
1166
1167 async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult, ToolError> {
1168 Ok(ToolResult {
1169 success: true,
1170 result: "custom-spawn-session".to_string(),
1171 display_preference: None,
1172 images: Vec::new(),
1173 })
1174 }
1175 }
1176
1177 let executor = BuiltinToolExecutorBuilder::new()
1178 .with_tool(CustomSpawnSessionTool)
1179 .expect("register custom spawn_session tool")
1180 .build();
1181
1182 let call = make_tool_call("spawn_session", json!({}));
1183 let result = executor.execute(&call).await.expect("execute custom tool");
1184 assert!(result.success);
1185 assert_eq!(result.result, "custom-spawn-session");
1186 }
1187}