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, ToolOutcome, 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 BashInputTool, BashOutputTool, BashTool, ConclusionWithOptionsTool, EditTool,
14 EnterPlanModeTool, ExitPlanModeTool, GetFileInfoTool, GlobTool, GrepTool, JsReplTool,
15 KillShellTool, NotebookEditTool, ReadTool, RequestPermissionsTool, SessionNoteTool, SleepTool,
16 TaskTool, 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(BashInputTool::new());
167 let _ = registry.register(BashOutputTool::new());
168 let _ = registry.register(EditTool::new());
169 let _ = registry.register(EnterPlanModeTool::new());
170 let _ = registry.register(ExitPlanModeTool::new());
171 let _ = registry.register(GetFileInfoTool::new());
173 let _ = registry.register(GlobTool::new());
174 let _ = registry.register(GrepTool::new());
175 let _ = registry.register(UpdateGoalTool::new());
176 let _ = registry.register(JsReplTool::new());
177 let _ = registry.register(KillShellTool::new());
178 let _ = registry.register(SessionNoteTool::new());
179 let _ = registry.register(NotebookEditTool::new());
180 let _ = registry.register(ReadTool::new());
181 let _ = registry.register(RequestPermissionsTool::new());
182 let _ = registry.register(SleepTool::new());
183 let _ = registry.register(TaskTool::new());
184 let _ = registry.register(WebFetchTool::new());
185 let _ = registry.register(WebSearchTool::new());
186 let _ = registry.register(WorkspaceTool::new());
188 let _ = registry.register(WriteTool::new());
189 }
190
191 pub fn tool_schemas() -> Vec<ToolSchema> {
193 let registry = ToolRegistry::new();
194 Self::register_builtin_tools(®istry, None);
195 registry.list_tools()
196 }
197
198 pub fn register_tool<T: Tool + 'static>(&self, tool: T) -> Result<(), ToolError> {
200 self.registry
201 .register(tool)
202 .map_err(|e| ToolError::Execution(e.to_string()))
203 }
204
205 pub fn register_tool_with_guide<T, G>(&self, tool: T, guide: G) -> Result<(), ToolError>
207 where
208 T: Tool + 'static,
209 G: ToolGuide + 'static,
210 {
211 self.registry
212 .register_with_guide(tool, guide)
213 .map_err(|e| ToolError::Execution(e.to_string()))
214 }
215
216 pub fn get_guide(&self, tool_name: &str) -> Option<Arc<dyn ToolGuide>> {
218 self.registry.get_guide(tool_name)
219 }
220
221 pub fn build_enhanced_prompt(&self, context: GuideBuildContext) -> String {
223 EnhancedPromptBuilder::build(Some(&self.registry), &self.registry.list_tools(), &context)
224 }
225}
226
227fn permission_error_to_tool_error(error: PermissionError) -> ToolError {
228 match error {
229 PermissionError::CheckFailed(_) => ToolError::InvalidArguments(error.to_string()),
230 _ => ToolError::Execution(error.to_string()),
231 }
232}
233
234impl Default for BuiltinToolExecutor {
235 fn default() -> Self {
236 Self::new()
237 }
238}
239
240#[async_trait]
241impl ToolExecutor for BuiltinToolExecutor {
242 async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
243 self.execute_with_context(call, ToolExecutionContext::none(&call.id))
244 .await
245 }
246
247 async fn execute_with_context(
248 &self,
249 call: &ToolCall,
250 ctx: ToolExecutionContext<'_>,
251 ) -> Result<ToolResult, ToolError> {
252 self.execute_with_context_outcome(call, ctx)
253 .await
254 .map(ToolOutcome::into_tool_result)
255 }
256
257 async fn execute_with_context_outcome(
258 &self,
259 call: &ToolCall,
260 ctx: ToolExecutionContext<'_>,
261 ) -> Result<ToolOutcome, ToolError> {
262 let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
272 pre_parsed.clone()
273 } else {
274 let args_raw = call.function.arguments.trim();
275 let (parsed, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
276 if let Some(warning) = parse_warning {
277 tracing::warn!(
278 "Builtin tool argument parsing fallback applied: session_id={:?}, tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", warning={}",
279 ctx.session_id,
280 call.id,
281 call.function.name,
282 args_raw.len(),
283 preview_for_log(args_raw, 180),
284 warning
285 );
286 }
287 parsed
288 };
289
290 let raw_tool_name = normalize_tool_name(&call.function.name);
291 if let Some(args_obj) = args.as_object_mut() {
292 normalize_legacy_builtin_args(raw_tool_name, args_obj);
293 }
294
295 let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
296
297 let tool = self
299 .registry
300 .get(&tool_name)
301 .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", tool_name)))?;
302
303 if let Some(outcome) = self.check_permissions_for(call, &ctx).await? {
310 return Ok(outcome);
311 }
312
313 let tool_ctx = ctx.to_tool_ctx();
321 tool.invoke(args, tool_ctx).await
322 }
323
324 async fn check_permissions_for(
348 &self,
349 call: &ToolCall,
350 ctx: &ToolExecutionContext<'_>,
351 ) -> Result<Option<ToolOutcome>, ToolError> {
352 let Some(permission_checker) = &self.permission_checker else {
353 return Ok(None);
354 };
355
356 let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
361 pre_parsed.clone()
362 } else {
363 parse_tool_args_best_effort(&call.function.arguments).0
364 };
365 let raw_tool_name = normalize_tool_name(&call.function.name);
366 if let Some(args_obj) = args.as_object_mut() {
367 normalize_legacy_builtin_args(raw_tool_name, args_obj);
368 }
369 let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
370
371 if let Some(contexts) =
372 check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
373 {
374 for context in contexts {
375 let resource = context.resource.clone();
376 let operation_summary = context.operation_description.clone();
377 let risk_level = context.risk_level();
378 let permission_type = context.permission_type;
379 let config = permission_checker.permission_config();
380 let proxy = crate::approval::current_approval_proxy();
381 let request = if let Some(config) = config.as_ref() {
382 let supported_decisions = if proxy.is_some() {
386 crate::permission::PermissionRequest::forced_decisions()
387 } else {
388 crate::permission::PermissionRequest::ordinary_decisions(true)
389 };
390 let workspace_path = args
391 .get("workspace_path")
392 .or_else(|| args.get("cwd"))
393 .and_then(|value| value.as_str())
394 .map(ToOwned::to_owned)
395 .or_else(|| {
396 ctx.session_id
397 .and_then(|session_id| config.session_workspace(session_id))
398 });
399 match config.evaluate(crate::permission::PermissionEvaluation {
400 request_id: call.id.clone(),
401 session_id: ctx.session_id.unwrap_or_default().to_string(),
402 workspace_path,
403 tool_name: tool_name.clone(),
404 tool_args: args.clone(),
405 permission_type,
406 resource: resource.clone(),
407 operation_summary: operation_summary.clone(),
408 risk_level,
409 bypass_requested: ctx.bypass_permissions,
410 platform_hard_deny: None,
411 consume_once: true,
412 supported_decisions,
413 }) {
414 crate::permission::PermissionOutcome::Allow { .. } => continue,
415 crate::permission::PermissionOutcome::Deny { reason, .. } => {
416 return Err(ToolError::Execution(reason.message));
417 }
418 crate::permission::PermissionOutcome::Ask(request) => request,
419 }
420 } else {
421 let force_ask =
424 permission_checker.requires_forced_confirmation(&tool_name, &args);
425 if ctx.bypass_permissions && !force_ask {
426 continue;
427 }
428 let decision = if force_ask {
429 permission_checker.check_or_request_forced(context).await
430 } else if let Some(session_id) = ctx.session_id {
431 permission_checker
432 .check_or_request_for_session(session_id, context)
433 .await
434 } else {
435 permission_checker.check_or_request(context).await
436 };
437 match decision {
438 Ok(true) => continue,
439 Ok(false) => {
440 return Err(ToolError::Execution(format!(
441 "Permission denied for: {}",
442 resource
443 )));
444 }
445 Err(PermissionError::ConfirmationRequired { .. }) => {
446 crate::permission::PermissionRequest {
447 request_id: call.id.clone(),
448 session_id: ctx.session_id.unwrap_or_default().to_string(),
449 workspace_path: None,
450 tool_name: tool_name.clone(),
451 permission_type,
452 resource: resource.clone(),
453 operation_summary: operation_summary.clone(),
454 risk_level,
455 reason_code: if force_ask {
456 crate::permission::PermissionReasonCode::ConfiguredAlwaysAsk
457 } else {
458 crate::permission::PermissionReasonCode::RiskThreshold
459 },
460 effective_mode: bamboo_config::settings::PermissionMode::Default,
461 bypass_requested: ctx.bypass_permissions,
462 policy_revision: 0,
463 matched_rule: None,
464 allowed_decisions:
465 crate::permission::PermissionRequest::forced_decisions(),
466 suggested_matchers: crate::permission::conservative_matchers(
467 permission_type,
468 &resource,
469 ),
470 }
471 }
472 Err(other) => return Err(permission_error_to_tool_error(other)),
473 }
474 };
475
476 if let Some(proxy) = proxy {
480 let approved = proxy
481 .request_approval(crate::approval::ApprovalAsk {
482 tool_name: tool_name.clone(),
483 permission: permission_type.description().to_string(),
484 resource: resource.clone(),
485 permission_request: Some(request.clone()),
486 })
487 .await;
488 if approved {
489 continue;
490 }
491 return Err(ToolError::Execution(format!(
492 "Permission denied by host for: {}",
493 resource
494 )));
495 }
496
497 if let Some(tx) = ctx.event_tx {
500 let _ = tx
501 .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
502 tool_call_id: call.id.clone(),
503 tool_name: tool_name.clone(),
504 parameters: args.clone(),
505 })
506 .await;
507
508 let question = format!(
509 "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
510 tool_name,
511 permission_type.description(),
512 resource
513 );
514 if let Some(config) = config {
515 config.register_pending_request(request.clone());
516 }
517 let payload = serde_json::json!({
518 "status": "awaiting_permission_approval",
519 "question": question,
520 "permission_type": permission_type,
521 "resource": resource,
522 "options": ["Approve", "Deny"],
523 "allow_custom": false,
524 "permission_request": request,
525 });
526 return Ok(Some(ToolOutcome::Completed(ToolResult {
527 success: true,
528 result: payload.to_string(),
529 display_preference: Some("request_permissions".to_string()),
530 images: Vec::new(),
531 })));
532 }
533
534 return Err(ToolError::Execution(format!(
535 "Permission approval required for: {}",
536 resource
537 )));
538 }
539 }
540
541 Ok(None)
542 }
543
544 fn list_tools(&self) -> Vec<ToolSchema> {
545 self.registry.list_tools()
546 }
547
548 fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
549 self.registry
550 .get(tool_name)
551 .map(|tool| tool.classify(&serde_json::Value::Null).mutability)
552 .unwrap_or_else(|| crate::classify_tool(tool_name))
553 }
554
555 fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
556 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
557 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
558 self.registry
559 .get(&canonical)
560 .map(|tool| tool.classify(&args).mutability)
561 .unwrap_or_else(|| self.tool_mutability(&canonical))
562 }
563
564 fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
565 let canonical = resolve_registered_tool_name(&self.registry, tool_name);
566 self.registry
567 .get(&canonical)
568 .map(|tool| tool.classify(&serde_json::Value::Null).parallel_safe)
569 .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
570 }
571
572 fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
573 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
574 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
575 self.registry
576 .get(&canonical)
577 .map(|tool| tool.classify(&args).parallel_safe)
578 .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
579 }
580
581 fn call_parallel_classification(&self, call: &ToolCall) -> (crate::ToolMutability, bool) {
582 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
586 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
587 match self.registry.get(&canonical) {
588 Some(tool) => {
589 let class = tool.classify(&args);
590 (class.mutability, class.parallel_safe)
591 }
592 None => (
593 self.tool_mutability(&canonical),
594 self.tool_concurrency_safe(&canonical),
595 ),
596 }
597 }
598}
599
600pub struct BuiltinToolExecutorBuilder {
602 registry: ToolRegistry,
603 permission_checker: Option<Arc<dyn PermissionChecker>>,
604}
605
606impl BuiltinToolExecutorBuilder {
607 pub fn new() -> Self {
609 Self {
610 registry: ToolRegistry::new(),
611 permission_checker: None,
612 }
613 }
614
615 pub fn with_default_tools(self) -> Self {
617 BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
618 self
619 }
620
621 pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
623 match name {
624 "Read" => self.registry.register(ReadTool::new()),
625 "Write" => self.registry.register(WriteTool::new()),
626 "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
628 "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
629 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
630 }
631 .map_err(|e| ToolError::Execution(e.to_string()))?;
632 Ok(self)
633 }
634
635 pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
637 match name {
638 "Bash" => self.registry.register(BashTool::new()),
639 "BashOutput" => self.registry.register(BashOutputTool::new()),
640 "KillShell" => self.registry.register(KillShellTool::new()),
641 "Task" => self.registry.register(TaskTool::new()),
642 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
643 }
644 .map_err(|e| ToolError::Execution(e.to_string()))?;
645 Ok(self)
646 }
647
648 pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
650 self.registry
651 .register(tool)
652 .map_err(|e| ToolError::Execution(e.to_string()))?;
653 Ok(self)
654 }
655
656 pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
658 self.permission_checker = Some(checker);
659 self
660 }
661
662 pub fn build(self) -> BuiltinToolExecutor {
664 BuiltinToolExecutor {
665 registry: self.registry,
666 permission_checker: self.permission_checker,
667 }
668 }
669}
670
671impl Default for BuiltinToolExecutorBuilder {
672 fn default() -> Self {
673 Self::new()
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use bamboo_agent_core::AgentEvent;
681 use bamboo_agent_core::FunctionCall;
682 use bamboo_agent_core::ToolCtx;
683 use bamboo_agent_core::ToolExecutionContext;
684 use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
685 use serde_json::json;
686 use std::sync::Arc;
687 use tokio::fs;
688 use tokio::sync::mpsc;
689
690 use crate::tools::WriteTool;
691
692 fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
693 ToolCall {
694 id: "call_1".to_string(),
695 tool_type: "function".to_string(),
696 function: FunctionCall {
697 name: name.to_string(),
698 arguments: args.to_string(),
699 },
700 }
701 }
702
703 fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
704 ToolCall {
705 id: "call_1".to_string(),
706 tool_type: "function".to_string(),
707 function: FunctionCall {
708 name: name.to_string(),
709 arguments: raw_args.to_string(),
710 },
711 }
712 }
713
714 fn make_executor(
715 permission_checker: Option<Arc<dyn PermissionChecker>>,
716 ) -> BuiltinToolExecutor {
717 let builder = BuiltinToolExecutorBuilder::new()
718 .with_tool(WriteTool::new())
719 .expect("register Write tool");
720
721 let builder = match permission_checker {
722 Some(checker) => builder.with_permission_checker(checker),
723 None => builder,
724 };
725
726 builder.build()
727 }
728
729 #[test]
730 fn test_normalize_tool_ref_accepts_claude_style_names() {
731 assert_eq!(
732 normalize_tool_ref("default::Bash"),
733 Some("Bash".to_string())
734 );
735 }
736
737 #[test]
738 fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
739 assert_eq!(
740 normalize_tool_ref("default::fileExists"),
741 Some("FileExists".to_string())
742 );
743 assert_eq!(
744 normalize_tool_ref("default::getCurrentDir"),
745 Some("GetCurrentDir".to_string())
746 );
747 assert_eq!(
748 normalize_tool_ref("default::getFileInfo"),
749 Some("GetFileInfo".to_string())
750 );
751 assert_eq!(
752 normalize_tool_ref("default::setWorkspace"),
753 Some("SetWorkspace".to_string())
754 );
755 assert_eq!(
756 normalize_tool_ref("default::sleep"),
757 Some("Sleep".to_string())
758 );
759 }
760
761 #[test]
762 fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
763 assert_eq!(
764 normalize_tool_ref("default::execute_command"),
765 Some("Bash".to_string())
766 );
767 assert_eq!(
768 normalize_tool_ref("default::file_exists"),
769 Some("FileExists".to_string())
770 );
771 assert_eq!(
772 normalize_tool_ref("default::get_current_dir"),
773 Some("GetCurrentDir".to_string())
774 );
775 assert_eq!(
776 normalize_tool_ref("default::get_file_info"),
777 Some("GetFileInfo".to_string())
778 );
779 assert_eq!(
780 normalize_tool_ref("default::list_directory"),
781 Some("Glob".to_string())
782 );
783 assert_eq!(
784 normalize_tool_ref("default::memory_note"),
785 Some("memory_note".to_string())
786 );
787 assert_eq!(
788 normalize_tool_ref("default::read_file"),
789 Some("Read".to_string())
790 );
791 assert_eq!(
792 normalize_tool_ref("default::set_workspace"),
793 Some("SetWorkspace".to_string())
794 );
795 assert_eq!(
796 normalize_tool_ref("default::write_file"),
797 Some("Write".to_string())
798 );
799 }
800
801 #[test]
802 fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
803 for alias in [
804 "default::spawn_session",
805 "default::sub_session",
806 "default::sub_task",
807 "default::team_agent",
808 "default::child_session",
809 ] {
810 assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
811 }
812 }
813
814 #[test]
815 fn test_normalize_tool_ref_accepts_server_overlay_tools() {
816 assert_eq!(normalize_tool_ref("compress_context"), None);
817 assert_eq!(
818 normalize_tool_ref("default::read_skill_resource"),
819 Some("read_skill_resource".to_string())
820 );
821 }
822
823 #[tokio::test]
824 async fn test_executor_accepts_legacy_read_file_path_argument() {
825 let dir = tempfile::tempdir().unwrap();
826 let file_path = dir.path().join("legacy-read.txt");
827 fs::write(&file_path, "legacy read content").await.unwrap();
828
829 let executor = BuiltinToolExecutor::new();
830 let call = make_tool_call("read_file", json!({"path": file_path}));
831
832 let result = executor.execute(&call).await.unwrap();
833 assert!(result.success);
834 assert!(result.result.contains("legacy read content"));
835 }
836
837 #[tokio::test]
838 async fn test_executor_accepts_legacy_list_directory_without_pattern() {
839 let dir = tempfile::tempdir().unwrap();
840 let file_path = dir.path().join("legacy-list.txt");
841 fs::write(&file_path, "legacy list content").await.unwrap();
842
843 let executor = BuiltinToolExecutor::new();
844 let call = make_tool_call("list_directory", json!({"path": dir.path()}));
845
846 let result = executor.execute(&call).await.unwrap();
847 assert!(result.success);
848 assert!(result.result.contains("legacy-list.txt"));
849 }
850
851 #[tokio::test]
852 async fn test_executor_accepts_canonical_read_with_path_argument() {
853 let dir = tempfile::tempdir().unwrap();
854 let file_path = dir.path().join("canonical-read.txt");
855 fs::write(&file_path, "canonical read content")
856 .await
857 .unwrap();
858
859 let executor = BuiltinToolExecutor::new();
860 let call = make_tool_call("Read", json!({"path": file_path}));
861
862 let result = executor.execute(&call).await.unwrap();
863 assert!(result.success);
864 assert!(result.result.contains("canonical read content"));
865 }
866
867 #[tokio::test]
868 async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
869 let dir = tempfile::tempdir().unwrap();
870 let file_path = dir.path().join("canonical-list.txt");
871 fs::write(&file_path, "canonical list content")
872 .await
873 .unwrap();
874
875 let executor = BuiltinToolExecutor::new();
876 let call = make_tool_call("Glob", json!({"path": dir.path()}));
877
878 let result = executor.execute(&call).await.unwrap();
879 assert!(result.success);
880 assert!(result.result.contains("canonical-list.txt"));
881 }
882
883 #[test]
884 fn test_executor_workspace_mutability_depends_on_path_argument() {
885 let executor = BuiltinToolExecutor::new();
886 let get_call = make_tool_call("Workspace", json!({}));
887 let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
888
889 assert_eq!(
890 executor.call_mutability(&get_call),
891 crate::ToolMutability::ReadOnly
892 );
893 assert!(executor.call_concurrency_safe(&get_call));
894
895 assert_eq!(
896 executor.call_mutability(&set_call),
897 crate::ToolMutability::Mutating
898 );
899 assert!(!executor.call_concurrency_safe(&set_call));
900 }
901
902 #[test]
903 fn call_parallel_classification_matches_individual_methods() {
904 let executor = BuiltinToolExecutor::new();
912 let cases: &[(&str, serde_json::Value)] = &[
913 ("Read", json!({})),
914 ("Grep", json!({"pattern": "x"})),
915 (
916 "Write",
917 json!({"file_path": "/tmp/par_cls.txt", "content": "y"}),
918 ),
919 ("Bash", json!({"command": "echo hi"})),
920 ("Workspace", json!({})),
921 ("Workspace", json!({"path": "/tmp"})),
922 ];
923
924 for (name, args) in cases {
925 let call = make_tool_call(name, args.clone());
926 let expected_mutability = executor.call_mutability(&call);
927 let expected_concurrency = executor.call_concurrency_safe(&call);
928 let (mutability, concurrency) = executor.call_parallel_classification(&call);
929 assert_eq!(
930 mutability, expected_mutability,
931 "mutability mismatch for {name} ({args})"
932 );
933 assert_eq!(
934 concurrency, expected_concurrency,
935 "concurrency mismatch for {name} ({args})"
936 );
937 }
938 }
939
940 #[test]
941 fn list_tools_snapshot_is_stable_across_calls() {
942 let executor = BuiltinToolExecutor::new();
947 let first: Vec<String> = executor
948 .list_tools()
949 .into_iter()
950 .map(|s| s.function.name)
951 .collect();
952 let second: Vec<String> = executor
953 .list_tools()
954 .into_iter()
955 .map(|s| s.function.name)
956 .collect();
957 assert!(!first.is_empty(), "builtin executor should expose tools");
958 assert_eq!(
959 first, second,
960 "list_tools() must be deterministic per round"
961 );
962 }
963
964 #[tokio::test]
965 async fn test_executor_recovers_truncated_json_arguments() {
966 let dir = tempfile::tempdir().unwrap();
967 let path = dir.path().join("recovered-write.txt");
968
969 let malformed_args = format!(
971 r#"{{"file_path":"{}","content":"recovered content""#,
972 path.display()
973 );
974
975 let executor = BuiltinToolExecutor::new();
976 let call = make_tool_call_with_raw_args("Write", &malformed_args);
977
978 let result = executor
979 .execute(&call)
980 .await
981 .expect("truncated JSON should be auto-repaired");
982 assert!(result.success);
983
984 let written = fs::read_to_string(&path)
985 .await
986 .expect("file should be written");
987 assert_eq!(written, "recovered content");
988 }
989
990 #[test]
991 fn test_normalize_tool_ref_rejects_unknown_tool() {
992 assert_eq!(normalize_tool_ref("default::search"), None);
993 }
994
995 #[test]
996 fn test_executor_does_not_expose_legacy_tools() {
997 let executor = BuiltinToolExecutor::new();
998 let tool_names: Vec<String> = executor
999 .list_tools()
1000 .into_iter()
1001 .map(|schema| schema.function.name)
1002 .collect();
1003
1004 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1005 assert!(!tool_names.iter().any(|name| name == legacy));
1006 }
1007 }
1008
1009 #[test]
1010 fn test_critical_tool_schemas_match_claude_shapes() {
1011 let executor = BuiltinToolExecutor::new();
1012 let tools = executor.list_tools();
1013
1014 let get_params = |name: &str| {
1015 tools
1016 .iter()
1017 .find(|tool| tool.function.name == name)
1018 .unwrap()
1019 .function
1020 .parameters
1021 .clone()
1022 };
1023
1024 let grep = get_params("Grep");
1025 assert_eq!(grep["required"], json!(["pattern"]));
1026 assert_eq!(
1027 grep["properties"]["output_mode"]["enum"],
1028 json!(["content", "files_with_matches", "count"])
1029 );
1030 assert!(grep["properties"]["-A"].is_object());
1031 assert!(grep["properties"]["-B"].is_object());
1032 assert!(grep["properties"]["-C"].is_object());
1033 assert!(grep["properties"]["-n"].is_object());
1034 assert!(grep["properties"]["-i"].is_object());
1035
1036 let edit = get_params("Edit");
1037 assert_eq!(edit["required"], json!(["file_path"]));
1038 assert_eq!(edit["properties"]["old_string"]["type"], "string");
1039 assert_eq!(edit["properties"]["new_string"]["type"], "string");
1040 assert_eq!(edit["properties"]["patch"]["type"], "string");
1041 assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
1042 assert!(edit.get("oneOf").is_none());
1043
1044 assert_eq!(edit["properties"]["patch"]["type"], "string");
1047 assert_eq!(edit["properties"]["line_number"]["type"], "integer");
1048
1049 let bash = get_params("Bash");
1050 assert_eq!(bash["required"], json!(["command"]));
1051 assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
1052 assert_eq!(bash["properties"]["workdir"]["type"], "string");
1053
1054 let bash_output = get_params("BashOutput");
1055 assert_eq!(bash_output["required"], json!(["bash_id"]));
1056 assert_eq!(bash_output["properties"]["filter"]["type"], "string");
1057 }
1058
1059 #[test]
1060 fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
1061 let executor = BuiltinToolExecutor::new();
1062 let tools = executor.list_tools();
1063 let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
1064
1065 for tool in tools {
1066 let params = &tool.function.parameters;
1067 assert_eq!(
1068 params["type"], "object",
1069 "tool '{}' parameters must be a top-level object schema",
1070 tool.function.name
1071 );
1072 for key in forbidden {
1073 assert!(
1074 params.get(key).is_none(),
1075 "tool '{}' parameters contains forbidden top-level keyword '{}'",
1076 tool.function.name,
1077 key
1078 );
1079 }
1080 }
1081 }
1082
1083 #[test]
1084 fn test_executor_has_all_builtin_tools() {
1085 let executor = BuiltinToolExecutor::new();
1086 let tools = executor.list_tools();
1087
1088 assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
1089
1090 let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
1091 for tool_name in BUILTIN_TOOL_NAMES {
1092 assert!(tool_names.contains(&tool_name.to_string()));
1093 }
1094 }
1095
1096 #[test]
1097 fn test_executor_builds_enhanced_prompt() {
1098 let executor = BuiltinToolExecutor::new();
1099 let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
1100 assert!(prompt.contains("## Tool Usage Guidelines"));
1101 assert!(prompt.contains("**Read**"));
1102 }
1103
1104 #[test]
1105 fn test_executor_builder_empty() {
1106 let executor = BuiltinToolExecutorBuilder::new().build();
1107 assert!(executor.list_tools().is_empty());
1108 }
1109
1110 #[test]
1111 fn test_executor_builder_with_default_tools() {
1112 let executor = BuiltinToolExecutorBuilder::new()
1113 .with_default_tools()
1114 .build();
1115 assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
1116 }
1117
1118 #[test]
1119 fn test_executor_builder_with_specific_tool() {
1120 let executor = BuiltinToolExecutorBuilder::new()
1121 .with_filesystem_tool("Read")
1122 .unwrap()
1123 .build();
1124
1125 let tools = executor.list_tools();
1126 assert_eq!(tools.len(), 1);
1127 assert_eq!(tools[0].function.name, "Read");
1128 }
1129
1130 #[tokio::test]
1131 async fn test_executor_skips_permission_checks_without_checker() {
1132 let executor = make_executor(None);
1133 let path = "/tmp/executor_permission_none.txt";
1134 let _ = fs::remove_file(path).await;
1135
1136 let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
1137 let result = executor.execute(&call).await.expect("execute tool");
1138
1139 assert!(result.success);
1140 let _ = fs::remove_file(path).await;
1141 }
1142
1143 #[tokio::test]
1144 async fn test_executor_with_permission_checker_enforces_checks() {
1145 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1146 let executor = make_executor(Some(checker));
1147 let path = "/tmp/executor_permission_denied.txt";
1148 let _ = fs::remove_file(path).await;
1149
1150 let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
1151 let result = executor.execute(&call).await;
1152
1153 assert!(matches!(result, Err(ToolError::Execution(_))));
1154 assert!(fs::metadata(path).await.is_err());
1155 }
1156
1157 #[tokio::test]
1158 async fn test_bypass_permissions_skips_checker() {
1159 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1162 let executor = make_executor(Some(checker));
1163 let dir = tempfile::tempdir().unwrap();
1164 let path = dir.path().join("bypass_allows_write.txt");
1165 let path_str = path.to_str().unwrap();
1166
1167 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1168 let ctx = ToolExecutionContext {
1169 session_id: Some("s-bypass"),
1170 tool_call_id: &call.id,
1171 event_tx: None,
1172 available_tool_schemas: None,
1173 bypass_permissions: true,
1174 can_async_resume: false,
1175 bash_completion_sink: None,
1176 pre_parsed_args: None,
1177 };
1178 let result = executor.execute_with_context(&call, ctx).await;
1179
1180 assert!(result.is_ok(), "bypass should allow the write: {result:?}");
1181 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1182 }
1183
1184 #[tokio::test]
1185 async fn test_forced_ask_rule_overrides_bypass() {
1186 let config = Arc::new(crate::permission::PermissionConfig::new());
1190 config.set_ask_rules(["Write(/etc/**)".to_string()]);
1191 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1192 let executor = make_executor(Some(checker));
1193
1194 let call = make_tool_call(
1195 "Write",
1196 json!({"file_path": "/etc/forced.conf", "content": "x"}),
1197 );
1198 let ctx = ToolExecutionContext {
1199 session_id: Some("s-forced"),
1200 tool_call_id: &call.id,
1201 event_tx: None,
1202 available_tool_schemas: None,
1203 bypass_permissions: true,
1204 can_async_resume: false,
1205 bash_completion_sink: None,
1206 pre_parsed_args: None,
1207 };
1208 let result = executor.execute_with_context(&call, ctx).await;
1209
1210 assert!(
1211 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("approval required")),
1212 "forced ask rule should block under bypass: {result:?}"
1213 );
1214 assert!(fs::metadata("/etc/forced.conf").await.is_err());
1215 }
1216
1217 #[tokio::test]
1218 async fn test_explicit_deny_overrides_bypass() {
1219 let dir = tempfile::tempdir().unwrap();
1220 let path = dir.path().join("explicit-deny.txt");
1221 let path_str = path.to_str().unwrap();
1222 let config = Arc::new(crate::permission::PermissionConfig::new());
1223 config.add_rule(crate::permission::PermissionRule::new(
1224 crate::permission::PermissionType::WriteFile,
1225 path_str,
1226 false,
1227 ));
1228 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1229 let executor = make_executor(Some(checker));
1230 let call = make_tool_call(
1231 "Write",
1232 json!({"file_path": path_str, "content": "blocked"}),
1233 );
1234 let ctx = ToolExecutionContext {
1235 session_id: Some("s-explicit-deny"),
1236 tool_call_id: &call.id,
1237 event_tx: None,
1238 available_tool_schemas: None,
1239 bypass_permissions: true,
1240 can_async_resume: false,
1241 bash_completion_sink: None,
1242 pre_parsed_args: None,
1243 };
1244
1245 let result = executor.execute_with_context(&call, ctx).await;
1246 assert!(
1247 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
1248 "explicit deny must beat bypass: {result:?}"
1249 );
1250 assert!(!path.exists());
1251 }
1252
1253 #[tokio::test]
1254 async fn test_explicit_delete_deny_overrides_bypass() {
1255 let config = Arc::new(crate::permission::PermissionConfig::new());
1256 config.add_rule(crate::permission::PermissionRule::new(
1257 crate::permission::PermissionType::DeleteOperation,
1258 "rm child-to-preserve",
1259 false,
1260 ));
1261 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1262 let executor = BuiltinToolExecutorBuilder::new()
1263 .with_tool(BashTool::new())
1264 .expect("register Bash tool")
1265 .with_permission_checker(checker)
1266 .build();
1267 let call = make_tool_call("Bash", json!({"command": "rm child-to-preserve"}));
1268 let ctx = ToolExecutionContext {
1269 session_id: Some("s-explicit-delete-deny"),
1270 tool_call_id: &call.id,
1271 event_tx: None,
1272 available_tool_schemas: None,
1273 bypass_permissions: true,
1274 can_async_resume: false,
1275 bash_completion_sink: None,
1276 pre_parsed_args: None,
1277 };
1278
1279 let result = executor.execute_with_context(&call, ctx).await;
1280 assert!(
1281 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
1282 "explicit delete deny must beat bypass: {result:?}"
1283 );
1284 }
1285
1286 #[tokio::test]
1287 async fn interactive_gate_returns_synthesized_approval_pause() {
1288 let config = Arc::new(crate::permission::PermissionConfig::new());
1296 config.set_ask_rules(["Write(/etc/**)".to_string()]);
1297 config.register_session_workspace("s-interactive", "/workspace/project");
1298 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1299 let executor = make_executor(Some(checker));
1300
1301 let (tx, mut rx) = mpsc::channel(8);
1302 let call = make_tool_call(
1303 "Write",
1304 json!({"file_path": "/etc/gated.conf", "content": "x"}),
1305 );
1306 let ctx = ToolExecutionContext {
1307 session_id: Some("s-interactive"),
1308 tool_call_id: &call.id,
1309 event_tx: Some(&tx),
1310 available_tool_schemas: None,
1311 bypass_permissions: false,
1312 can_async_resume: false,
1313 bash_completion_sink: None,
1314 pre_parsed_args: None,
1315 };
1316
1317 let result = executor
1318 .execute_with_context(&call, ctx)
1319 .await
1320 .expect("interactive gate should pause (Ok), not error");
1321
1322 assert_eq!(
1323 result.display_preference.as_deref(),
1324 Some("request_permissions"),
1325 "interactive gate must return the request_permissions pause result"
1326 );
1327 assert!(result.result.contains("awaiting_permission_approval"));
1328 let payload: serde_json::Value = serde_json::from_str(&result.result).expect("payload");
1329 let request = &payload["permission_request"];
1330 assert_eq!(request["request_id"], call.id);
1331 assert_eq!(request["session_id"], "s-interactive");
1332 assert_eq!(request["workspace_path"], "/workspace/project");
1333 assert_eq!(request["reason_code"], "configured_always_ask");
1334 assert_eq!(
1335 request["allowed_decisions"],
1336 json!(["allow_once", "deny_once"])
1337 );
1338 assert_eq!(payload["options"], json!(["Approve", "Deny"]));
1339 assert!(fs::metadata("/etc/gated.conf").await.is_err());
1340
1341 let ev = rx.recv().await.expect("approval event should be emitted");
1342 assert!(
1343 matches!(ev, AgentEvent::ToolApprovalRequested { tool_name, .. } if tool_name == "Write")
1344 );
1345 }
1346
1347 #[tokio::test]
1348 async fn check_permissions_for_returns_none_when_permitted() {
1349 let executor = make_executor(None);
1352 let call = make_tool_call("Read", json!({"file_path": "/tmp/whatever"}));
1353 let ctx = ToolExecutionContext::none(&call.id);
1354 let decision = executor
1355 .check_permissions_for(&call, &ctx)
1356 .await
1357 .expect("no checker means no gate");
1358 assert!(decision.is_none(), "no checker must yield Ok(None)");
1359 }
1360
1361 struct HostStub {
1364 approve: bool,
1365 }
1366
1367 #[async_trait]
1368 impl crate::approval::ApprovalProxy for HostStub {
1369 async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
1370 self.approve
1371 }
1372 }
1373
1374 #[tokio::test]
1375 async fn approval_proxy_grant_lets_gated_tool_proceed() {
1376 let dir = tempfile::tempdir().unwrap();
1381 let path = dir.path().join("approved.txt");
1382 let path_str = path.to_str().unwrap().to_string();
1383 let config = Arc::new(crate::permission::PermissionConfig::new());
1384 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1385 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1386 let executor = make_executor(Some(checker));
1387
1388 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1389 let ctx = ToolExecutionContext {
1390 session_id: Some("s-worker"),
1391 tool_call_id: &call.id,
1392 event_tx: None,
1393 available_tool_schemas: None,
1394 bypass_permissions: false,
1395 can_async_resume: false,
1396 bash_completion_sink: None,
1397 pre_parsed_args: None,
1398 };
1399
1400 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
1401 let result = crate::approval::with_approval_proxy(
1402 Some(proxy),
1403 executor.execute_with_context(&call, ctx),
1404 )
1405 .await;
1406
1407 assert!(
1408 result.is_ok(),
1409 "host grant should let the write through: {result:?}"
1410 );
1411 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1412 }
1413
1414 #[tokio::test]
1415 async fn approval_proxy_deny_fails_gated_tool_closed() {
1416 let dir = tempfile::tempdir().unwrap();
1419 let path = dir.path().join("denied.txt");
1420 let path_str = path.to_str().unwrap().to_string();
1421 let config = Arc::new(crate::permission::PermissionConfig::new());
1422 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1423 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1424 let executor = make_executor(Some(checker));
1425
1426 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
1427 let ctx = ToolExecutionContext {
1428 session_id: Some("s-worker"),
1429 tool_call_id: &call.id,
1430 event_tx: None,
1431 available_tool_schemas: None,
1432 bypass_permissions: false,
1433 can_async_resume: false,
1434 bash_completion_sink: None,
1435 pre_parsed_args: None,
1436 };
1437
1438 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
1439 let result = crate::approval::with_approval_proxy(
1440 Some(proxy),
1441 executor.execute_with_context(&call, ctx),
1442 )
1443 .await;
1444
1445 assert!(
1446 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
1447 "host deny should fail the tool closed: {result:?}"
1448 );
1449 assert!(fs::metadata(&path).await.is_err());
1450 }
1451
1452 #[tokio::test]
1453 async fn tool_can_stream_events_via_execute_with_context() {
1454 struct StreamingTool;
1455
1456 #[async_trait]
1457 impl Tool for StreamingTool {
1458 fn name(&self) -> &str {
1459 "streaming_tool"
1460 }
1461
1462 fn description(&self) -> &str {
1463 "streams one token"
1464 }
1465
1466 fn parameters_schema(&self) -> serde_json::Value {
1467 json!({"type":"object","properties":{}})
1468 }
1469
1470 async fn invoke(
1471 &self,
1472 _args: serde_json::Value,
1473 ctx: ToolCtx,
1474 ) -> Result<ToolOutcome, ToolError> {
1475 ctx.emit(AgentEvent::Token {
1476 content: "stream".to_string(),
1477 })
1478 .await;
1479 Ok(ToolOutcome::Completed(ToolResult {
1480 success: true,
1481 result: "ok".to_string(),
1482 display_preference: None,
1483 images: Vec::new(),
1484 }))
1485 }
1486 }
1487
1488 let executor = BuiltinToolExecutor::new();
1489 executor
1490 .register_tool(StreamingTool)
1491 .expect("register streaming tool");
1492
1493 let (tx, mut rx) = mpsc::channel(8);
1494 let call = make_tool_call("streaming_tool", json!({}));
1495
1496 let result = executor
1497 .execute_with_context(
1498 &call,
1499 ToolExecutionContext {
1500 session_id: Some("s1"),
1501 tool_call_id: &call.id,
1502 event_tx: Some(&tx),
1503 available_tool_schemas: None,
1504 bypass_permissions: false,
1505 can_async_resume: false,
1506 bash_completion_sink: None,
1507 pre_parsed_args: None,
1508 },
1509 )
1510 .await
1511 .expect("execute tool");
1512
1513 assert!(result.success);
1514 assert_eq!(result.result, "ok");
1515
1516 let ev = rx.recv().await.expect("expected streamed event");
1517 assert!(
1518 matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
1519 );
1520 }
1521
1522 #[tokio::test]
1523 async fn removed_legacy_tools_return_not_found() {
1524 let executor = BuiltinToolExecutor::new();
1525
1526 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1527 let call = make_tool_call(legacy, json!({}));
1528 let result = executor.execute(&call).await;
1529 assert!(matches!(result, Err(ToolError::NotFound(_))));
1530 }
1531 }
1532
1533 #[tokio::test]
1534 async fn executor_prefers_exact_tool_name_before_builtin_alias() {
1535 struct CustomSpawnSessionTool;
1536
1537 #[async_trait]
1538 impl Tool for CustomSpawnSessionTool {
1539 fn name(&self) -> &str {
1540 "spawn_session"
1541 }
1542
1543 fn description(&self) -> &str {
1544 "custom tool for regression coverage"
1545 }
1546
1547 fn parameters_schema(&self) -> serde_json::Value {
1548 json!({"type":"object","properties":{}})
1549 }
1550
1551 async fn invoke(
1552 &self,
1553 _args: serde_json::Value,
1554 _ctx: ToolCtx,
1555 ) -> Result<ToolOutcome, ToolError> {
1556 Ok(ToolOutcome::Completed(ToolResult {
1557 success: true,
1558 result: "custom-spawn-session".to_string(),
1559 display_preference: None,
1560 images: Vec::new(),
1561 }))
1562 }
1563 }
1564
1565 let executor = BuiltinToolExecutorBuilder::new()
1566 .with_tool(CustomSpawnSessionTool)
1567 .expect("register custom spawn_session tool")
1568 .build();
1569
1570 let call = make_tool_call("spawn_session", json!({}));
1571 let result = executor.execute(&call).await.expect("execute custom tool");
1572 assert!(result.success);
1573 assert_eq!(result.result, "custom-spawn-session");
1574 }
1575
1576 struct EchoArgsTool;
1581
1582 #[async_trait]
1583 impl Tool for EchoArgsTool {
1584 fn name(&self) -> &str {
1585 "echo_args"
1586 }
1587 fn description(&self) -> &str {
1588 "echoes the `v` arg"
1589 }
1590 fn parameters_schema(&self) -> serde_json::Value {
1591 json!({"type":"object","properties":{"v":{"type":"string"}}})
1592 }
1593 async fn invoke(
1594 &self,
1595 args: serde_json::Value,
1596 _ctx: ToolCtx,
1597 ) -> Result<ToolOutcome, ToolError> {
1598 let v = args
1599 .get("v")
1600 .and_then(serde_json::Value::as_str)
1601 .unwrap_or("<none>")
1602 .to_string();
1603 Ok(ToolOutcome::Completed(ToolResult {
1604 success: true,
1605 result: v,
1606 display_preference: None,
1607 images: Vec::new(),
1608 }))
1609 }
1610 }
1611
1612 fn ctx_with_pre_parsed<'a>(
1613 call_id: &'a str,
1614 pre_parsed: Option<&'a serde_json::Value>,
1615 ) -> ToolExecutionContext<'a> {
1616 ToolExecutionContext {
1617 session_id: Some("s-106"),
1618 tool_call_id: call_id,
1619 event_tx: None,
1620 available_tool_schemas: None,
1621 bypass_permissions: false,
1622 can_async_resume: false,
1623 bash_completion_sink: None,
1624 pre_parsed_args: pre_parsed,
1625 }
1626 }
1627
1628 #[tokio::test]
1629 async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
1630 let executor = BuiltinToolExecutor::new();
1636 executor.register_tool(EchoArgsTool).expect("register echo");
1637
1638 let call = make_tool_call("echo_args", json!({"v": "raw"}));
1639 let pre_parsed = json!({"v": "preparsed"});
1640 let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
1641
1642 let result = executor
1643 .execute_with_context(&call, ctx)
1644 .await
1645 .expect("execute echo tool");
1646 assert_eq!(
1647 result.result, "preparsed",
1648 "executor must reuse pre_parsed_args, not re-parse the raw string"
1649 );
1650 }
1651
1652 #[tokio::test]
1653 async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
1654 let executor = BuiltinToolExecutor::new();
1658 executor.register_tool(EchoArgsTool).expect("register echo");
1659
1660 let call = make_tool_call("echo_args", json!({"v": "raw"}));
1661 let ctx = ctx_with_pre_parsed(&call.id, None);
1662
1663 let result = executor
1664 .execute_with_context(&call, ctx)
1665 .await
1666 .expect("execute echo tool");
1667 assert_eq!(
1668 result.result, "raw",
1669 "without pre_parsed_args the executor parses the raw string as before"
1670 );
1671 }
1672
1673 #[tokio::test]
1674 async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
1675 let dir = tempfile::tempdir().unwrap();
1679 let path = dir.path().join("recovered-no-preparsed.txt");
1680 let malformed_args = format!(
1681 r#"{{"file_path":"{}","content":"recovered content""#,
1682 path.display()
1683 );
1684
1685 let executor = BuiltinToolExecutor::new();
1686 let call = make_tool_call_with_raw_args("Write", &malformed_args);
1687 let ctx = ctx_with_pre_parsed(&call.id, None);
1688
1689 let result = executor
1690 .execute_with_context(&call, ctx)
1691 .await
1692 .expect("truncated JSON should be auto-repaired");
1693 assert!(result.success);
1694 let written = fs::read_to_string(&path).await.expect("file written");
1695 assert_eq!(written, "recovered content");
1696 }
1697}