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