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 args_raw = call.function.arguments.trim();
253 let (mut args, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
254 if let Some(warning) = parse_warning {
255 tracing::warn!(
256 "Builtin tool argument parsing fallback applied: session_id={:?}, tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", warning={}",
257 ctx.session_id,
258 call.id,
259 call.function.name,
260 args_raw.len(),
261 preview_for_log(args_raw, 180),
262 warning
263 );
264 }
265
266 let raw_tool_name = normalize_tool_name(&call.function.name);
267 if let Some(args_obj) = args.as_object_mut() {
268 normalize_legacy_builtin_args(raw_tool_name, args_obj);
269 }
270
271 let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
272
273 let tool = self
275 .registry
276 .get(&tool_name)
277 .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", tool_name)))?;
278
279 if let Some(permission_checker) = &self.permission_checker {
280 if let Some(contexts) =
281 check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
282 {
283 let force_ask = permission_checker.requires_forced_confirmation(&tool_name, &args);
288 for context in contexts {
289 if ctx.bypass_permissions && !force_ask {
290 continue;
291 }
292 let resource = context.resource.clone();
293 let decision = if force_ask {
296 permission_checker.check_or_request_forced(context).await
297 } else {
298 permission_checker.check_or_request(context).await
299 };
300 match decision {
301 Ok(true) => {}
302 Ok(false) => {
303 return Err(ToolError::Execution(format!(
304 "Permission denied for: {}",
305 resource
306 )));
307 }
308 Err(PermissionError::ConfirmationRequired {
309 permission_type,
310 resource: _,
311 }) => {
312 if let Some(proxy) = crate::approval::current_approval_proxy() {
323 let approved = proxy
324 .request_approval(crate::approval::ApprovalAsk {
325 tool_name: tool_name.clone(),
326 permission: permission_type.description().to_string(),
327 resource: resource.clone(),
328 })
329 .await;
330 if approved {
331 continue;
334 }
335 return Err(ToolError::Execution(format!(
336 "Permission denied by host for: {}",
337 resource
338 )));
339 }
340
341 if let Some(tx) = ctx.event_tx {
348 let _ = tx
350 .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
351 tool_call_id: call.id.clone(),
352 tool_name: tool_name.clone(),
353 parameters: args.clone(),
354 })
355 .await;
356
357 let question = format!(
358 "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
359 tool_name,
360 permission_type.description(),
361 resource
362 );
363 let payload = serde_json::json!({
364 "status": "awaiting_permission_approval",
365 "question": question,
366 "permission_type": permission_type,
367 "resource": resource,
368 "options": ["Approve", "Deny"],
369 "allow_custom": false,
370 });
371 return Ok(ToolResult {
372 success: true,
373 result: payload.to_string(),
374 display_preference: Some("request_permissions".to_string()),
375 images: Vec::new(),
376 });
377 }
378
379 return Err(ToolError::Execution(format!(
382 "Permission approval required for: {}",
383 resource
384 )));
385 }
386 Err(other) => {
387 return Err(permission_error_to_tool_error(other));
388 }
389 }
390 }
391 }
392 }
393
394 tool.execute_with_context(args, ctx).await
395 }
396
397 fn list_tools(&self) -> Vec<ToolSchema> {
398 self.registry.list_tools()
399 }
400
401 fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
402 self.registry
403 .get(tool_name)
404 .map(|tool| tool.mutability())
405 .unwrap_or_else(|| crate::classify_tool(tool_name))
406 }
407
408 fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
409 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
410 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
411 self.registry
412 .get(&canonical)
413 .map(|tool| tool.call_mutability(&args))
414 .unwrap_or_else(|| self.tool_mutability(&canonical))
415 }
416
417 fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
418 let canonical = resolve_registered_tool_name(&self.registry, tool_name);
419 self.registry
420 .get(&canonical)
421 .map(|tool| tool.concurrency_safe())
422 .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
423 }
424
425 fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
426 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
427 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
428 self.registry
429 .get(&canonical)
430 .map(|tool| tool.call_concurrency_safe(&args))
431 .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
432 }
433
434 fn call_parallel_classification(&self, call: &ToolCall) -> (crate::ToolMutability, bool) {
435 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 match self.registry.get(&canonical) {
443 Some(tool) => (
444 tool.call_mutability(&args),
445 tool.call_concurrency_safe(&args),
446 ),
447 None => (
448 self.tool_mutability(&canonical),
449 self.tool_concurrency_safe(&canonical),
450 ),
451 }
452 }
453}
454
455pub struct BuiltinToolExecutorBuilder {
457 registry: ToolRegistry,
458 permission_checker: Option<Arc<dyn PermissionChecker>>,
459}
460
461impl BuiltinToolExecutorBuilder {
462 pub fn new() -> Self {
464 Self {
465 registry: ToolRegistry::new(),
466 permission_checker: None,
467 }
468 }
469
470 pub fn with_default_tools(self) -> Self {
472 BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
473 self
474 }
475
476 pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
478 match name {
479 "Read" => self.registry.register(ReadTool::new()),
480 "Write" => self.registry.register(WriteTool::new()),
481 "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
483 "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
484 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
485 }
486 .map_err(|e| ToolError::Execution(e.to_string()))?;
487 Ok(self)
488 }
489
490 pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
492 match name {
493 "Bash" => self.registry.register(BashTool::new()),
494 "BashOutput" => self.registry.register(BashOutputTool::new()),
495 "KillShell" => self.registry.register(KillShellTool::new()),
496 "Task" => self.registry.register(TaskTool::new()),
497 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
498 }
499 .map_err(|e| ToolError::Execution(e.to_string()))?;
500 Ok(self)
501 }
502
503 pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
505 self.registry
506 .register(tool)
507 .map_err(|e| ToolError::Execution(e.to_string()))?;
508 Ok(self)
509 }
510
511 pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
513 self.permission_checker = Some(checker);
514 self
515 }
516
517 pub fn build(self) -> BuiltinToolExecutor {
519 BuiltinToolExecutor {
520 registry: self.registry,
521 permission_checker: self.permission_checker,
522 }
523 }
524}
525
526impl Default for BuiltinToolExecutorBuilder {
527 fn default() -> Self {
528 Self::new()
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use bamboo_agent_core::AgentEvent;
536 use bamboo_agent_core::FunctionCall;
537 use bamboo_agent_core::ToolExecutionContext;
538 use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
539 use serde_json::json;
540 use std::sync::Arc;
541 use tokio::fs;
542 use tokio::sync::mpsc;
543
544 use crate::tools::WriteTool;
545
546 fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
547 ToolCall {
548 id: "call_1".to_string(),
549 tool_type: "function".to_string(),
550 function: FunctionCall {
551 name: name.to_string(),
552 arguments: args.to_string(),
553 },
554 }
555 }
556
557 fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
558 ToolCall {
559 id: "call_1".to_string(),
560 tool_type: "function".to_string(),
561 function: FunctionCall {
562 name: name.to_string(),
563 arguments: raw_args.to_string(),
564 },
565 }
566 }
567
568 fn make_executor(
569 permission_checker: Option<Arc<dyn PermissionChecker>>,
570 ) -> BuiltinToolExecutor {
571 let builder = BuiltinToolExecutorBuilder::new()
572 .with_tool(WriteTool::new())
573 .expect("register Write tool");
574
575 let builder = match permission_checker {
576 Some(checker) => builder.with_permission_checker(checker),
577 None => builder,
578 };
579
580 builder.build()
581 }
582
583 #[test]
584 fn test_normalize_tool_ref_accepts_claude_style_names() {
585 assert_eq!(
586 normalize_tool_ref("default::Bash"),
587 Some("Bash".to_string())
588 );
589 }
590
591 #[test]
592 fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
593 assert_eq!(
594 normalize_tool_ref("default::fileExists"),
595 Some("FileExists".to_string())
596 );
597 assert_eq!(
598 normalize_tool_ref("default::getCurrentDir"),
599 Some("GetCurrentDir".to_string())
600 );
601 assert_eq!(
602 normalize_tool_ref("default::getFileInfo"),
603 Some("GetFileInfo".to_string())
604 );
605 assert_eq!(
606 normalize_tool_ref("default::setWorkspace"),
607 Some("SetWorkspace".to_string())
608 );
609 assert_eq!(
610 normalize_tool_ref("default::sleep"),
611 Some("Sleep".to_string())
612 );
613 }
614
615 #[test]
616 fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
617 assert_eq!(
618 normalize_tool_ref("default::execute_command"),
619 Some("Bash".to_string())
620 );
621 assert_eq!(
622 normalize_tool_ref("default::file_exists"),
623 Some("FileExists".to_string())
624 );
625 assert_eq!(
626 normalize_tool_ref("default::get_current_dir"),
627 Some("GetCurrentDir".to_string())
628 );
629 assert_eq!(
630 normalize_tool_ref("default::get_file_info"),
631 Some("GetFileInfo".to_string())
632 );
633 assert_eq!(
634 normalize_tool_ref("default::list_directory"),
635 Some("Glob".to_string())
636 );
637 assert_eq!(
638 normalize_tool_ref("default::memory_note"),
639 Some("memory_note".to_string())
640 );
641 assert_eq!(
642 normalize_tool_ref("default::read_file"),
643 Some("Read".to_string())
644 );
645 assert_eq!(
646 normalize_tool_ref("default::set_workspace"),
647 Some("SetWorkspace".to_string())
648 );
649 assert_eq!(
650 normalize_tool_ref("default::write_file"),
651 Some("Write".to_string())
652 );
653 }
654
655 #[test]
656 fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
657 for alias in [
658 "default::spawn_session",
659 "default::sub_session",
660 "default::sub_task",
661 "default::team_agent",
662 "default::child_session",
663 ] {
664 assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
665 }
666 }
667
668 #[test]
669 fn test_normalize_tool_ref_accepts_server_overlay_tools() {
670 assert_eq!(normalize_tool_ref("compress_context"), None);
671 assert_eq!(
672 normalize_tool_ref("default::read_skill_resource"),
673 Some("read_skill_resource".to_string())
674 );
675 }
676
677 #[tokio::test]
678 async fn test_executor_accepts_legacy_read_file_path_argument() {
679 let dir = tempfile::tempdir().unwrap();
680 let file_path = dir.path().join("legacy-read.txt");
681 fs::write(&file_path, "legacy read content").await.unwrap();
682
683 let executor = BuiltinToolExecutor::new();
684 let call = make_tool_call("read_file", json!({"path": file_path}));
685
686 let result = executor.execute(&call).await.unwrap();
687 assert!(result.success);
688 assert!(result.result.contains("legacy read content"));
689 }
690
691 #[tokio::test]
692 async fn test_executor_accepts_legacy_list_directory_without_pattern() {
693 let dir = tempfile::tempdir().unwrap();
694 let file_path = dir.path().join("legacy-list.txt");
695 fs::write(&file_path, "legacy list content").await.unwrap();
696
697 let executor = BuiltinToolExecutor::new();
698 let call = make_tool_call("list_directory", json!({"path": dir.path()}));
699
700 let result = executor.execute(&call).await.unwrap();
701 assert!(result.success);
702 assert!(result.result.contains("legacy-list.txt"));
703 }
704
705 #[tokio::test]
706 async fn test_executor_accepts_canonical_read_with_path_argument() {
707 let dir = tempfile::tempdir().unwrap();
708 let file_path = dir.path().join("canonical-read.txt");
709 fs::write(&file_path, "canonical read content")
710 .await
711 .unwrap();
712
713 let executor = BuiltinToolExecutor::new();
714 let call = make_tool_call("Read", json!({"path": file_path}));
715
716 let result = executor.execute(&call).await.unwrap();
717 assert!(result.success);
718 assert!(result.result.contains("canonical read content"));
719 }
720
721 #[tokio::test]
722 async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
723 let dir = tempfile::tempdir().unwrap();
724 let file_path = dir.path().join("canonical-list.txt");
725 fs::write(&file_path, "canonical list content")
726 .await
727 .unwrap();
728
729 let executor = BuiltinToolExecutor::new();
730 let call = make_tool_call("Glob", json!({"path": dir.path()}));
731
732 let result = executor.execute(&call).await.unwrap();
733 assert!(result.success);
734 assert!(result.result.contains("canonical-list.txt"));
735 }
736
737 #[test]
738 fn test_executor_workspace_mutability_depends_on_path_argument() {
739 let executor = BuiltinToolExecutor::new();
740 let get_call = make_tool_call("Workspace", json!({}));
741 let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
742
743 assert_eq!(
744 executor.call_mutability(&get_call),
745 crate::ToolMutability::ReadOnly
746 );
747 assert!(executor.call_concurrency_safe(&get_call));
748
749 assert_eq!(
750 executor.call_mutability(&set_call),
751 crate::ToolMutability::Mutating
752 );
753 assert!(!executor.call_concurrency_safe(&set_call));
754 }
755
756 #[test]
757 fn call_parallel_classification_matches_individual_methods() {
758 let executor = BuiltinToolExecutor::new();
766 let cases: &[(&str, serde_json::Value)] = &[
767 ("Read", json!({})),
768 ("Grep", json!({"pattern": "x"})),
769 (
770 "Write",
771 json!({"file_path": "/tmp/par_cls.txt", "content": "y"}),
772 ),
773 ("Bash", json!({"command": "echo hi"})),
774 ("Workspace", json!({})),
775 ("Workspace", json!({"path": "/tmp"})),
776 ];
777
778 for (name, args) in cases {
779 let call = make_tool_call(name, args.clone());
780 let expected_mutability = executor.call_mutability(&call);
781 let expected_concurrency = executor.call_concurrency_safe(&call);
782 let (mutability, concurrency) = executor.call_parallel_classification(&call);
783 assert_eq!(
784 mutability, expected_mutability,
785 "mutability mismatch for {name} ({args})"
786 );
787 assert_eq!(
788 concurrency, expected_concurrency,
789 "concurrency mismatch for {name} ({args})"
790 );
791 }
792 }
793
794 #[test]
795 fn list_tools_snapshot_is_stable_across_calls() {
796 let executor = BuiltinToolExecutor::new();
801 let first: Vec<String> = executor
802 .list_tools()
803 .into_iter()
804 .map(|s| s.function.name)
805 .collect();
806 let second: Vec<String> = executor
807 .list_tools()
808 .into_iter()
809 .map(|s| s.function.name)
810 .collect();
811 assert!(!first.is_empty(), "builtin executor should expose tools");
812 assert_eq!(
813 first, second,
814 "list_tools() must be deterministic per round"
815 );
816 }
817
818 #[tokio::test]
819 async fn test_executor_recovers_truncated_json_arguments() {
820 let dir = tempfile::tempdir().unwrap();
821 let path = dir.path().join("recovered-write.txt");
822
823 let malformed_args = format!(
825 r#"{{"file_path":"{}","content":"recovered content""#,
826 path.display()
827 );
828
829 let executor = BuiltinToolExecutor::new();
830 let call = make_tool_call_with_raw_args("Write", &malformed_args);
831
832 let result = executor
833 .execute(&call)
834 .await
835 .expect("truncated JSON should be auto-repaired");
836 assert!(result.success);
837
838 let written = fs::read_to_string(&path)
839 .await
840 .expect("file should be written");
841 assert_eq!(written, "recovered content");
842 }
843
844 #[test]
845 fn test_normalize_tool_ref_rejects_unknown_tool() {
846 assert_eq!(normalize_tool_ref("default::search"), None);
847 }
848
849 #[test]
850 fn test_executor_does_not_expose_legacy_tools() {
851 let executor = BuiltinToolExecutor::new();
852 let tool_names: Vec<String> = executor
853 .list_tools()
854 .into_iter()
855 .map(|schema| schema.function.name)
856 .collect();
857
858 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
859 assert!(!tool_names.iter().any(|name| name == legacy));
860 }
861 }
862
863 #[test]
864 fn test_critical_tool_schemas_match_claude_shapes() {
865 let executor = BuiltinToolExecutor::new();
866 let tools = executor.list_tools();
867
868 let get_params = |name: &str| {
869 tools
870 .iter()
871 .find(|tool| tool.function.name == name)
872 .unwrap()
873 .function
874 .parameters
875 .clone()
876 };
877
878 let grep = get_params("Grep");
879 assert_eq!(grep["required"], json!(["pattern"]));
880 assert_eq!(
881 grep["properties"]["output_mode"]["enum"],
882 json!(["content", "files_with_matches", "count"])
883 );
884 assert!(grep["properties"]["-A"].is_object());
885 assert!(grep["properties"]["-B"].is_object());
886 assert!(grep["properties"]["-C"].is_object());
887 assert!(grep["properties"]["-n"].is_object());
888 assert!(grep["properties"]["-i"].is_object());
889
890 let edit = get_params("Edit");
891 assert_eq!(edit["required"], json!(["file_path"]));
892 assert_eq!(edit["properties"]["old_string"]["type"], "string");
893 assert_eq!(edit["properties"]["new_string"]["type"], "string");
894 assert_eq!(edit["properties"]["patch"]["type"], "string");
895 assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
896 assert!(edit.get("oneOf").is_none());
897
898 assert_eq!(edit["properties"]["patch"]["type"], "string");
901 assert_eq!(edit["properties"]["line_number"]["type"], "integer");
902
903 let bash = get_params("Bash");
904 assert_eq!(bash["required"], json!(["command"]));
905 assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
906 assert_eq!(bash["properties"]["workdir"]["type"], "string");
907
908 let bash_output = get_params("BashOutput");
909 assert_eq!(bash_output["required"], json!(["bash_id"]));
910 assert_eq!(bash_output["properties"]["filter"]["type"], "string");
911 }
912
913 #[test]
914 fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
915 let executor = BuiltinToolExecutor::new();
916 let tools = executor.list_tools();
917 let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
918
919 for tool in tools {
920 let params = &tool.function.parameters;
921 assert_eq!(
922 params["type"], "object",
923 "tool '{}' parameters must be a top-level object schema",
924 tool.function.name
925 );
926 for key in forbidden {
927 assert!(
928 params.get(key).is_none(),
929 "tool '{}' parameters contains forbidden top-level keyword '{}'",
930 tool.function.name,
931 key
932 );
933 }
934 }
935 }
936
937 #[test]
938 fn test_executor_has_all_builtin_tools() {
939 let executor = BuiltinToolExecutor::new();
940 let tools = executor.list_tools();
941
942 assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
943
944 let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
945 for tool_name in BUILTIN_TOOL_NAMES {
946 assert!(tool_names.contains(&tool_name.to_string()));
947 }
948 }
949
950 #[test]
951 fn test_executor_builds_enhanced_prompt() {
952 let executor = BuiltinToolExecutor::new();
953 let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
954 assert!(prompt.contains("## Tool Usage Guidelines"));
955 assert!(prompt.contains("**Read**"));
956 }
957
958 #[test]
959 fn test_executor_builder_empty() {
960 let executor = BuiltinToolExecutorBuilder::new().build();
961 assert!(executor.list_tools().is_empty());
962 }
963
964 #[test]
965 fn test_executor_builder_with_default_tools() {
966 let executor = BuiltinToolExecutorBuilder::new()
967 .with_default_tools()
968 .build();
969 assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
970 }
971
972 #[test]
973 fn test_executor_builder_with_specific_tool() {
974 let executor = BuiltinToolExecutorBuilder::new()
975 .with_filesystem_tool("Read")
976 .unwrap()
977 .build();
978
979 let tools = executor.list_tools();
980 assert_eq!(tools.len(), 1);
981 assert_eq!(tools[0].function.name, "Read");
982 }
983
984 #[tokio::test]
985 async fn test_executor_skips_permission_checks_without_checker() {
986 let executor = make_executor(None);
987 let path = "/tmp/executor_permission_none.txt";
988 let _ = fs::remove_file(path).await;
989
990 let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
991 let result = executor.execute(&call).await.expect("execute tool");
992
993 assert!(result.success);
994 let _ = fs::remove_file(path).await;
995 }
996
997 #[tokio::test]
998 async fn test_executor_with_permission_checker_enforces_checks() {
999 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1000 let executor = make_executor(Some(checker));
1001 let path = "/tmp/executor_permission_denied.txt";
1002 let _ = fs::remove_file(path).await;
1003
1004 let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
1005 let result = executor.execute(&call).await;
1006
1007 assert!(matches!(result, Err(ToolError::Execution(_))));
1008 assert!(fs::metadata(path).await.is_err());
1009 }
1010
1011 #[tokio::test]
1012 async fn test_bypass_permissions_skips_checker() {
1013 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1016 let executor = make_executor(Some(checker));
1017 let dir = tempfile::tempdir().unwrap();
1018 let path = dir.path().join("bypass_allows_write.txt");
1019 let path_str = path.to_str().unwrap();
1020
1021 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1022 let ctx = ToolExecutionContext {
1023 session_id: Some("s-bypass"),
1024 tool_call_id: &call.id,
1025 event_tx: None,
1026 available_tool_schemas: None,
1027 bypass_permissions: true,
1028 can_async_resume: false,
1029 };
1030 let result = executor.execute_with_context(&call, ctx).await;
1031
1032 assert!(result.is_ok(), "bypass should allow the write: {result:?}");
1033 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1034 }
1035
1036 #[tokio::test]
1037 async fn test_forced_ask_rule_overrides_bypass() {
1038 let config = Arc::new(crate::permission::PermissionConfig::new());
1042 config.set_ask_rules(["Write(/etc/**)".to_string()]);
1043 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1044 let executor = make_executor(Some(checker));
1045
1046 let call = make_tool_call(
1047 "Write",
1048 json!({"file_path": "/etc/forced.conf", "content": "x"}),
1049 );
1050 let ctx = ToolExecutionContext {
1051 session_id: Some("s-forced"),
1052 tool_call_id: &call.id,
1053 event_tx: None,
1054 available_tool_schemas: None,
1055 bypass_permissions: true,
1056 can_async_resume: false,
1057 };
1058 let result = executor.execute_with_context(&call, ctx).await;
1059
1060 assert!(
1061 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("approval required")),
1062 "forced ask rule should block under bypass: {result:?}"
1063 );
1064 assert!(fs::metadata("/etc/forced.conf").await.is_err());
1065 }
1066
1067 struct HostStub {
1070 approve: bool,
1071 }
1072
1073 #[async_trait]
1074 impl crate::approval::ApprovalProxy for HostStub {
1075 async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
1076 self.approve
1077 }
1078 }
1079
1080 #[tokio::test]
1081 async fn approval_proxy_grant_lets_gated_tool_proceed() {
1082 let dir = tempfile::tempdir().unwrap();
1087 let path = dir.path().join("approved.txt");
1088 let path_str = path.to_str().unwrap().to_string();
1089 let config = Arc::new(crate::permission::PermissionConfig::new());
1090 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1091 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1092 let executor = make_executor(Some(checker));
1093
1094 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1095 let ctx = ToolExecutionContext {
1096 session_id: Some("s-worker"),
1097 tool_call_id: &call.id,
1098 event_tx: None,
1099 available_tool_schemas: None,
1100 bypass_permissions: false,
1101 can_async_resume: false,
1102 };
1103
1104 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
1105 let result = crate::approval::with_approval_proxy(
1106 Some(proxy),
1107 executor.execute_with_context(&call, ctx),
1108 )
1109 .await;
1110
1111 assert!(
1112 result.is_ok(),
1113 "host grant should let the write through: {result:?}"
1114 );
1115 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1116 }
1117
1118 #[tokio::test]
1119 async fn approval_proxy_deny_fails_gated_tool_closed() {
1120 let dir = tempfile::tempdir().unwrap();
1123 let path = dir.path().join("denied.txt");
1124 let path_str = path.to_str().unwrap().to_string();
1125 let config = Arc::new(crate::permission::PermissionConfig::new());
1126 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1127 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1128 let executor = make_executor(Some(checker));
1129
1130 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
1131 let ctx = ToolExecutionContext {
1132 session_id: Some("s-worker"),
1133 tool_call_id: &call.id,
1134 event_tx: None,
1135 available_tool_schemas: None,
1136 bypass_permissions: false,
1137 can_async_resume: false,
1138 };
1139
1140 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
1141 let result = crate::approval::with_approval_proxy(
1142 Some(proxy),
1143 executor.execute_with_context(&call, ctx),
1144 )
1145 .await;
1146
1147 assert!(
1148 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
1149 "host deny should fail the tool closed: {result:?}"
1150 );
1151 assert!(fs::metadata(&path).await.is_err());
1152 }
1153
1154 #[tokio::test]
1155 async fn tool_can_stream_events_via_execute_with_context() {
1156 struct StreamingTool;
1157
1158 #[async_trait]
1159 impl Tool for StreamingTool {
1160 fn name(&self) -> &str {
1161 "streaming_tool"
1162 }
1163
1164 fn description(&self) -> &str {
1165 "streams one token"
1166 }
1167
1168 fn parameters_schema(&self) -> serde_json::Value {
1169 json!({"type":"object","properties":{}})
1170 }
1171
1172 async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult, ToolError> {
1173 Ok(ToolResult {
1174 success: true,
1175 result: "ok".to_string(),
1176 display_preference: None,
1177 images: Vec::new(),
1178 })
1179 }
1180
1181 async fn execute_with_context(
1182 &self,
1183 args: serde_json::Value,
1184 ctx: ToolExecutionContext<'_>,
1185 ) -> Result<ToolResult, ToolError> {
1186 ctx.emit(AgentEvent::Token {
1187 content: "stream".to_string(),
1188 })
1189 .await;
1190 self.execute(args).await
1191 }
1192 }
1193
1194 let executor = BuiltinToolExecutor::new();
1195 executor
1196 .register_tool(StreamingTool)
1197 .expect("register streaming tool");
1198
1199 let (tx, mut rx) = mpsc::channel(8);
1200 let call = make_tool_call("streaming_tool", json!({}));
1201
1202 let result = executor
1203 .execute_with_context(
1204 &call,
1205 ToolExecutionContext {
1206 session_id: Some("s1"),
1207 tool_call_id: &call.id,
1208 event_tx: Some(&tx),
1209 available_tool_schemas: None,
1210 bypass_permissions: false,
1211 can_async_resume: false,
1212 },
1213 )
1214 .await
1215 .expect("execute tool");
1216
1217 assert!(result.success);
1218 assert_eq!(result.result, "ok");
1219
1220 let ev = rx.recv().await.expect("expected streamed event");
1221 assert!(
1222 matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
1223 );
1224 }
1225
1226 #[tokio::test]
1227 async fn removed_legacy_tools_return_not_found() {
1228 let executor = BuiltinToolExecutor::new();
1229
1230 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1231 let call = make_tool_call(legacy, json!({}));
1232 let result = executor.execute(&call).await;
1233 assert!(matches!(result, Err(ToolError::NotFound(_))));
1234 }
1235 }
1236
1237 #[tokio::test]
1238 async fn executor_prefers_exact_tool_name_before_builtin_alias() {
1239 struct CustomSpawnSessionTool;
1240
1241 #[async_trait]
1242 impl Tool for CustomSpawnSessionTool {
1243 fn name(&self) -> &str {
1244 "spawn_session"
1245 }
1246
1247 fn description(&self) -> &str {
1248 "custom tool for regression coverage"
1249 }
1250
1251 fn parameters_schema(&self) -> serde_json::Value {
1252 json!({"type":"object","properties":{}})
1253 }
1254
1255 async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult, ToolError> {
1256 Ok(ToolResult {
1257 success: true,
1258 result: "custom-spawn-session".to_string(),
1259 display_preference: None,
1260 images: Vec::new(),
1261 })
1262 }
1263 }
1264
1265 let executor = BuiltinToolExecutorBuilder::new()
1266 .with_tool(CustomSpawnSessionTool)
1267 .expect("register custom spawn_session tool")
1268 .build();
1269
1270 let call = make_tool_call("spawn_session", json!({}));
1271 let result = executor.execute(&call).await.expect("execute custom tool");
1272 assert!(result.success);
1273 assert_eq!(result.result, "custom-spawn-session");
1274 }
1275}