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