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