1use std::sync::Arc;
2
3use async_trait::async_trait;
4use bamboo_agent_core::{
5 normalize_tool_name, parse_tool_args_best_effort, Tool, ToolCall, ToolError,
6 ToolExecutionContext, ToolExecutor, ToolResult, ToolSchema,
7};
8use bamboo_domain::tool_names::{normalize_builtin_alias, resolve_alias};
9
10use crate::guide::{context::GuideBuildContext, EnhancedPromptBuilder, ToolGuide};
11use crate::permission::{check_permissions, PermissionChecker, PermissionError};
12use crate::tools::{
13 BashOutputTool, BashTool, ConclusionWithOptionsTool, EditTool, ExitPlanModeTool,
14 GetFileInfoTool, GlobTool, GrepTool, JsReplTool, KillShellTool, NotebookEditTool, ReadTool,
15 RequestPermissionsTool, SessionNoteTool, SleepTool, TaskTool, ToolRegistry, WebFetchTool,
16 WebSearchTool, WorkspaceTool, WriteTool,
17};
18use bamboo_infrastructure::Config;
19use tokio::sync::RwLock;
20
21fn preview_for_log(value: &str, max_chars: usize) -> String {
22 let mut iter = value.chars();
23 let mut preview = String::new();
24 for _ in 0..max_chars {
25 match iter.next() {
26 Some(ch) => preview.push(ch),
27 None => break,
28 }
29 }
30 if iter.next().is_some() {
31 preview.push_str("...");
32 }
33 preview.replace('\n', "\\n").replace('\r', "\\r")
34}
35
36fn copy_legacy_arg_if_missing(
37 args: &mut serde_json::Map<String, serde_json::Value>,
38 from: &str,
39 to: &str,
40) {
41 if args.contains_key(to) {
42 return;
43 }
44 if let Some(value) = args.get(from).cloned() {
45 args.insert(to.to_string(), value);
46 }
47}
48
49fn normalize_legacy_builtin_args(
50 raw_tool_name: &str,
51 args: &mut serde_json::Map<String, serde_json::Value>,
52) {
53 match raw_tool_name {
54 "read_file" | "write_file" | "Read" | "Write" | "apply_patch" => {
55 copy_legacy_arg_if_missing(args, "path", "file_path");
56 }
57 "execute_command" | "Bash" => {
58 copy_legacy_arg_if_missing(args, "cmd", "command");
59 }
60 "list_directory" | "Glob" => {
61 let should_default_pattern = raw_tool_name == "list_directory"
62 || args.contains_key("path")
63 || args.contains_key("recursive");
64 if should_default_pattern && !args.contains_key("pattern") {
65 let recursive = args
66 .get("recursive")
67 .and_then(serde_json::Value::as_bool)
68 .unwrap_or(false);
69 let pattern = if recursive { "**/*" } else { "*" };
70 args.insert(
71 "pattern".to_string(),
72 serde_json::Value::String(pattern.to_string()),
73 );
74 }
75 args.remove("recursive");
76 }
77 _ => {}
78 }
79}
80
81fn resolve_registered_tool_name(registry: &ToolRegistry, raw_tool_name: &str) -> String {
82 if registry.get(raw_tool_name).is_some() {
83 return raw_tool_name.to_string();
84 }
85
86 let aliased = normalize_builtin_alias(raw_tool_name);
87 if registry.get(aliased).is_some() {
88 return aliased.to_string();
89 }
90
91 resolve_alias(aliased).unwrap_or(aliased).to_string()
92}
93
94pub struct BuiltinToolExecutor {
96 registry: ToolRegistry,
97 permission_checker: Option<Arc<dyn PermissionChecker>>,
98}
99
100impl BuiltinToolExecutor {
101 pub fn new() -> Self {
103 let registry = ToolRegistry::new();
104 Self::register_builtin_tools(®istry, None);
105 Self {
106 registry,
107 permission_checker: None,
108 }
109 }
110
111 pub fn new_with_permissions(permission_checker: Arc<dyn PermissionChecker>) -> Self {
113 let registry = ToolRegistry::new();
114 Self::register_builtin_tools(®istry, None);
115 Self {
116 registry,
117 permission_checker: Some(permission_checker),
118 }
119 }
120
121 pub fn new_with_config(config: Arc<RwLock<Config>>) -> Self {
126 let registry = ToolRegistry::new();
127 Self::register_builtin_tools(®istry, Some(config));
128 Self {
129 registry,
130 permission_checker: None,
131 }
132 }
133
134 pub fn new_with_config_and_permissions(
136 config: Arc<RwLock<Config>>,
137 permission_checker: Arc<dyn PermissionChecker>,
138 ) -> Self {
139 let registry = ToolRegistry::new();
140 Self::register_builtin_tools(®istry, Some(config));
141 Self {
142 registry,
143 permission_checker: Some(permission_checker),
144 }
145 }
146
147 pub fn with_registry(registry: ToolRegistry) -> Self {
149 Self {
150 registry,
151 permission_checker: None,
152 }
153 }
154
155 pub fn registry(&self) -> &ToolRegistry {
157 &self.registry
158 }
159
160 fn register_builtin_tools(registry: &ToolRegistry, config: Option<Arc<RwLock<Config>>>) {
162 let _ = config;
163 let _ = registry.register(ConclusionWithOptionsTool::new());
165 let _ = registry.register(BashTool::new());
166 let _ = registry.register(BashOutputTool::new());
167 let _ = registry.register(EditTool::new());
168 let _ = registry.register(ExitPlanModeTool::new());
169 let _ = registry.register(GetFileInfoTool::new());
171 let _ = registry.register(GlobTool::new());
172 let _ = registry.register(GrepTool::new());
173 let _ = registry.register(JsReplTool::new());
174 let _ = registry.register(KillShellTool::new());
175 let _ = registry.register(SessionNoteTool::new());
176 let _ = registry.register(NotebookEditTool::new());
177 let _ = registry.register(ReadTool::new());
178 let _ = registry.register(RequestPermissionsTool::new());
179 let _ = registry.register(SleepTool::new());
180 let _ = registry.register(TaskTool::new());
181 let _ = registry.register(WebFetchTool::new());
182 let _ = registry.register(WebSearchTool::new());
183 let _ = registry.register(WorkspaceTool::new());
185 let _ = registry.register(WriteTool::new());
186 }
187
188 pub fn tool_schemas() -> Vec<ToolSchema> {
190 let registry = ToolRegistry::new();
191 Self::register_builtin_tools(®istry, None);
192 registry.list_tools()
193 }
194
195 pub fn register_tool<T: Tool + 'static>(&self, tool: T) -> Result<(), ToolError> {
197 self.registry
198 .register(tool)
199 .map_err(|e| ToolError::Execution(e.to_string()))
200 }
201
202 pub fn register_tool_with_guide<T, G>(&self, tool: T, guide: G) -> Result<(), ToolError>
204 where
205 T: Tool + 'static,
206 G: ToolGuide + 'static,
207 {
208 self.registry
209 .register_with_guide(tool, guide)
210 .map_err(|e| ToolError::Execution(e.to_string()))
211 }
212
213 pub fn get_guide(&self, tool_name: &str) -> Option<Arc<dyn ToolGuide>> {
215 self.registry.get_guide(tool_name)
216 }
217
218 pub fn build_enhanced_prompt(&self, context: GuideBuildContext) -> String {
220 EnhancedPromptBuilder::build(Some(&self.registry), &self.registry.list_tools(), &context)
221 }
222}
223
224fn permission_error_to_tool_error(error: PermissionError) -> ToolError {
225 match error {
226 PermissionError::CheckFailed(_) => ToolError::InvalidArguments(error.to_string()),
227 _ => ToolError::Execution(error.to_string()),
228 }
229}
230
231impl Default for BuiltinToolExecutor {
232 fn default() -> Self {
233 Self::new()
234 }
235}
236
237#[async_trait]
238impl ToolExecutor for BuiltinToolExecutor {
239 async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
240 self.execute_with_context(call, ToolExecutionContext::none(&call.id))
241 .await
242 }
243
244 async fn execute_with_context(
245 &self,
246 call: &ToolCall,
247 ctx: ToolExecutionContext<'_>,
248 ) -> Result<ToolResult, ToolError> {
249 let args_raw = call.function.arguments.trim();
250 let (mut args, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
251 if let Some(warning) = parse_warning {
252 tracing::warn!(
253 "Builtin tool argument parsing fallback applied: session_id={:?}, tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", warning={}",
254 ctx.session_id,
255 call.id,
256 call.function.name,
257 args_raw.len(),
258 preview_for_log(args_raw, 180),
259 warning
260 );
261 }
262
263 let raw_tool_name = normalize_tool_name(&call.function.name);
264 if let Some(args_obj) = args.as_object_mut() {
265 normalize_legacy_builtin_args(raw_tool_name, args_obj);
266 }
267
268 let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
269
270 let tool = self
272 .registry
273 .get(&tool_name)
274 .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", tool_name)))?;
275
276 if let Some(permission_checker) = &self.permission_checker {
277 if let Some(contexts) =
278 check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
279 {
280 for context in contexts {
281 let resource = context.resource.clone();
282 let allowed = permission_checker
283 .check_or_request(context)
284 .await
285 .map_err(permission_error_to_tool_error)?;
286 if !allowed {
287 return Err(ToolError::Execution(format!(
288 "Permission denied for: {}",
289 resource
290 )));
291 }
292 }
293 }
294 }
295
296 tool.execute_with_context(args, ctx).await
297 }
298
299 fn list_tools(&self) -> Vec<ToolSchema> {
300 self.registry.list_tools()
301 }
302
303 fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
304 self.registry
305 .get(tool_name)
306 .map(|tool| tool.mutability())
307 .unwrap_or_else(|| crate::classify_tool(tool_name))
308 }
309
310 fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
311 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
312 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
313 self.registry
314 .get(&canonical)
315 .map(|tool| tool.call_mutability(&args))
316 .unwrap_or_else(|| self.tool_mutability(&canonical))
317 }
318
319 fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
320 let canonical = resolve_registered_tool_name(&self.registry, tool_name);
321 self.registry
322 .get(&canonical)
323 .map(|tool| tool.concurrency_safe())
324 .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
325 }
326
327 fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
328 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
329 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
330 self.registry
331 .get(&canonical)
332 .map(|tool| tool.call_concurrency_safe(&args))
333 .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
334 }
335}
336
337pub struct BuiltinToolExecutorBuilder {
339 registry: ToolRegistry,
340 permission_checker: Option<Arc<dyn PermissionChecker>>,
341}
342
343impl BuiltinToolExecutorBuilder {
344 pub fn new() -> Self {
346 Self {
347 registry: ToolRegistry::new(),
348 permission_checker: None,
349 }
350 }
351
352 pub fn with_default_tools(self) -> Self {
354 BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
355 self
356 }
357
358 pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
360 match name {
361 "Read" => self.registry.register(ReadTool::new()),
362 "Write" => self.registry.register(WriteTool::new()),
363 "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
365 "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
366 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
367 }
368 .map_err(|e| ToolError::Execution(e.to_string()))?;
369 Ok(self)
370 }
371
372 pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
374 match name {
375 "Bash" => self.registry.register(BashTool::new()),
376 "BashOutput" => self.registry.register(BashOutputTool::new()),
377 "KillShell" => self.registry.register(KillShellTool::new()),
378 "Task" => self.registry.register(TaskTool::new()),
379 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
380 }
381 .map_err(|e| ToolError::Execution(e.to_string()))?;
382 Ok(self)
383 }
384
385 pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
387 self.registry
388 .register(tool)
389 .map_err(|e| ToolError::Execution(e.to_string()))?;
390 Ok(self)
391 }
392
393 pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
395 self.permission_checker = Some(checker);
396 self
397 }
398
399 pub fn build(self) -> BuiltinToolExecutor {
401 BuiltinToolExecutor {
402 registry: self.registry,
403 permission_checker: self.permission_checker,
404 }
405 }
406}
407
408impl Default for BuiltinToolExecutorBuilder {
409 fn default() -> Self {
410 Self::new()
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use bamboo_agent_core::AgentEvent;
418 use bamboo_agent_core::FunctionCall;
419 use bamboo_agent_core::ToolExecutionContext;
420 use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
421 use serde_json::json;
422 use std::sync::Arc;
423 use tokio::fs;
424 use tokio::sync::mpsc;
425
426 use crate::tools::WriteTool;
427
428 fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
429 ToolCall {
430 id: "call_1".to_string(),
431 tool_type: "function".to_string(),
432 function: FunctionCall {
433 name: name.to_string(),
434 arguments: args.to_string(),
435 },
436 }
437 }
438
439 fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
440 ToolCall {
441 id: "call_1".to_string(),
442 tool_type: "function".to_string(),
443 function: FunctionCall {
444 name: name.to_string(),
445 arguments: raw_args.to_string(),
446 },
447 }
448 }
449
450 fn make_executor(
451 permission_checker: Option<Arc<dyn PermissionChecker>>,
452 ) -> BuiltinToolExecutor {
453 let builder = BuiltinToolExecutorBuilder::new()
454 .with_tool(WriteTool::new())
455 .expect("register Write tool");
456
457 let builder = match permission_checker {
458 Some(checker) => builder.with_permission_checker(checker),
459 None => builder,
460 };
461
462 builder.build()
463 }
464
465 #[test]
466 fn test_normalize_tool_ref_accepts_claude_style_names() {
467 assert_eq!(
468 normalize_tool_ref("default::Bash"),
469 Some("Bash".to_string())
470 );
471 }
472
473 #[test]
474 fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
475 assert_eq!(
476 normalize_tool_ref("default::fileExists"),
477 Some("FileExists".to_string())
478 );
479 assert_eq!(
480 normalize_tool_ref("default::getCurrentDir"),
481 Some("GetCurrentDir".to_string())
482 );
483 assert_eq!(
484 normalize_tool_ref("default::getFileInfo"),
485 Some("GetFileInfo".to_string())
486 );
487 assert_eq!(
488 normalize_tool_ref("default::setWorkspace"),
489 Some("SetWorkspace".to_string())
490 );
491 assert_eq!(
492 normalize_tool_ref("default::sleep"),
493 Some("Sleep".to_string())
494 );
495 }
496
497 #[test]
498 fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
499 assert_eq!(
500 normalize_tool_ref("default::execute_command"),
501 Some("Bash".to_string())
502 );
503 assert_eq!(
504 normalize_tool_ref("default::file_exists"),
505 Some("FileExists".to_string())
506 );
507 assert_eq!(
508 normalize_tool_ref("default::get_current_dir"),
509 Some("GetCurrentDir".to_string())
510 );
511 assert_eq!(
512 normalize_tool_ref("default::get_file_info"),
513 Some("GetFileInfo".to_string())
514 );
515 assert_eq!(
516 normalize_tool_ref("default::list_directory"),
517 Some("Glob".to_string())
518 );
519 assert_eq!(
520 normalize_tool_ref("default::memory_note"),
521 Some("memory_note".to_string())
522 );
523 assert_eq!(
524 normalize_tool_ref("default::read_file"),
525 Some("Read".to_string())
526 );
527 assert_eq!(
528 normalize_tool_ref("default::set_workspace"),
529 Some("SetWorkspace".to_string())
530 );
531 assert_eq!(
532 normalize_tool_ref("default::write_file"),
533 Some("Write".to_string())
534 );
535 }
536
537 #[test]
538 fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
539 for alias in [
540 "default::spawn_session",
541 "default::sub_session",
542 "default::sub_task",
543 "default::team_agent",
544 "default::child_session",
545 ] {
546 assert_eq!(normalize_tool_ref(alias), Some("SubSession".to_string()));
547 }
548 }
549
550 #[test]
551 fn test_normalize_tool_ref_accepts_server_overlay_tools() {
552 assert_eq!(normalize_tool_ref("compress_context"), None);
553 assert_eq!(
554 normalize_tool_ref("default::read_skill_resource"),
555 Some("read_skill_resource".to_string())
556 );
557 }
558
559 #[tokio::test]
560 async fn test_executor_accepts_legacy_read_file_path_argument() {
561 let dir = tempfile::tempdir().unwrap();
562 let file_path = dir.path().join("legacy-read.txt");
563 fs::write(&file_path, "legacy read content").await.unwrap();
564
565 let executor = BuiltinToolExecutor::new();
566 let call = make_tool_call("read_file", json!({"path": file_path}));
567
568 let result = executor.execute(&call).await.unwrap();
569 assert!(result.success);
570 assert!(result.result.contains("legacy read content"));
571 }
572
573 #[tokio::test]
574 async fn test_executor_accepts_legacy_list_directory_without_pattern() {
575 let dir = tempfile::tempdir().unwrap();
576 let file_path = dir.path().join("legacy-list.txt");
577 fs::write(&file_path, "legacy list content").await.unwrap();
578
579 let executor = BuiltinToolExecutor::new();
580 let call = make_tool_call("list_directory", json!({"path": dir.path()}));
581
582 let result = executor.execute(&call).await.unwrap();
583 assert!(result.success);
584 assert!(result.result.contains("legacy-list.txt"));
585 }
586
587 #[tokio::test]
588 async fn test_executor_accepts_canonical_read_with_path_argument() {
589 let dir = tempfile::tempdir().unwrap();
590 let file_path = dir.path().join("canonical-read.txt");
591 fs::write(&file_path, "canonical read content")
592 .await
593 .unwrap();
594
595 let executor = BuiltinToolExecutor::new();
596 let call = make_tool_call("Read", json!({"path": file_path}));
597
598 let result = executor.execute(&call).await.unwrap();
599 assert!(result.success);
600 assert!(result.result.contains("canonical read content"));
601 }
602
603 #[tokio::test]
604 async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
605 let dir = tempfile::tempdir().unwrap();
606 let file_path = dir.path().join("canonical-list.txt");
607 fs::write(&file_path, "canonical list content")
608 .await
609 .unwrap();
610
611 let executor = BuiltinToolExecutor::new();
612 let call = make_tool_call("Glob", json!({"path": dir.path()}));
613
614 let result = executor.execute(&call).await.unwrap();
615 assert!(result.success);
616 assert!(result.result.contains("canonical-list.txt"));
617 }
618
619 #[test]
620 fn test_executor_workspace_mutability_depends_on_path_argument() {
621 let executor = BuiltinToolExecutor::new();
622 let get_call = make_tool_call("Workspace", json!({}));
623 let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
624
625 assert_eq!(
626 executor.call_mutability(&get_call),
627 crate::ToolMutability::ReadOnly
628 );
629 assert!(executor.call_concurrency_safe(&get_call));
630
631 assert_eq!(
632 executor.call_mutability(&set_call),
633 crate::ToolMutability::Mutating
634 );
635 assert!(!executor.call_concurrency_safe(&set_call));
636 }
637
638 #[tokio::test]
639 async fn test_executor_recovers_truncated_json_arguments() {
640 let dir = tempfile::tempdir().unwrap();
641 let path = dir.path().join("recovered-write.txt");
642
643 let malformed_args = format!(
645 r#"{{"file_path":"{}","content":"recovered content""#,
646 path.display()
647 );
648
649 let executor = BuiltinToolExecutor::new();
650 let call = make_tool_call_with_raw_args("Write", &malformed_args);
651
652 let result = executor
653 .execute(&call)
654 .await
655 .expect("truncated JSON should be auto-repaired");
656 assert!(result.success);
657
658 let written = fs::read_to_string(&path)
659 .await
660 .expect("file should be written");
661 assert_eq!(written, "recovered content");
662 }
663
664 #[test]
665 fn test_normalize_tool_ref_rejects_unknown_tool() {
666 assert_eq!(normalize_tool_ref("default::search"), None);
667 }
668
669 #[test]
670 fn test_executor_does_not_expose_legacy_tools() {
671 let executor = BuiltinToolExecutor::new();
672 let tool_names: Vec<String> = executor
673 .list_tools()
674 .into_iter()
675 .map(|schema| schema.function.name)
676 .collect();
677
678 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
679 assert!(!tool_names.iter().any(|name| name == legacy));
680 }
681 }
682
683 #[test]
684 fn test_critical_tool_schemas_match_claude_shapes() {
685 let executor = BuiltinToolExecutor::new();
686 let tools = executor.list_tools();
687
688 let get_params = |name: &str| {
689 tools
690 .iter()
691 .find(|tool| tool.function.name == name)
692 .unwrap()
693 .function
694 .parameters
695 .clone()
696 };
697
698 let grep = get_params("Grep");
699 assert_eq!(grep["required"], json!(["pattern"]));
700 assert_eq!(
701 grep["properties"]["output_mode"]["enum"],
702 json!(["content", "files_with_matches", "count"])
703 );
704 assert!(grep["properties"]["-A"].is_object());
705 assert!(grep["properties"]["-B"].is_object());
706 assert!(grep["properties"]["-C"].is_object());
707 assert!(grep["properties"]["-n"].is_object());
708 assert!(grep["properties"]["-i"].is_object());
709
710 let edit = get_params("Edit");
711 assert_eq!(edit["required"], json!(["file_path"]));
712 assert_eq!(edit["properties"]["old_string"]["type"], "string");
713 assert_eq!(edit["properties"]["new_string"]["type"], "string");
714 assert_eq!(edit["properties"]["patch"]["type"], "string");
715 assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
716 assert!(edit.get("oneOf").is_none());
717
718 assert_eq!(edit["properties"]["patch"]["type"], "string");
721 assert_eq!(edit["properties"]["line_number"]["type"], "integer");
722
723 let bash = get_params("Bash");
724 assert_eq!(bash["required"], json!(["command"]));
725 assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
726 assert_eq!(bash["properties"]["workdir"]["type"], "string");
727
728 let bash_output = get_params("BashOutput");
729 assert_eq!(bash_output["required"], json!(["bash_id"]));
730 assert_eq!(bash_output["properties"]["filter"]["type"], "string");
731 }
732
733 #[test]
734 fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
735 let executor = BuiltinToolExecutor::new();
736 let tools = executor.list_tools();
737 let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
738
739 for tool in tools {
740 let params = &tool.function.parameters;
741 assert_eq!(
742 params["type"], "object",
743 "tool '{}' parameters must be a top-level object schema",
744 tool.function.name
745 );
746 for key in forbidden {
747 assert!(
748 params.get(key).is_none(),
749 "tool '{}' parameters contains forbidden top-level keyword '{}'",
750 tool.function.name,
751 key
752 );
753 }
754 }
755 }
756
757 #[test]
758 fn test_executor_has_all_builtin_tools() {
759 let executor = BuiltinToolExecutor::new();
760 let tools = executor.list_tools();
761
762 assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
763
764 let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
765 for tool_name in BUILTIN_TOOL_NAMES {
766 assert!(tool_names.contains(&tool_name.to_string()));
767 }
768 }
769
770 #[test]
771 fn test_executor_builds_enhanced_prompt() {
772 let executor = BuiltinToolExecutor::new();
773 let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
774 assert!(prompt.contains("## Tool Usage Guidelines"));
775 assert!(prompt.contains("**Read**"));
776 }
777
778 #[test]
779 fn test_executor_builder_empty() {
780 let executor = BuiltinToolExecutorBuilder::new().build();
781 assert!(executor.list_tools().is_empty());
782 }
783
784 #[test]
785 fn test_executor_builder_with_default_tools() {
786 let executor = BuiltinToolExecutorBuilder::new()
787 .with_default_tools()
788 .build();
789 assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
790 }
791
792 #[test]
793 fn test_executor_builder_with_specific_tool() {
794 let executor = BuiltinToolExecutorBuilder::new()
795 .with_filesystem_tool("Read")
796 .unwrap()
797 .build();
798
799 let tools = executor.list_tools();
800 assert_eq!(tools.len(), 1);
801 assert_eq!(tools[0].function.name, "Read");
802 }
803
804 #[tokio::test]
805 async fn test_executor_skips_permission_checks_without_checker() {
806 let executor = make_executor(None);
807 let path = "/tmp/executor_permission_none.txt";
808 let _ = fs::remove_file(path).await;
809
810 let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
811 let result = executor.execute(&call).await.expect("execute tool");
812
813 assert!(result.success);
814 let _ = fs::remove_file(path).await;
815 }
816
817 #[tokio::test]
818 async fn test_executor_with_permission_checker_enforces_checks() {
819 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
820 let executor = make_executor(Some(checker));
821 let path = "/tmp/executor_permission_denied.txt";
822 let _ = fs::remove_file(path).await;
823
824 let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
825 let result = executor.execute(&call).await;
826
827 assert!(matches!(result, Err(ToolError::Execution(_))));
828 assert!(fs::metadata(path).await.is_err());
829 }
830
831 #[tokio::test]
832 async fn tool_can_stream_events_via_execute_with_context() {
833 struct StreamingTool;
834
835 #[async_trait]
836 impl Tool for StreamingTool {
837 fn name(&self) -> &str {
838 "streaming_tool"
839 }
840
841 fn description(&self) -> &str {
842 "streams one token"
843 }
844
845 fn parameters_schema(&self) -> serde_json::Value {
846 json!({"type":"object","properties":{}})
847 }
848
849 async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult, ToolError> {
850 Ok(ToolResult {
851 success: true,
852 result: "ok".to_string(),
853 display_preference: None,
854 })
855 }
856
857 async fn execute_with_context(
858 &self,
859 args: serde_json::Value,
860 ctx: ToolExecutionContext<'_>,
861 ) -> Result<ToolResult, ToolError> {
862 ctx.emit(AgentEvent::Token {
863 content: "stream".to_string(),
864 })
865 .await;
866 self.execute(args).await
867 }
868 }
869
870 let executor = BuiltinToolExecutor::new();
871 executor
872 .register_tool(StreamingTool)
873 .expect("register streaming tool");
874
875 let (tx, mut rx) = mpsc::channel(8);
876 let call = make_tool_call("streaming_tool", json!({}));
877
878 let result = executor
879 .execute_with_context(
880 &call,
881 ToolExecutionContext {
882 session_id: Some("s1"),
883 tool_call_id: &call.id,
884 event_tx: Some(&tx),
885 available_tool_schemas: None,
886 },
887 )
888 .await
889 .expect("execute tool");
890
891 assert!(result.success);
892 assert_eq!(result.result, "ok");
893
894 let ev = rx.recv().await.expect("expected streamed event");
895 assert!(
896 matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
897 );
898 }
899
900 #[tokio::test]
901 async fn removed_legacy_tools_return_not_found() {
902 let executor = BuiltinToolExecutor::new();
903
904 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
905 let call = make_tool_call(legacy, json!({}));
906 let result = executor.execute(&call).await;
907 assert!(matches!(result, Err(ToolError::NotFound(_))));
908 }
909 }
910
911 #[tokio::test]
912 async fn executor_prefers_exact_tool_name_before_builtin_alias() {
913 struct CustomSpawnSessionTool;
914
915 #[async_trait]
916 impl Tool for CustomSpawnSessionTool {
917 fn name(&self) -> &str {
918 "spawn_session"
919 }
920
921 fn description(&self) -> &str {
922 "custom tool for regression coverage"
923 }
924
925 fn parameters_schema(&self) -> serde_json::Value {
926 json!({"type":"object","properties":{}})
927 }
928
929 async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult, ToolError> {
930 Ok(ToolResult {
931 success: true,
932 result: "custom-spawn-session".to_string(),
933 display_preference: None,
934 })
935 }
936 }
937
938 let executor = BuiltinToolExecutorBuilder::new()
939 .with_tool(CustomSpawnSessionTool)
940 .expect("register custom spawn_session tool")
941 .build();
942
943 let call = make_tool_call("spawn_session", json!({}));
944 let result = executor.execute(&call).await.expect("execute custom tool");
945 assert!(result.success);
946 assert_eq!(result.result, "custom-spawn-session");
947 }
948}