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