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 BashInputTool, BashOutputTool, BashTool, ConclusionWithOptionsTool, EditTool,
14 EnterPlanModeTool, ExitPlanModeTool, GetFileInfoTool, GlobTool, GrepTool, JsReplTool,
15 KillShellTool, NotebookEditTool, ReadTool, RequestPermissionsTool, SessionNoteTool, SleepTool,
16 TaskTool, 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(BashInputTool::new());
167 let _ = registry.register(BashOutputTool::new());
168 let _ = registry.register(EditTool::new());
169 let _ = registry.register(EnterPlanModeTool::new());
170 let _ = registry.register(ExitPlanModeTool::new());
171 let _ = registry.register(GetFileInfoTool::new());
173 let _ = registry.register(GlobTool::new());
174 let _ = registry.register(GrepTool::new());
175 let _ = registry.register(UpdateGoalTool::new());
176 let _ = registry.register(JsReplTool::new());
177 let _ = registry.register(KillShellTool::new());
178 let _ = registry.register(SessionNoteTool::new());
179 let _ = registry.register(NotebookEditTool::new());
180 let _ = registry.register(ReadTool::new());
181 let _ = registry.register(RequestPermissionsTool::new());
182 let _ = registry.register(SleepTool::new());
183 let _ = registry.register(TaskTool::new());
184 let _ = registry.register(WebFetchTool::new());
185 let _ = registry.register(WebSearchTool::new());
186 let _ = registry.register(WorkspaceTool::new());
188 let _ = registry.register(WriteTool::new());
189 }
190
191 pub fn tool_schemas() -> Vec<ToolSchema> {
193 let registry = ToolRegistry::new();
194 Self::register_builtin_tools(®istry, None);
195 registry.list_tools()
196 }
197
198 pub fn register_tool<T: Tool + 'static>(&self, tool: T) -> Result<(), ToolError> {
200 self.registry
201 .register(tool)
202 .map_err(|e| ToolError::Execution(e.to_string()))
203 }
204
205 pub fn register_tool_with_guide<T, G>(&self, tool: T, guide: G) -> Result<(), ToolError>
207 where
208 T: Tool + 'static,
209 G: ToolGuide + 'static,
210 {
211 self.registry
212 .register_with_guide(tool, guide)
213 .map_err(|e| ToolError::Execution(e.to_string()))
214 }
215
216 pub fn get_guide(&self, tool_name: &str) -> Option<Arc<dyn ToolGuide>> {
218 self.registry.get_guide(tool_name)
219 }
220
221 pub fn build_enhanced_prompt(&self, context: GuideBuildContext) -> String {
223 EnhancedPromptBuilder::build(Some(&self.registry), &self.registry.list_tools(), &context)
224 }
225}
226
227fn permission_error_to_tool_error(error: PermissionError) -> ToolError {
228 match error {
229 PermissionError::CheckFailed(_) => ToolError::InvalidArguments(error.to_string()),
230 _ => ToolError::Execution(error.to_string()),
231 }
232}
233
234impl Default for BuiltinToolExecutor {
235 fn default() -> Self {
236 Self::new()
237 }
238}
239
240#[async_trait]
241impl ToolExecutor for BuiltinToolExecutor {
242 async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
243 self.execute_with_context(call, ToolExecutionContext::none(&call.id))
244 .await
245 }
246
247 async fn execute_with_context(
248 &self,
249 call: &ToolCall,
250 ctx: ToolExecutionContext<'_>,
251 ) -> Result<ToolResult, ToolError> {
252 let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
262 pre_parsed.clone()
263 } else {
264 let args_raw = call.function.arguments.trim();
265 let (parsed, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
266 if let Some(warning) = parse_warning {
267 tracing::warn!(
268 "Builtin tool argument parsing fallback applied: session_id={:?}, tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", warning={}",
269 ctx.session_id,
270 call.id,
271 call.function.name,
272 args_raw.len(),
273 preview_for_log(args_raw, 180),
274 warning
275 );
276 }
277 parsed
278 };
279
280 let raw_tool_name = normalize_tool_name(&call.function.name);
281 if let Some(args_obj) = args.as_object_mut() {
282 normalize_legacy_builtin_args(raw_tool_name, args_obj);
283 }
284
285 let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
286
287 let tool = self
289 .registry
290 .get(&tool_name)
291 .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", tool_name)))?;
292
293 if let Some(permission_checker) = &self.permission_checker {
294 if let Some(contexts) =
295 check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
296 {
297 let force_ask = permission_checker.requires_forced_confirmation(&tool_name, &args);
302 for context in contexts {
303 if ctx.bypass_permissions && !force_ask {
304 continue;
305 }
306 let resource = context.resource.clone();
307 let decision = if force_ask {
310 permission_checker.check_or_request_forced(context).await
311 } else {
312 permission_checker.check_or_request(context).await
313 };
314 match decision {
315 Ok(true) => {}
316 Ok(false) => {
317 return Err(ToolError::Execution(format!(
318 "Permission denied for: {}",
319 resource
320 )));
321 }
322 Err(PermissionError::ConfirmationRequired {
323 permission_type,
324 resource: _,
325 }) => {
326 if let Some(proxy) = crate::approval::current_approval_proxy() {
337 let approved = proxy
338 .request_approval(crate::approval::ApprovalAsk {
339 tool_name: tool_name.clone(),
340 permission: permission_type.description().to_string(),
341 resource: resource.clone(),
342 })
343 .await;
344 if approved {
345 continue;
348 }
349 return Err(ToolError::Execution(format!(
350 "Permission denied by host for: {}",
351 resource
352 )));
353 }
354
355 if let Some(tx) = ctx.event_tx {
362 let _ = tx
364 .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
365 tool_call_id: call.id.clone(),
366 tool_name: tool_name.clone(),
367 parameters: args.clone(),
368 })
369 .await;
370
371 let question = format!(
372 "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
373 tool_name,
374 permission_type.description(),
375 resource
376 );
377 let payload = serde_json::json!({
378 "status": "awaiting_permission_approval",
379 "question": question,
380 "permission_type": permission_type,
381 "resource": resource,
382 "options": ["Approve", "Deny"],
383 "allow_custom": false,
384 });
385 return Ok(ToolResult {
386 success: true,
387 result: payload.to_string(),
388 display_preference: Some("request_permissions".to_string()),
389 images: Vec::new(),
390 });
391 }
392
393 return Err(ToolError::Execution(format!(
396 "Permission approval required for: {}",
397 resource
398 )));
399 }
400 Err(other) => {
401 return Err(permission_error_to_tool_error(other));
402 }
403 }
404 }
405 }
406 }
407
408 tool.execute_with_context(args, ctx).await
409 }
410
411 fn list_tools(&self) -> Vec<ToolSchema> {
412 self.registry.list_tools()
413 }
414
415 fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
416 self.registry
417 .get(tool_name)
418 .map(|tool| tool.mutability())
419 .unwrap_or_else(|| crate::classify_tool(tool_name))
420 }
421
422 fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
423 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
424 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
425 self.registry
426 .get(&canonical)
427 .map(|tool| tool.call_mutability(&args))
428 .unwrap_or_else(|| self.tool_mutability(&canonical))
429 }
430
431 fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
432 let canonical = resolve_registered_tool_name(&self.registry, tool_name);
433 self.registry
434 .get(&canonical)
435 .map(|tool| tool.concurrency_safe())
436 .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
437 }
438
439 fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
440 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
441 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
442 self.registry
443 .get(&canonical)
444 .map(|tool| tool.call_concurrency_safe(&args))
445 .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
446 }
447
448 fn call_parallel_classification(&self, call: &ToolCall) -> (crate::ToolMutability, bool) {
449 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
455 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
456 match self.registry.get(&canonical) {
457 Some(tool) => (
458 tool.call_mutability(&args),
459 tool.call_concurrency_safe(&args),
460 ),
461 None => (
462 self.tool_mutability(&canonical),
463 self.tool_concurrency_safe(&canonical),
464 ),
465 }
466 }
467}
468
469pub struct BuiltinToolExecutorBuilder {
471 registry: ToolRegistry,
472 permission_checker: Option<Arc<dyn PermissionChecker>>,
473}
474
475impl BuiltinToolExecutorBuilder {
476 pub fn new() -> Self {
478 Self {
479 registry: ToolRegistry::new(),
480 permission_checker: None,
481 }
482 }
483
484 pub fn with_default_tools(self) -> Self {
486 BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
487 self
488 }
489
490 pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
492 match name {
493 "Read" => self.registry.register(ReadTool::new()),
494 "Write" => self.registry.register(WriteTool::new()),
495 "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
497 "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
498 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
499 }
500 .map_err(|e| ToolError::Execution(e.to_string()))?;
501 Ok(self)
502 }
503
504 pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
506 match name {
507 "Bash" => self.registry.register(BashTool::new()),
508 "BashOutput" => self.registry.register(BashOutputTool::new()),
509 "KillShell" => self.registry.register(KillShellTool::new()),
510 "Task" => self.registry.register(TaskTool::new()),
511 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
512 }
513 .map_err(|e| ToolError::Execution(e.to_string()))?;
514 Ok(self)
515 }
516
517 pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
519 self.registry
520 .register(tool)
521 .map_err(|e| ToolError::Execution(e.to_string()))?;
522 Ok(self)
523 }
524
525 pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
527 self.permission_checker = Some(checker);
528 self
529 }
530
531 pub fn build(self) -> BuiltinToolExecutor {
533 BuiltinToolExecutor {
534 registry: self.registry,
535 permission_checker: self.permission_checker,
536 }
537 }
538}
539
540impl Default for BuiltinToolExecutorBuilder {
541 fn default() -> Self {
542 Self::new()
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 use bamboo_agent_core::AgentEvent;
550 use bamboo_agent_core::FunctionCall;
551 use bamboo_agent_core::ToolExecutionContext;
552 use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
553 use serde_json::json;
554 use std::sync::Arc;
555 use tokio::fs;
556 use tokio::sync::mpsc;
557
558 use crate::tools::WriteTool;
559
560 fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
561 ToolCall {
562 id: "call_1".to_string(),
563 tool_type: "function".to_string(),
564 function: FunctionCall {
565 name: name.to_string(),
566 arguments: args.to_string(),
567 },
568 }
569 }
570
571 fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
572 ToolCall {
573 id: "call_1".to_string(),
574 tool_type: "function".to_string(),
575 function: FunctionCall {
576 name: name.to_string(),
577 arguments: raw_args.to_string(),
578 },
579 }
580 }
581
582 fn make_executor(
583 permission_checker: Option<Arc<dyn PermissionChecker>>,
584 ) -> BuiltinToolExecutor {
585 let builder = BuiltinToolExecutorBuilder::new()
586 .with_tool(WriteTool::new())
587 .expect("register Write tool");
588
589 let builder = match permission_checker {
590 Some(checker) => builder.with_permission_checker(checker),
591 None => builder,
592 };
593
594 builder.build()
595 }
596
597 #[test]
598 fn test_normalize_tool_ref_accepts_claude_style_names() {
599 assert_eq!(
600 normalize_tool_ref("default::Bash"),
601 Some("Bash".to_string())
602 );
603 }
604
605 #[test]
606 fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
607 assert_eq!(
608 normalize_tool_ref("default::fileExists"),
609 Some("FileExists".to_string())
610 );
611 assert_eq!(
612 normalize_tool_ref("default::getCurrentDir"),
613 Some("GetCurrentDir".to_string())
614 );
615 assert_eq!(
616 normalize_tool_ref("default::getFileInfo"),
617 Some("GetFileInfo".to_string())
618 );
619 assert_eq!(
620 normalize_tool_ref("default::setWorkspace"),
621 Some("SetWorkspace".to_string())
622 );
623 assert_eq!(
624 normalize_tool_ref("default::sleep"),
625 Some("Sleep".to_string())
626 );
627 }
628
629 #[test]
630 fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
631 assert_eq!(
632 normalize_tool_ref("default::execute_command"),
633 Some("Bash".to_string())
634 );
635 assert_eq!(
636 normalize_tool_ref("default::file_exists"),
637 Some("FileExists".to_string())
638 );
639 assert_eq!(
640 normalize_tool_ref("default::get_current_dir"),
641 Some("GetCurrentDir".to_string())
642 );
643 assert_eq!(
644 normalize_tool_ref("default::get_file_info"),
645 Some("GetFileInfo".to_string())
646 );
647 assert_eq!(
648 normalize_tool_ref("default::list_directory"),
649 Some("Glob".to_string())
650 );
651 assert_eq!(
652 normalize_tool_ref("default::memory_note"),
653 Some("memory_note".to_string())
654 );
655 assert_eq!(
656 normalize_tool_ref("default::read_file"),
657 Some("Read".to_string())
658 );
659 assert_eq!(
660 normalize_tool_ref("default::set_workspace"),
661 Some("SetWorkspace".to_string())
662 );
663 assert_eq!(
664 normalize_tool_ref("default::write_file"),
665 Some("Write".to_string())
666 );
667 }
668
669 #[test]
670 fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
671 for alias in [
672 "default::spawn_session",
673 "default::sub_session",
674 "default::sub_task",
675 "default::team_agent",
676 "default::child_session",
677 ] {
678 assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
679 }
680 }
681
682 #[test]
683 fn test_normalize_tool_ref_accepts_server_overlay_tools() {
684 assert_eq!(normalize_tool_ref("compress_context"), None);
685 assert_eq!(
686 normalize_tool_ref("default::read_skill_resource"),
687 Some("read_skill_resource".to_string())
688 );
689 }
690
691 #[tokio::test]
692 async fn test_executor_accepts_legacy_read_file_path_argument() {
693 let dir = tempfile::tempdir().unwrap();
694 let file_path = dir.path().join("legacy-read.txt");
695 fs::write(&file_path, "legacy read content").await.unwrap();
696
697 let executor = BuiltinToolExecutor::new();
698 let call = make_tool_call("read_file", json!({"path": file_path}));
699
700 let result = executor.execute(&call).await.unwrap();
701 assert!(result.success);
702 assert!(result.result.contains("legacy read content"));
703 }
704
705 #[tokio::test]
706 async fn test_executor_accepts_legacy_list_directory_without_pattern() {
707 let dir = tempfile::tempdir().unwrap();
708 let file_path = dir.path().join("legacy-list.txt");
709 fs::write(&file_path, "legacy list content").await.unwrap();
710
711 let executor = BuiltinToolExecutor::new();
712 let call = make_tool_call("list_directory", json!({"path": dir.path()}));
713
714 let result = executor.execute(&call).await.unwrap();
715 assert!(result.success);
716 assert!(result.result.contains("legacy-list.txt"));
717 }
718
719 #[tokio::test]
720 async fn test_executor_accepts_canonical_read_with_path_argument() {
721 let dir = tempfile::tempdir().unwrap();
722 let file_path = dir.path().join("canonical-read.txt");
723 fs::write(&file_path, "canonical read content")
724 .await
725 .unwrap();
726
727 let executor = BuiltinToolExecutor::new();
728 let call = make_tool_call("Read", json!({"path": file_path}));
729
730 let result = executor.execute(&call).await.unwrap();
731 assert!(result.success);
732 assert!(result.result.contains("canonical read content"));
733 }
734
735 #[tokio::test]
736 async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
737 let dir = tempfile::tempdir().unwrap();
738 let file_path = dir.path().join("canonical-list.txt");
739 fs::write(&file_path, "canonical list content")
740 .await
741 .unwrap();
742
743 let executor = BuiltinToolExecutor::new();
744 let call = make_tool_call("Glob", json!({"path": dir.path()}));
745
746 let result = executor.execute(&call).await.unwrap();
747 assert!(result.success);
748 assert!(result.result.contains("canonical-list.txt"));
749 }
750
751 #[test]
752 fn test_executor_workspace_mutability_depends_on_path_argument() {
753 let executor = BuiltinToolExecutor::new();
754 let get_call = make_tool_call("Workspace", json!({}));
755 let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
756
757 assert_eq!(
758 executor.call_mutability(&get_call),
759 crate::ToolMutability::ReadOnly
760 );
761 assert!(executor.call_concurrency_safe(&get_call));
762
763 assert_eq!(
764 executor.call_mutability(&set_call),
765 crate::ToolMutability::Mutating
766 );
767 assert!(!executor.call_concurrency_safe(&set_call));
768 }
769
770 #[test]
771 fn call_parallel_classification_matches_individual_methods() {
772 let executor = BuiltinToolExecutor::new();
780 let cases: &[(&str, serde_json::Value)] = &[
781 ("Read", json!({})),
782 ("Grep", json!({"pattern": "x"})),
783 (
784 "Write",
785 json!({"file_path": "/tmp/par_cls.txt", "content": "y"}),
786 ),
787 ("Bash", json!({"command": "echo hi"})),
788 ("Workspace", json!({})),
789 ("Workspace", json!({"path": "/tmp"})),
790 ];
791
792 for (name, args) in cases {
793 let call = make_tool_call(name, args.clone());
794 let expected_mutability = executor.call_mutability(&call);
795 let expected_concurrency = executor.call_concurrency_safe(&call);
796 let (mutability, concurrency) = executor.call_parallel_classification(&call);
797 assert_eq!(
798 mutability, expected_mutability,
799 "mutability mismatch for {name} ({args})"
800 );
801 assert_eq!(
802 concurrency, expected_concurrency,
803 "concurrency mismatch for {name} ({args})"
804 );
805 }
806 }
807
808 #[test]
809 fn list_tools_snapshot_is_stable_across_calls() {
810 let executor = BuiltinToolExecutor::new();
815 let first: Vec<String> = executor
816 .list_tools()
817 .into_iter()
818 .map(|s| s.function.name)
819 .collect();
820 let second: Vec<String> = executor
821 .list_tools()
822 .into_iter()
823 .map(|s| s.function.name)
824 .collect();
825 assert!(!first.is_empty(), "builtin executor should expose tools");
826 assert_eq!(
827 first, second,
828 "list_tools() must be deterministic per round"
829 );
830 }
831
832 #[tokio::test]
833 async fn test_executor_recovers_truncated_json_arguments() {
834 let dir = tempfile::tempdir().unwrap();
835 let path = dir.path().join("recovered-write.txt");
836
837 let malformed_args = format!(
839 r#"{{"file_path":"{}","content":"recovered content""#,
840 path.display()
841 );
842
843 let executor = BuiltinToolExecutor::new();
844 let call = make_tool_call_with_raw_args("Write", &malformed_args);
845
846 let result = executor
847 .execute(&call)
848 .await
849 .expect("truncated JSON should be auto-repaired");
850 assert!(result.success);
851
852 let written = fs::read_to_string(&path)
853 .await
854 .expect("file should be written");
855 assert_eq!(written, "recovered content");
856 }
857
858 #[test]
859 fn test_normalize_tool_ref_rejects_unknown_tool() {
860 assert_eq!(normalize_tool_ref("default::search"), None);
861 }
862
863 #[test]
864 fn test_executor_does_not_expose_legacy_tools() {
865 let executor = BuiltinToolExecutor::new();
866 let tool_names: Vec<String> = executor
867 .list_tools()
868 .into_iter()
869 .map(|schema| schema.function.name)
870 .collect();
871
872 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
873 assert!(!tool_names.iter().any(|name| name == legacy));
874 }
875 }
876
877 #[test]
878 fn test_critical_tool_schemas_match_claude_shapes() {
879 let executor = BuiltinToolExecutor::new();
880 let tools = executor.list_tools();
881
882 let get_params = |name: &str| {
883 tools
884 .iter()
885 .find(|tool| tool.function.name == name)
886 .unwrap()
887 .function
888 .parameters
889 .clone()
890 };
891
892 let grep = get_params("Grep");
893 assert_eq!(grep["required"], json!(["pattern"]));
894 assert_eq!(
895 grep["properties"]["output_mode"]["enum"],
896 json!(["content", "files_with_matches", "count"])
897 );
898 assert!(grep["properties"]["-A"].is_object());
899 assert!(grep["properties"]["-B"].is_object());
900 assert!(grep["properties"]["-C"].is_object());
901 assert!(grep["properties"]["-n"].is_object());
902 assert!(grep["properties"]["-i"].is_object());
903
904 let edit = get_params("Edit");
905 assert_eq!(edit["required"], json!(["file_path"]));
906 assert_eq!(edit["properties"]["old_string"]["type"], "string");
907 assert_eq!(edit["properties"]["new_string"]["type"], "string");
908 assert_eq!(edit["properties"]["patch"]["type"], "string");
909 assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
910 assert!(edit.get("oneOf").is_none());
911
912 assert_eq!(edit["properties"]["patch"]["type"], "string");
915 assert_eq!(edit["properties"]["line_number"]["type"], "integer");
916
917 let bash = get_params("Bash");
918 assert_eq!(bash["required"], json!(["command"]));
919 assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
920 assert_eq!(bash["properties"]["workdir"]["type"], "string");
921
922 let bash_output = get_params("BashOutput");
923 assert_eq!(bash_output["required"], json!(["bash_id"]));
924 assert_eq!(bash_output["properties"]["filter"]["type"], "string");
925 }
926
927 #[test]
928 fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
929 let executor = BuiltinToolExecutor::new();
930 let tools = executor.list_tools();
931 let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
932
933 for tool in tools {
934 let params = &tool.function.parameters;
935 assert_eq!(
936 params["type"], "object",
937 "tool '{}' parameters must be a top-level object schema",
938 tool.function.name
939 );
940 for key in forbidden {
941 assert!(
942 params.get(key).is_none(),
943 "tool '{}' parameters contains forbidden top-level keyword '{}'",
944 tool.function.name,
945 key
946 );
947 }
948 }
949 }
950
951 #[test]
952 fn test_executor_has_all_builtin_tools() {
953 let executor = BuiltinToolExecutor::new();
954 let tools = executor.list_tools();
955
956 assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
957
958 let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
959 for tool_name in BUILTIN_TOOL_NAMES {
960 assert!(tool_names.contains(&tool_name.to_string()));
961 }
962 }
963
964 #[test]
965 fn test_executor_builds_enhanced_prompt() {
966 let executor = BuiltinToolExecutor::new();
967 let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
968 assert!(prompt.contains("## Tool Usage Guidelines"));
969 assert!(prompt.contains("**Read**"));
970 }
971
972 #[test]
973 fn test_executor_builder_empty() {
974 let executor = BuiltinToolExecutorBuilder::new().build();
975 assert!(executor.list_tools().is_empty());
976 }
977
978 #[test]
979 fn test_executor_builder_with_default_tools() {
980 let executor = BuiltinToolExecutorBuilder::new()
981 .with_default_tools()
982 .build();
983 assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
984 }
985
986 #[test]
987 fn test_executor_builder_with_specific_tool() {
988 let executor = BuiltinToolExecutorBuilder::new()
989 .with_filesystem_tool("Read")
990 .unwrap()
991 .build();
992
993 let tools = executor.list_tools();
994 assert_eq!(tools.len(), 1);
995 assert_eq!(tools[0].function.name, "Read");
996 }
997
998 #[tokio::test]
999 async fn test_executor_skips_permission_checks_without_checker() {
1000 let executor = make_executor(None);
1001 let path = "/tmp/executor_permission_none.txt";
1002 let _ = fs::remove_file(path).await;
1003
1004 let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
1005 let result = executor.execute(&call).await.expect("execute tool");
1006
1007 assert!(result.success);
1008 let _ = fs::remove_file(path).await;
1009 }
1010
1011 #[tokio::test]
1012 async fn test_executor_with_permission_checker_enforces_checks() {
1013 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1014 let executor = make_executor(Some(checker));
1015 let path = "/tmp/executor_permission_denied.txt";
1016 let _ = fs::remove_file(path).await;
1017
1018 let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
1019 let result = executor.execute(&call).await;
1020
1021 assert!(matches!(result, Err(ToolError::Execution(_))));
1022 assert!(fs::metadata(path).await.is_err());
1023 }
1024
1025 #[tokio::test]
1026 async fn test_bypass_permissions_skips_checker() {
1027 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1030 let executor = make_executor(Some(checker));
1031 let dir = tempfile::tempdir().unwrap();
1032 let path = dir.path().join("bypass_allows_write.txt");
1033 let path_str = path.to_str().unwrap();
1034
1035 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1036 let ctx = ToolExecutionContext {
1037 session_id: Some("s-bypass"),
1038 tool_call_id: &call.id,
1039 event_tx: None,
1040 available_tool_schemas: None,
1041 bypass_permissions: true,
1042 can_async_resume: false,
1043 pre_parsed_args: None,
1044 };
1045 let result = executor.execute_with_context(&call, ctx).await;
1046
1047 assert!(result.is_ok(), "bypass should allow the write: {result:?}");
1048 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1049 }
1050
1051 #[tokio::test]
1052 async fn test_forced_ask_rule_overrides_bypass() {
1053 let config = Arc::new(crate::permission::PermissionConfig::new());
1057 config.set_ask_rules(["Write(/etc/**)".to_string()]);
1058 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1059 let executor = make_executor(Some(checker));
1060
1061 let call = make_tool_call(
1062 "Write",
1063 json!({"file_path": "/etc/forced.conf", "content": "x"}),
1064 );
1065 let ctx = ToolExecutionContext {
1066 session_id: Some("s-forced"),
1067 tool_call_id: &call.id,
1068 event_tx: None,
1069 available_tool_schemas: None,
1070 bypass_permissions: true,
1071 can_async_resume: false,
1072 pre_parsed_args: None,
1073 };
1074 let result = executor.execute_with_context(&call, ctx).await;
1075
1076 assert!(
1077 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("approval required")),
1078 "forced ask rule should block under bypass: {result:?}"
1079 );
1080 assert!(fs::metadata("/etc/forced.conf").await.is_err());
1081 }
1082
1083 struct HostStub {
1086 approve: bool,
1087 }
1088
1089 #[async_trait]
1090 impl crate::approval::ApprovalProxy for HostStub {
1091 async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
1092 self.approve
1093 }
1094 }
1095
1096 #[tokio::test]
1097 async fn approval_proxy_grant_lets_gated_tool_proceed() {
1098 let dir = tempfile::tempdir().unwrap();
1103 let path = dir.path().join("approved.txt");
1104 let path_str = path.to_str().unwrap().to_string();
1105 let config = Arc::new(crate::permission::PermissionConfig::new());
1106 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1107 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1108 let executor = make_executor(Some(checker));
1109
1110 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1111 let ctx = ToolExecutionContext {
1112 session_id: Some("s-worker"),
1113 tool_call_id: &call.id,
1114 event_tx: None,
1115 available_tool_schemas: None,
1116 bypass_permissions: false,
1117 can_async_resume: false,
1118 pre_parsed_args: None,
1119 };
1120
1121 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
1122 let result = crate::approval::with_approval_proxy(
1123 Some(proxy),
1124 executor.execute_with_context(&call, ctx),
1125 )
1126 .await;
1127
1128 assert!(
1129 result.is_ok(),
1130 "host grant should let the write through: {result:?}"
1131 );
1132 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1133 }
1134
1135 #[tokio::test]
1136 async fn approval_proxy_deny_fails_gated_tool_closed() {
1137 let dir = tempfile::tempdir().unwrap();
1140 let path = dir.path().join("denied.txt");
1141 let path_str = path.to_str().unwrap().to_string();
1142 let config = Arc::new(crate::permission::PermissionConfig::new());
1143 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1144 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1145 let executor = make_executor(Some(checker));
1146
1147 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
1148 let ctx = ToolExecutionContext {
1149 session_id: Some("s-worker"),
1150 tool_call_id: &call.id,
1151 event_tx: None,
1152 available_tool_schemas: None,
1153 bypass_permissions: false,
1154 can_async_resume: false,
1155 pre_parsed_args: None,
1156 };
1157
1158 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
1159 let result = crate::approval::with_approval_proxy(
1160 Some(proxy),
1161 executor.execute_with_context(&call, ctx),
1162 )
1163 .await;
1164
1165 assert!(
1166 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
1167 "host deny should fail the tool closed: {result:?}"
1168 );
1169 assert!(fs::metadata(&path).await.is_err());
1170 }
1171
1172 #[tokio::test]
1173 async fn tool_can_stream_events_via_execute_with_context() {
1174 struct StreamingTool;
1175
1176 #[async_trait]
1177 impl Tool for StreamingTool {
1178 fn name(&self) -> &str {
1179 "streaming_tool"
1180 }
1181
1182 fn description(&self) -> &str {
1183 "streams one token"
1184 }
1185
1186 fn parameters_schema(&self) -> serde_json::Value {
1187 json!({"type":"object","properties":{}})
1188 }
1189
1190 async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult, ToolError> {
1191 Ok(ToolResult {
1192 success: true,
1193 result: "ok".to_string(),
1194 display_preference: None,
1195 images: Vec::new(),
1196 })
1197 }
1198
1199 async fn execute_with_context(
1200 &self,
1201 args: serde_json::Value,
1202 ctx: ToolExecutionContext<'_>,
1203 ) -> Result<ToolResult, ToolError> {
1204 ctx.emit(AgentEvent::Token {
1205 content: "stream".to_string(),
1206 })
1207 .await;
1208 self.execute(args).await
1209 }
1210 }
1211
1212 let executor = BuiltinToolExecutor::new();
1213 executor
1214 .register_tool(StreamingTool)
1215 .expect("register streaming tool");
1216
1217 let (tx, mut rx) = mpsc::channel(8);
1218 let call = make_tool_call("streaming_tool", json!({}));
1219
1220 let result = executor
1221 .execute_with_context(
1222 &call,
1223 ToolExecutionContext {
1224 session_id: Some("s1"),
1225 tool_call_id: &call.id,
1226 event_tx: Some(&tx),
1227 available_tool_schemas: None,
1228 bypass_permissions: false,
1229 can_async_resume: false,
1230 pre_parsed_args: None,
1231 },
1232 )
1233 .await
1234 .expect("execute tool");
1235
1236 assert!(result.success);
1237 assert_eq!(result.result, "ok");
1238
1239 let ev = rx.recv().await.expect("expected streamed event");
1240 assert!(
1241 matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
1242 );
1243 }
1244
1245 #[tokio::test]
1246 async fn removed_legacy_tools_return_not_found() {
1247 let executor = BuiltinToolExecutor::new();
1248
1249 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1250 let call = make_tool_call(legacy, json!({}));
1251 let result = executor.execute(&call).await;
1252 assert!(matches!(result, Err(ToolError::NotFound(_))));
1253 }
1254 }
1255
1256 #[tokio::test]
1257 async fn executor_prefers_exact_tool_name_before_builtin_alias() {
1258 struct CustomSpawnSessionTool;
1259
1260 #[async_trait]
1261 impl Tool for CustomSpawnSessionTool {
1262 fn name(&self) -> &str {
1263 "spawn_session"
1264 }
1265
1266 fn description(&self) -> &str {
1267 "custom tool for regression coverage"
1268 }
1269
1270 fn parameters_schema(&self) -> serde_json::Value {
1271 json!({"type":"object","properties":{}})
1272 }
1273
1274 async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult, ToolError> {
1275 Ok(ToolResult {
1276 success: true,
1277 result: "custom-spawn-session".to_string(),
1278 display_preference: None,
1279 images: Vec::new(),
1280 })
1281 }
1282 }
1283
1284 let executor = BuiltinToolExecutorBuilder::new()
1285 .with_tool(CustomSpawnSessionTool)
1286 .expect("register custom spawn_session tool")
1287 .build();
1288
1289 let call = make_tool_call("spawn_session", json!({}));
1290 let result = executor.execute(&call).await.expect("execute custom tool");
1291 assert!(result.success);
1292 assert_eq!(result.result, "custom-spawn-session");
1293 }
1294
1295 struct EchoArgsTool;
1300
1301 #[async_trait]
1302 impl Tool for EchoArgsTool {
1303 fn name(&self) -> &str {
1304 "echo_args"
1305 }
1306 fn description(&self) -> &str {
1307 "echoes the `v` arg"
1308 }
1309 fn parameters_schema(&self) -> serde_json::Value {
1310 json!({"type":"object","properties":{"v":{"type":"string"}}})
1311 }
1312 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
1313 let v = args
1314 .get("v")
1315 .and_then(serde_json::Value::as_str)
1316 .unwrap_or("<none>")
1317 .to_string();
1318 Ok(ToolResult {
1319 success: true,
1320 result: v,
1321 display_preference: None,
1322 images: Vec::new(),
1323 })
1324 }
1325 }
1326
1327 fn ctx_with_pre_parsed<'a>(
1328 call_id: &'a str,
1329 pre_parsed: Option<&'a serde_json::Value>,
1330 ) -> ToolExecutionContext<'a> {
1331 ToolExecutionContext {
1332 session_id: Some("s-106"),
1333 tool_call_id: call_id,
1334 event_tx: None,
1335 available_tool_schemas: None,
1336 bypass_permissions: false,
1337 can_async_resume: false,
1338 pre_parsed_args: pre_parsed,
1339 }
1340 }
1341
1342 #[tokio::test]
1343 async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
1344 let executor = BuiltinToolExecutor::new();
1350 executor.register_tool(EchoArgsTool).expect("register echo");
1351
1352 let call = make_tool_call("echo_args", json!({"v": "raw"}));
1353 let pre_parsed = json!({"v": "preparsed"});
1354 let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
1355
1356 let result = executor
1357 .execute_with_context(&call, ctx)
1358 .await
1359 .expect("execute echo tool");
1360 assert_eq!(
1361 result.result, "preparsed",
1362 "executor must reuse pre_parsed_args, not re-parse the raw string"
1363 );
1364 }
1365
1366 #[tokio::test]
1367 async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
1368 let executor = BuiltinToolExecutor::new();
1372 executor.register_tool(EchoArgsTool).expect("register echo");
1373
1374 let call = make_tool_call("echo_args", json!({"v": "raw"}));
1375 let ctx = ctx_with_pre_parsed(&call.id, None);
1376
1377 let result = executor
1378 .execute_with_context(&call, ctx)
1379 .await
1380 .expect("execute echo tool");
1381 assert_eq!(
1382 result.result, "raw",
1383 "without pre_parsed_args the executor parses the raw string as before"
1384 );
1385 }
1386
1387 #[tokio::test]
1388 async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
1389 let dir = tempfile::tempdir().unwrap();
1393 let path = dir.path().join("recovered-no-preparsed.txt");
1394 let malformed_args = format!(
1395 r#"{{"file_path":"{}","content":"recovered content""#,
1396 path.display()
1397 );
1398
1399 let executor = BuiltinToolExecutor::new();
1400 let call = make_tool_call_with_raw_args("Write", &malformed_args);
1401 let ctx = ctx_with_pre_parsed(&call.id, None);
1402
1403 let result = executor
1404 .execute_with_context(&call, ctx)
1405 .await
1406 .expect("truncated JSON should be auto-repaired");
1407 assert!(result.success);
1408 let written = fs::read_to_string(&path).await.expect("file written");
1409 assert_eq!(written, "recovered content");
1410 }
1411}