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