1use crate::builtins::Builtin;
35use crate::error::Error;
36use crate::{Bash, ExecResult, ExecutionLimits, OutputCallback};
37use async_trait::async_trait;
38use schemars::{schema_for, JsonSchema};
39use serde::{Deserialize, Serialize};
40use std::sync::{Arc, Mutex};
41
42pub const VERSION: &str = env!("CARGO_PKG_VERSION");
44
45const BUILTINS: &str = "echo cat grep sed awk jq curl head tail sort uniq cut tr wc date sleep mkdir rm cp mv touch chmod printf test [ true false exit cd pwd ls find xargs basename dirname env export read";
47
48const BASE_HELP: &str = r#"BASH(1) User Commands BASH(1)
50
51NAME
52 bashkit - virtual bash interpreter with virtual filesystem
53
54SYNOPSIS
55 {"commands": "<bash commands>"}
56
57DESCRIPTION
58 Bashkit executes bash commands in a virtual environment with a virtual
59 filesystem. All file operations are contained within the virtual environment.
60
61 Supports full bash syntax including variables, pipelines, redirects,
62 loops, conditionals, functions, and arrays.
63
64BUILTINS
65 echo, cat, grep, sed, awk, jq, curl, head, tail, sort, uniq, cut, tr,
66 wc, date, sleep, mkdir, rm, cp, mv, touch, chmod, printf, test, [,
67 true, false, exit, cd, pwd, ls, find, xargs, basename, dirname, env,
68 export, read
69
70INPUT
71 commands Bash commands to execute (like bash -c "commands")
72
73OUTPUT
74 stdout Standard output from the commands
75 stderr Standard error from the commands
76 exit_code Exit status (0 = success)
77
78EXAMPLES
79 Simple echo:
80 {"commands": "echo 'Hello, World!'"}
81
82 Arithmetic:
83 {"commands": "x=5; y=3; echo $((x + y))"}
84
85 Pipeline:
86 {"commands": "echo -e 'apple\nbanana' | grep a"}
87
88 JSON processing:
89 {"commands": "echo '{\"n\":1}' | jq '.n'"}
90
91 File operations (virtual):
92 {"commands": "echo data > /tmp/f.txt && cat /tmp/f.txt"}
93
94 Run script from VFS:
95 {"commands": "source /path/to/script.sh"}
96
97EXIT STATUS
98 0 Success
99 1-125 Command-specific error
100 126 Command not executable
101 127 Command not found
102
103SEE ALSO
104 bash(1), sh(1)
105"#;
106
107#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
111pub struct ToolRequest {
112 pub commands: String,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
118pub struct ToolResponse {
119 pub stdout: String,
121 pub stderr: String,
123 pub exit_code: i32,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub error: Option<String>,
128}
129
130impl From<ExecResult> for ToolResponse {
131 fn from(result: ExecResult) -> Self {
132 Self {
133 stdout: result.stdout,
134 stderr: result.stderr,
135 exit_code: result.exit_code,
136 error: None,
137 }
138 }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ToolStatus {
144 pub phase: String,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub message: Option<String>,
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub percent_complete: Option<f32>,
152 #[serde(skip_serializing_if = "Option::is_none")]
154 pub eta_ms: Option<u64>,
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub output: Option<String>,
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub stream: Option<String>,
161}
162
163impl ToolStatus {
164 pub fn new(phase: impl Into<String>) -> Self {
166 Self {
167 phase: phase.into(),
168 message: None,
169 percent_complete: None,
170 eta_ms: None,
171 output: None,
172 stream: None,
173 }
174 }
175
176 pub fn stdout(chunk: impl Into<String>) -> Self {
178 Self {
179 phase: "output".to_string(),
180 message: None,
181 percent_complete: None,
182 eta_ms: None,
183 output: Some(chunk.into()),
184 stream: Some("stdout".to_string()),
185 }
186 }
187
188 pub fn stderr(chunk: impl Into<String>) -> Self {
190 Self {
191 phase: "output".to_string(),
192 message: None,
193 percent_complete: None,
194 eta_ms: None,
195 output: Some(chunk.into()),
196 stream: Some("stderr".to_string()),
197 }
198 }
199
200 pub fn with_message(mut self, message: impl Into<String>) -> Self {
202 self.message = Some(message.into());
203 self
204 }
205
206 pub fn with_percent(mut self, percent: f32) -> Self {
208 self.percent_complete = Some(percent);
209 self
210 }
211
212 pub fn with_eta(mut self, eta_ms: u64) -> Self {
214 self.eta_ms = Some(eta_ms);
215 self
216 }
217}
218
219#[async_trait]
237pub trait Tool: Send + Sync {
238 fn name(&self) -> &str;
240
241 fn short_description(&self) -> &str;
243
244 fn description(&self) -> String;
246
247 fn help(&self) -> String;
249
250 fn system_prompt(&self) -> String;
252
253 fn input_schema(&self) -> serde_json::Value;
255
256 fn output_schema(&self) -> serde_json::Value;
258
259 fn version(&self) -> &str;
261
262 async fn execute(&mut self, req: ToolRequest) -> ToolResponse;
264
265 async fn execute_with_status(
267 &mut self,
268 req: ToolRequest,
269 status_callback: Box<dyn FnMut(ToolStatus) + Send>,
270 ) -> ToolResponse;
271}
272
273#[derive(Default)]
279pub struct BashToolBuilder {
280 username: Option<String>,
282 hostname: Option<String>,
284 limits: Option<ExecutionLimits>,
286 env_vars: Vec<(String, String)>,
288 builtins: Vec<(String, Box<dyn Builtin>)>,
290}
291
292impl BashToolBuilder {
293 pub fn new() -> Self {
295 Self::default()
296 }
297
298 pub fn username(mut self, username: impl Into<String>) -> Self {
300 self.username = Some(username.into());
301 self
302 }
303
304 pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
306 self.hostname = Some(hostname.into());
307 self
308 }
309
310 pub fn limits(mut self, limits: ExecutionLimits) -> Self {
312 self.limits = Some(limits);
313 self
314 }
315
316 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
318 self.env_vars.push((key.into(), value.into()));
319 self
320 }
321
322 pub fn builtin(mut self, name: impl Into<String>, builtin: Box<dyn Builtin>) -> Self {
329 self.builtins.push((name.into(), builtin));
330 self
331 }
332
333 #[cfg(feature = "python")]
340 pub fn python(self) -> Self {
341 self.python_with_limits(crate::builtins::PythonLimits::default())
342 }
343
344 #[cfg(feature = "python")]
346 pub fn python_with_limits(self, limits: crate::builtins::PythonLimits) -> Self {
347 use crate::builtins::Python;
348 self.builtin("python", Box::new(Python::with_limits(limits.clone())))
349 .builtin("python3", Box::new(Python::with_limits(limits)))
350 }
351
352 pub fn build(self) -> BashTool {
354 let builtin_names: Vec<String> = self.builtins.iter().map(|(n, _)| n.clone()).collect();
355
356 let mut builtin_hints: Vec<String> = self
358 .builtins
359 .iter()
360 .filter_map(|(_, b)| b.llm_hint().map(String::from))
361 .collect();
362 builtin_hints.sort();
363 builtin_hints.dedup();
364
365 BashTool {
366 username: self.username,
367 hostname: self.hostname,
368 limits: self.limits,
369 env_vars: self.env_vars,
370 builtins: self.builtins,
371 builtin_names,
372 builtin_hints,
373 }
374 }
375}
376
377#[derive(Default)]
379pub struct BashTool {
380 username: Option<String>,
381 hostname: Option<String>,
382 limits: Option<ExecutionLimits>,
383 env_vars: Vec<(String, String)>,
384 builtins: Vec<(String, Box<dyn Builtin>)>,
385 builtin_names: Vec<String>,
387 builtin_hints: Vec<String>,
389}
390
391impl BashTool {
392 pub fn builder() -> BashToolBuilder {
394 BashToolBuilder::new()
395 }
396
397 fn create_bash(&mut self) -> Bash {
399 let mut builder = Bash::builder();
400
401 if let Some(ref username) = self.username {
402 builder = builder.username(username);
403 }
404 if let Some(ref hostname) = self.hostname {
405 builder = builder.hostname(hostname);
406 }
407 if let Some(ref limits) = self.limits {
408 builder = builder.limits(limits.clone());
409 }
410 for (key, value) in &self.env_vars {
411 builder = builder.env(key, value);
412 }
413 for (name, builtin) in std::mem::take(&mut self.builtins) {
415 builder = builder.builtin(name, builtin);
416 }
417
418 builder.build()
419 }
420
421 fn build_description(&self) -> String {
423 let mut desc =
424 String::from("Virtual bash interpreter with virtual filesystem. Supported tools: ");
425 desc.push_str(BUILTINS);
426 if !self.builtin_names.is_empty() {
427 desc.push(' ');
428 desc.push_str(&self.builtin_names.join(" "));
429 }
430 desc
431 }
432
433 fn build_help(&self) -> String {
435 let mut doc = BASE_HELP.to_string();
436
437 let has_config = !self.builtin_names.is_empty()
439 || self.username.is_some()
440 || self.hostname.is_some()
441 || self.limits.is_some()
442 || !self.env_vars.is_empty();
443
444 if has_config {
445 doc.push_str("\nCONFIGURATION\n");
446
447 if !self.builtin_names.is_empty() {
448 doc.push_str(" Custom commands: ");
449 doc.push_str(&self.builtin_names.join(", "));
450 doc.push('\n');
451 }
452
453 if let Some(ref username) = self.username {
454 doc.push_str(&format!(" User: {} (whoami)\n", username));
455 }
456 if let Some(ref hostname) = self.hostname {
457 doc.push_str(&format!(" Host: {} (hostname)\n", hostname));
458 }
459
460 if let Some(ref limits) = self.limits {
461 doc.push_str(&format!(
462 " Limits: {} commands, {} iterations, {} depth\n",
463 limits.max_commands, limits.max_loop_iterations, limits.max_function_depth
464 ));
465 }
466
467 if !self.env_vars.is_empty() {
468 doc.push_str(" Environment: ");
469 let keys: Vec<&str> = self.env_vars.iter().map(|(k, _)| k.as_str()).collect();
470 doc.push_str(&keys.join(", "));
471 doc.push('\n');
472 }
473 }
474
475 if !self.builtin_hints.is_empty() {
477 doc.push_str("\nNOTES\n");
478 for hint in &self.builtin_hints {
479 doc.push_str(&format!(" {hint}\n"));
480 }
481 }
482
483 if let Some(warning) = self.language_warning() {
485 doc.push_str(&format!("\nWARNINGS\n {warning}\n"));
486 }
487
488 doc
489 }
490
491 fn language_warning(&self) -> Option<String> {
494 let mut missing = Vec::new();
495
496 let has_perl = self.builtin_names.iter().any(|n| n == "perl");
497 if !has_perl {
498 missing.push("perl");
499 }
500
501 let has_python = self
502 .builtin_names
503 .iter()
504 .any(|n| n == "python" || n == "python3");
505 if !has_python {
506 missing.push("python/python3");
507 }
508
509 if missing.is_empty() {
510 None
511 } else {
512 Some(format!("{} not available.", missing.join(", ")))
513 }
514 }
515
516 fn build_system_prompt(&self) -> String {
518 let mut prompt = String::from("# Bash Tool\n\n");
519
520 prompt.push_str("Virtual bash interpreter with virtual filesystem.\n");
522
523 if let Some(ref username) = self.username {
525 prompt.push_str(&format!("Home: /home/{}\n", username));
526 }
527
528 prompt.push('\n');
529
530 prompt.push_str("Input: {\"commands\": \"<bash commands>\"}\n");
532 prompt.push_str("Output: {stdout, stderr, exit_code}\n");
533
534 if !self.builtin_hints.is_empty() {
536 prompt.push('\n');
537 for hint in &self.builtin_hints {
538 prompt.push_str(&format!("Note: {hint}\n"));
539 }
540 }
541
542 if let Some(warning) = self.language_warning() {
544 prompt.push_str(&format!("\nWarning: {warning}\n"));
545 }
546
547 prompt
548 }
549}
550
551#[async_trait]
552impl Tool for BashTool {
553 fn name(&self) -> &str {
554 "bashkit"
555 }
556
557 fn short_description(&self) -> &str {
558 "Virtual bash interpreter with virtual filesystem"
559 }
560
561 fn description(&self) -> String {
562 self.build_description()
563 }
564
565 fn help(&self) -> String {
566 self.build_help()
567 }
568
569 fn system_prompt(&self) -> String {
570 self.build_system_prompt()
571 }
572
573 fn input_schema(&self) -> serde_json::Value {
574 let schema = schema_for!(ToolRequest);
575 serde_json::to_value(schema).unwrap_or_default()
576 }
577
578 fn output_schema(&self) -> serde_json::Value {
579 let schema = schema_for!(ToolResponse);
580 serde_json::to_value(schema).unwrap_or_default()
581 }
582
583 fn version(&self) -> &str {
584 VERSION
585 }
586
587 async fn execute(&mut self, req: ToolRequest) -> ToolResponse {
588 if req.commands.is_empty() {
589 return ToolResponse {
590 stdout: String::new(),
591 stderr: String::new(),
592 exit_code: 0,
593 error: None,
594 };
595 }
596
597 let mut bash = self.create_bash();
598
599 match bash.exec(&req.commands).await {
600 Ok(result) => result.into(),
601 Err(e) => ToolResponse {
602 stdout: String::new(),
603 stderr: e.to_string(),
604 exit_code: 1,
605 error: Some(error_kind(&e)),
606 },
607 }
608 }
609
610 async fn execute_with_status(
611 &mut self,
612 req: ToolRequest,
613 mut status_callback: Box<dyn FnMut(ToolStatus) + Send>,
614 ) -> ToolResponse {
615 status_callback(ToolStatus::new("validate").with_percent(0.0));
616
617 if req.commands.is_empty() {
618 status_callback(ToolStatus::new("complete").with_percent(100.0));
619 return ToolResponse {
620 stdout: String::new(),
621 stderr: String::new(),
622 exit_code: 0,
623 error: None,
624 };
625 }
626
627 status_callback(ToolStatus::new("parse").with_percent(10.0));
628
629 let mut bash = self.create_bash();
630
631 status_callback(ToolStatus::new("execute").with_percent(20.0));
632
633 let status_cb = Arc::new(Mutex::new(status_callback));
635 let status_cb_output = status_cb.clone();
636 let output_cb: OutputCallback = Box::new(move |stdout_chunk, stderr_chunk| {
637 if let Ok(mut cb) = status_cb_output.lock() {
638 if !stdout_chunk.is_empty() {
639 cb(ToolStatus::stdout(stdout_chunk));
640 }
641 if !stderr_chunk.is_empty() {
642 cb(ToolStatus::stderr(stderr_chunk));
643 }
644 }
645 });
646
647 let response = match bash.exec_streaming(&req.commands, output_cb).await {
648 Ok(result) => result.into(),
649 Err(e) => ToolResponse {
650 stdout: String::new(),
651 stderr: e.to_string(),
652 exit_code: 1,
653 error: Some(error_kind(&e)),
654 },
655 };
656
657 if let Ok(mut cb) = status_cb.lock() {
658 cb(ToolStatus::new("complete").with_percent(100.0));
659 }
660
661 response
662 }
663}
664
665fn error_kind(e: &Error) -> String {
667 match e {
668 Error::Parse(_) | Error::ParseAt { .. } => "parse_error".to_string(),
669 Error::Execution(_) => "execution_error".to_string(),
670 Error::Io(_) => "io_error".to_string(),
671 Error::CommandNotFound(_) => "command_not_found".to_string(),
672 Error::ResourceLimit(_) => "resource_limit".to_string(),
673 Error::Network(_) => "network_error".to_string(),
674 Error::Internal(_) => "internal_error".to_string(),
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 #[test]
683 fn test_bash_tool_builder() {
684 let tool = BashTool::builder()
685 .username("testuser")
686 .hostname("testhost")
687 .env("FOO", "bar")
688 .limits(ExecutionLimits::new().max_commands(100))
689 .build();
690
691 assert_eq!(tool.username, Some("testuser".to_string()));
692 assert_eq!(tool.hostname, Some("testhost".to_string()));
693 assert_eq!(tool.env_vars, vec![("FOO".to_string(), "bar".to_string())]);
694 }
695
696 #[test]
697 fn test_tool_trait_methods() {
698 let tool = BashTool::default();
699
700 assert_eq!(tool.name(), "bashkit");
702 assert_eq!(
703 tool.short_description(),
704 "Virtual bash interpreter with virtual filesystem"
705 );
706 assert!(tool.description().contains("Virtual bash interpreter"));
707 assert!(tool.description().contains("Supported tools:"));
708 assert!(tool.help().contains("BASH(1)"));
709 assert!(tool.help().contains("SYNOPSIS"));
710 assert!(tool.system_prompt().contains("# Bash Tool"));
711 assert_eq!(tool.version(), VERSION);
712 }
713
714 #[test]
715 fn test_tool_description_with_config() {
716 let tool = BashTool::builder()
717 .username("agent")
718 .hostname("sandbox")
719 .env("API_KEY", "secret")
720 .limits(ExecutionLimits::new().max_commands(50))
721 .build();
722
723 let helptext = tool.help();
725 assert!(helptext.contains("CONFIGURATION"));
726 assert!(helptext.contains("User: agent"));
727 assert!(helptext.contains("Host: sandbox"));
728 assert!(helptext.contains("50 commands"));
729 assert!(helptext.contains("API_KEY"));
730
731 let sysprompt = tool.system_prompt();
733 assert!(sysprompt.contains("# Bash Tool"));
734 assert!(sysprompt.contains("Home: /home/agent"));
735 }
736
737 #[test]
738 fn test_tool_schemas() {
739 let tool = BashTool::default();
740 let input_schema = tool.input_schema();
741 let output_schema = tool.output_schema();
742
743 assert!(input_schema["properties"]["commands"].is_object());
745
746 assert!(output_schema["properties"]["stdout"].is_object());
748 assert!(output_schema["properties"]["stderr"].is_object());
749 assert!(output_schema["properties"]["exit_code"].is_object());
750 }
751
752 #[test]
753 fn test_tool_status() {
754 let status = ToolStatus::new("execute")
755 .with_message("Running commands")
756 .with_percent(50.0)
757 .with_eta(5000);
758
759 assert_eq!(status.phase, "execute");
760 assert_eq!(status.message, Some("Running commands".to_string()));
761 assert_eq!(status.percent_complete, Some(50.0));
762 assert_eq!(status.eta_ms, Some(5000));
763 }
764
765 #[tokio::test]
766 async fn test_tool_execute_empty() {
767 let mut tool = BashTool::default();
768 let req = ToolRequest {
769 commands: String::new(),
770 };
771 let resp = tool.execute(req).await;
772 assert_eq!(resp.exit_code, 0);
773 assert!(resp.error.is_none());
774 }
775
776 #[tokio::test]
777 async fn test_tool_execute_echo() {
778 let mut tool = BashTool::default();
779 let req = ToolRequest {
780 commands: "echo hello".to_string(),
781 };
782 let resp = tool.execute(req).await;
783 assert_eq!(resp.stdout, "hello\n");
784 assert_eq!(resp.exit_code, 0);
785 assert!(resp.error.is_none());
786 }
787
788 #[test]
789 fn test_builtin_hints_in_help_and_system_prompt() {
790 use crate::builtins::Builtin;
791 use crate::error::Result;
792 use crate::interpreter::ExecResult;
793
794 struct HintedBuiltin;
795
796 #[async_trait]
797 impl Builtin for HintedBuiltin {
798 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
799 Ok(ExecResult::ok(String::new()))
800 }
801 fn llm_hint(&self) -> Option<&'static str> {
802 Some("mycommand: Processes CSV. Max 10MB. No streaming.")
803 }
804 }
805
806 let tool = BashTool::builder()
807 .builtin("mycommand", Box::new(HintedBuiltin))
808 .build();
809
810 let helptext = tool.help();
812 assert!(helptext.contains("NOTES"), "help should have NOTES section");
813 assert!(
814 helptext.contains("mycommand: Processes CSV"),
815 "help should contain the hint"
816 );
817
818 let sysprompt = tool.system_prompt();
820 assert!(
821 sysprompt.contains("mycommand: Processes CSV"),
822 "system_prompt should contain the hint"
823 );
824 }
825
826 #[test]
827 fn test_no_hints_without_hinted_builtins() {
828 let tool = BashTool::default();
829
830 let helptext = tool.help();
831 assert!(
832 !helptext.contains("NOTES"),
833 "help should not have NOTES without hinted builtins"
834 );
835
836 let sysprompt = tool.system_prompt();
837 assert!(
838 !sysprompt.contains("Note:"),
839 "system_prompt should not have notes without hinted builtins"
840 );
841 }
842
843 #[test]
844 fn test_language_warning_default() {
845 let tool = BashTool::default();
846
847 let sysprompt = tool.system_prompt();
848 assert!(
849 sysprompt.contains("Warning: perl, python/python3 not available."),
850 "system_prompt should have single combined warning"
851 );
852
853 let helptext = tool.help();
854 assert!(
855 helptext.contains("WARNINGS"),
856 "help should have WARNINGS section"
857 );
858 assert!(
859 helptext.contains("perl, python/python3 not available."),
860 "help should have single combined warning"
861 );
862 }
863
864 #[test]
865 fn test_language_warning_suppressed_by_custom_builtins() {
866 use crate::builtins::Builtin;
867 use crate::error::Result;
868 use crate::interpreter::ExecResult;
869
870 struct NoopBuiltin;
871
872 #[async_trait]
873 impl Builtin for NoopBuiltin {
874 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
875 Ok(ExecResult::ok(String::new()))
876 }
877 }
878
879 let tool = BashTool::builder()
880 .builtin("python", Box::new(NoopBuiltin))
881 .builtin("perl", Box::new(NoopBuiltin))
882 .build();
883
884 let sysprompt = tool.system_prompt();
885 assert!(
886 !sysprompt.contains("Warning:"),
887 "no warning when all languages registered"
888 );
889
890 let helptext = tool.help();
891 assert!(
892 !helptext.contains("WARNINGS"),
893 "no WARNINGS section when all languages registered"
894 );
895 }
896
897 #[test]
898 fn test_language_warning_partial() {
899 use crate::builtins::Builtin;
900 use crate::error::Result;
901 use crate::interpreter::ExecResult;
902
903 struct NoopBuiltin;
904
905 #[async_trait]
906 impl Builtin for NoopBuiltin {
907 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
908 Ok(ExecResult::ok(String::new()))
909 }
910 }
911
912 let tool = BashTool::builder()
914 .builtin("python3", Box::new(NoopBuiltin))
915 .build();
916
917 let sysprompt = tool.system_prompt();
918 assert!(
919 sysprompt.contains("Warning: perl not available."),
920 "should warn about perl only"
921 );
922 assert!(
923 !sysprompt.contains("python/python3"),
924 "python warning suppressed when python3 registered"
925 );
926 }
927
928 #[test]
929 fn test_duplicate_hints_deduplicated() {
930 use crate::builtins::Builtin;
931 use crate::error::Result;
932 use crate::interpreter::ExecResult;
933
934 struct SameHint;
935
936 #[async_trait]
937 impl Builtin for SameHint {
938 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
939 Ok(ExecResult::ok(String::new()))
940 }
941 fn llm_hint(&self) -> Option<&'static str> {
942 Some("same hint")
943 }
944 }
945
946 let tool = BashTool::builder()
947 .builtin("cmd1", Box::new(SameHint))
948 .builtin("cmd2", Box::new(SameHint))
949 .build();
950
951 let helptext = tool.help();
952 assert_eq!(
954 helptext.matches("same hint").count(),
955 1,
956 "Duplicate hints should be deduplicated"
957 );
958 }
959
960 #[cfg(feature = "python")]
961 #[test]
962 fn test_python_hint_via_builder() {
963 let tool = BashTool::builder().python().build();
964
965 let helptext = tool.help();
966 assert!(helptext.contains("python"), "help should mention python");
967 assert!(
968 helptext.contains("no open()"),
969 "help should document open() limitation"
970 );
971 assert!(
972 helptext.contains("No HTTP"),
973 "help should document HTTP limitation"
974 );
975
976 let sysprompt = tool.system_prompt();
977 assert!(
978 sysprompt.contains("python"),
979 "system_prompt should mention python"
980 );
981
982 assert!(
984 !sysprompt.contains("python/python3 not available"),
985 "python warning should not appear when Monty python enabled"
986 );
987 }
988
989 #[tokio::test]
990 async fn test_tool_execute_with_status() {
991 use std::sync::{Arc, Mutex};
992
993 let mut tool = BashTool::default();
994 let req = ToolRequest {
995 commands: "echo test".to_string(),
996 };
997
998 let phases = Arc::new(Mutex::new(Vec::new()));
999 let phases_clone = phases.clone();
1000
1001 let resp = tool
1002 .execute_with_status(
1003 req,
1004 Box::new(move |status| {
1005 phases_clone
1006 .lock()
1007 .expect("lock poisoned")
1008 .push(status.phase.clone());
1009 }),
1010 )
1011 .await;
1012
1013 assert_eq!(resp.stdout, "test\n");
1014 let phases = phases.lock().expect("lock poisoned");
1015 assert!(phases.contains(&"validate".to_string()));
1016 assert!(phases.contains(&"complete".to_string()));
1017 }
1018
1019 #[tokio::test]
1020 async fn test_execute_with_status_streams_output() {
1021 let mut tool = BashTool::default();
1022 let req = ToolRequest {
1023 commands: "for i in a b c; do echo $i; done".to_string(),
1024 };
1025
1026 let events = Arc::new(Mutex::new(Vec::new()));
1027 let events_clone = events.clone();
1028
1029 let resp = tool
1030 .execute_with_status(
1031 req,
1032 Box::new(move |status| {
1033 events_clone.lock().expect("lock poisoned").push(status);
1034 }),
1035 )
1036 .await;
1037
1038 assert_eq!(resp.stdout, "a\nb\nc\n");
1039 assert_eq!(resp.exit_code, 0);
1040
1041 let events = events.lock().expect("lock poisoned");
1042 let output_events: Vec<_> = events.iter().filter(|s| s.phase == "output").collect();
1044 assert_eq!(
1045 output_events.len(),
1046 3,
1047 "expected 3 output events, got {output_events:?}"
1048 );
1049 assert_eq!(output_events[0].output.as_deref(), Some("a\n"));
1050 assert_eq!(output_events[0].stream.as_deref(), Some("stdout"));
1051 assert_eq!(output_events[1].output.as_deref(), Some("b\n"));
1052 assert_eq!(output_events[2].output.as_deref(), Some("c\n"));
1053 }
1054
1055 #[tokio::test]
1056 async fn test_execute_with_status_streams_list_commands() {
1057 let mut tool = BashTool::default();
1058 let req = ToolRequest {
1059 commands: "echo start; echo end".to_string(),
1060 };
1061
1062 let events = Arc::new(Mutex::new(Vec::new()));
1063 let events_clone = events.clone();
1064
1065 let resp = tool
1066 .execute_with_status(
1067 req,
1068 Box::new(move |status| {
1069 events_clone.lock().expect("lock poisoned").push(status);
1070 }),
1071 )
1072 .await;
1073
1074 assert_eq!(resp.stdout, "start\nend\n");
1075
1076 let events = events.lock().expect("lock poisoned");
1077 let output_events: Vec<_> = events.iter().filter(|s| s.phase == "output").collect();
1078 assert_eq!(
1079 output_events.len(),
1080 2,
1081 "expected 2 output events, got {output_events:?}"
1082 );
1083 assert_eq!(output_events[0].output.as_deref(), Some("start\n"));
1084 assert_eq!(output_events[1].output.as_deref(), Some("end\n"));
1085 }
1086
1087 #[tokio::test]
1088 async fn test_execute_with_status_no_duplicate_output() {
1089 let mut tool = BashTool::default();
1090 let req = ToolRequest {
1092 commands: "echo start; for i in 1 2 3; do echo $i; done; echo end".to_string(),
1093 };
1094
1095 let events = Arc::new(Mutex::new(Vec::new()));
1096 let events_clone = events.clone();
1097
1098 let resp = tool
1099 .execute_with_status(
1100 req,
1101 Box::new(move |status| {
1102 events_clone.lock().expect("lock poisoned").push(status);
1103 }),
1104 )
1105 .await;
1106
1107 assert_eq!(resp.stdout, "start\n1\n2\n3\nend\n");
1108
1109 let events = events.lock().expect("lock poisoned");
1110 let output_events: Vec<_> = events
1111 .iter()
1112 .filter(|s| s.phase == "output")
1113 .map(|s| s.output.as_deref().unwrap_or(""))
1114 .collect();
1115 assert_eq!(
1116 output_events,
1117 vec!["start\n", "1\n", "2\n", "3\n", "end\n"],
1118 "should have exactly 5 distinct output events"
1119 );
1120 }
1121
1122 #[test]
1123 fn test_tool_status_stdout_constructor() {
1124 let status = ToolStatus::stdout("hello\n");
1125 assert_eq!(status.phase, "output");
1126 assert_eq!(status.output.as_deref(), Some("hello\n"));
1127 assert_eq!(status.stream.as_deref(), Some("stdout"));
1128 assert!(status.message.is_none());
1129 }
1130
1131 #[test]
1132 fn test_tool_status_stderr_constructor() {
1133 let status = ToolStatus::stderr("error\n");
1134 assert_eq!(status.phase, "output");
1135 assert_eq!(status.output.as_deref(), Some("error\n"));
1136 assert_eq!(status.stream.as_deref(), Some("stderr"));
1137 }
1138}