1use crate::builtins::Builtin;
36use crate::error::Error;
37use crate::{Bash, ExecResult, ExecutionLimits, OutputCallback};
38use async_trait::async_trait;
39use schemars::{schema_for, JsonSchema};
40use serde::{Deserialize, Serialize};
41use std::sync::{Arc, Mutex};
42use std::time::Duration;
43
44pub const VERSION: &str = env!("CARGO_PKG_VERSION");
46
47const BUILTINS: &str = "\
49echo printf cat read \
50grep sed awk jq head tail sort uniq cut tr wc nl paste column comm diff strings tac rev \
51cd pwd ls find mkdir mktemp rm rmdir cp mv touch chmod chown ln \
52file stat less tar gzip gunzip du df \
53test [ true false exit return break continue \
54export set unset local shift source eval declare typeset readonly shopt getopts \
55sleep date seq expr yes wait timeout xargs tee watch \
56basename dirname realpath \
57pushd popd dirs \
58whoami hostname uname id env printenv history \
59curl wget \
60od xxd hexdump base64 \
61kill";
62
63const BASE_HELP: &str = r#"BASH(1) User Commands BASH(1)
65
66NAME
67 bashkit - virtual bash interpreter with virtual filesystem
68
69SYNOPSIS
70 {"commands": "<bash commands>"}
71
72DESCRIPTION
73 Bashkit executes bash commands in a virtual environment with a virtual
74 filesystem. All file operations are contained within the virtual environment.
75
76 Supports full bash syntax including variables, pipelines, redirects,
77 loops, conditionals, functions, and arrays.
78
79BUILTINS
80 Core I/O: echo, printf, cat, read
81 Text Processing: grep, sed, awk, jq, head, tail, sort, uniq, cut, tr, wc,
82 nl, paste, column, comm, diff, strings, tac, rev
83 File Operations: cd, pwd, ls, find, mkdir, mktemp, rm, rmdir, cp, mv,
84 touch, chmod, chown, ln
85 File Inspection: file, stat, less, tar, gzip, gunzip, du, df
86 Flow Control: test, [, true, false, exit, return, break, continue
87 Shell/Variables: export, set, unset, local, shift, source, eval, declare,
88 typeset, readonly, shopt, getopts
89 Utilities: sleep, date, seq, expr, yes, wait, timeout, xargs, tee,
90 watch, basename, dirname, realpath
91 Dir Stack: pushd, popd, dirs
92 System Info: whoami, hostname, uname, id, env, printenv, history
93 Network: curl, wget
94 Binary/Hex: od, xxd, hexdump, base64
95 Signals: kill
96
97INPUT
98 commands Bash commands to execute (like bash -c "commands")
99
100OUTPUT
101 stdout Standard output from the commands
102 stderr Standard error from the commands
103 exit_code Exit status (0 = success)
104
105EXAMPLES
106 Simple echo:
107 {"commands": "echo 'Hello, World!'"}
108
109 Arithmetic:
110 {"commands": "x=5; y=3; echo $((x + y))"}
111
112 Pipeline:
113 {"commands": "echo -e 'apple\nbanana' | grep a"}
114
115 JSON processing:
116 {"commands": "echo '{\"n\":1}' | jq '.n'"}
117
118 File operations (virtual):
119 {"commands": "echo data > /tmp/f.txt && cat /tmp/f.txt"}
120
121 Run script from VFS:
122 {"commands": "source /path/to/script.sh"}
123
124EXIT STATUS
125 0 Success
126 1-125 Command-specific error
127 126 Command not executable
128 127 Command not found
129
130SEE ALSO
131 bash(1), sh(1)
132"#;
133
134#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
138pub struct ToolRequest {
139 pub commands: String,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub timeout_ms: Option<u64>,
146}
147
148impl ToolRequest {
149 pub fn new(commands: impl Into<String>) -> Self {
151 Self {
152 commands: commands.into(),
153 timeout_ms: None,
154 }
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
160pub struct ToolResponse {
161 pub stdout: String,
163 pub stderr: String,
165 pub exit_code: i32,
167 #[serde(skip_serializing_if = "Option::is_none")]
169 pub error: Option<String>,
170}
171
172impl From<ExecResult> for ToolResponse {
173 fn from(result: ExecResult) -> Self {
174 Self {
175 stdout: result.stdout,
176 stderr: result.stderr,
177 exit_code: result.exit_code,
178 error: None,
179 }
180 }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct ToolStatus {
186 pub phase: String,
188 #[serde(skip_serializing_if = "Option::is_none")]
190 pub message: Option<String>,
191 #[serde(skip_serializing_if = "Option::is_none")]
193 pub percent_complete: Option<f32>,
194 #[serde(skip_serializing_if = "Option::is_none")]
196 pub eta_ms: Option<u64>,
197 #[serde(skip_serializing_if = "Option::is_none")]
199 pub output: Option<String>,
200 #[serde(skip_serializing_if = "Option::is_none")]
202 pub stream: Option<String>,
203}
204
205impl ToolStatus {
206 pub fn new(phase: impl Into<String>) -> Self {
208 Self {
209 phase: phase.into(),
210 message: None,
211 percent_complete: None,
212 eta_ms: None,
213 output: None,
214 stream: None,
215 }
216 }
217
218 pub fn stdout(chunk: impl Into<String>) -> Self {
220 Self {
221 phase: "output".to_string(),
222 message: None,
223 percent_complete: None,
224 eta_ms: None,
225 output: Some(chunk.into()),
226 stream: Some("stdout".to_string()),
227 }
228 }
229
230 pub fn stderr(chunk: impl Into<String>) -> Self {
232 Self {
233 phase: "output".to_string(),
234 message: None,
235 percent_complete: None,
236 eta_ms: None,
237 output: Some(chunk.into()),
238 stream: Some("stderr".to_string()),
239 }
240 }
241
242 pub fn with_message(mut self, message: impl Into<String>) -> Self {
244 self.message = Some(message.into());
245 self
246 }
247
248 pub fn with_percent(mut self, percent: f32) -> Self {
250 self.percent_complete = Some(percent);
251 self
252 }
253
254 pub fn with_eta(mut self, eta_ms: u64) -> Self {
256 self.eta_ms = Some(eta_ms);
257 self
258 }
259}
260
261#[async_trait]
279pub trait Tool: Send + Sync {
280 fn name(&self) -> &str;
282
283 fn short_description(&self) -> &str;
285
286 fn description(&self) -> String;
288
289 fn help(&self) -> String;
291
292 fn system_prompt(&self) -> String;
294
295 fn input_schema(&self) -> serde_json::Value;
297
298 fn output_schema(&self) -> serde_json::Value;
300
301 fn version(&self) -> &str;
303
304 async fn execute(&mut self, req: ToolRequest) -> ToolResponse;
306
307 async fn execute_with_status(
309 &mut self,
310 req: ToolRequest,
311 status_callback: Box<dyn FnMut(ToolStatus) + Send>,
312 ) -> ToolResponse;
313}
314
315#[derive(Default)]
321pub struct BashToolBuilder {
322 username: Option<String>,
324 hostname: Option<String>,
326 limits: Option<ExecutionLimits>,
328 env_vars: Vec<(String, String)>,
330 builtins: Vec<(String, Arc<dyn Builtin>)>,
332}
333
334impl BashToolBuilder {
335 pub fn new() -> Self {
337 Self::default()
338 }
339
340 pub fn username(mut self, username: impl Into<String>) -> Self {
342 self.username = Some(username.into());
343 self
344 }
345
346 pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
348 self.hostname = Some(hostname.into());
349 self
350 }
351
352 pub fn limits(mut self, limits: ExecutionLimits) -> Self {
354 self.limits = Some(limits);
355 self
356 }
357
358 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
360 self.env_vars.push((key.into(), value.into()));
361 self
362 }
363
364 pub fn builtin(mut self, name: impl Into<String>, builtin: Box<dyn Builtin>) -> Self {
371 self.builtins.push((name.into(), Arc::from(builtin)));
372 self
373 }
374
375 #[cfg(feature = "python")]
382 pub fn python(self) -> Self {
383 self.python_with_limits(crate::builtins::PythonLimits::default())
384 }
385
386 #[cfg(feature = "python")]
388 pub fn python_with_limits(self, limits: crate::builtins::PythonLimits) -> Self {
389 use crate::builtins::Python;
390 self.builtin("python", Box::new(Python::with_limits(limits.clone())))
391 .builtin("python3", Box::new(Python::with_limits(limits)))
392 }
393
394 pub fn build(self) -> BashTool {
396 let builtin_names: Vec<String> = self.builtins.iter().map(|(n, _)| n.clone()).collect();
397
398 let mut builtin_hints: Vec<String> = self
400 .builtins
401 .iter()
402 .filter_map(|(_, b)| b.llm_hint().map(String::from))
403 .collect();
404 builtin_hints.sort();
405 builtin_hints.dedup();
406
407 BashTool {
408 username: self.username,
409 hostname: self.hostname,
410 limits: self.limits,
411 env_vars: self.env_vars,
412 builtins: self.builtins,
413 builtin_names,
414 builtin_hints,
415 }
416 }
417}
418
419#[derive(Default)]
421pub struct BashTool {
422 username: Option<String>,
423 hostname: Option<String>,
424 limits: Option<ExecutionLimits>,
425 env_vars: Vec<(String, String)>,
426 builtins: Vec<(String, Arc<dyn Builtin>)>,
427 builtin_names: Vec<String>,
429 builtin_hints: Vec<String>,
431}
432
433impl BashTool {
434 pub fn builder() -> BashToolBuilder {
436 BashToolBuilder::new()
437 }
438
439 fn create_bash(&mut self) -> Bash {
441 let mut builder = Bash::builder();
442
443 if let Some(ref username) = self.username {
444 builder = builder.username(username);
445 }
446 if let Some(ref hostname) = self.hostname {
447 builder = builder.hostname(hostname);
448 }
449 if let Some(ref limits) = self.limits {
450 builder = builder.limits(limits.clone());
451 }
452 for (key, value) in &self.env_vars {
453 builder = builder.env(key, value);
454 }
455 for (name, builtin) in &self.builtins {
457 builder = builder.builtin(name.clone(), Box::new(Arc::clone(builtin)));
458 }
459
460 builder.build()
461 }
462
463 fn build_description(&self) -> String {
465 let mut desc =
466 String::from("Virtual bash interpreter with virtual filesystem. Supported tools: ");
467 desc.push_str(BUILTINS);
468 if !self.builtin_names.is_empty() {
469 desc.push(' ');
470 desc.push_str(&self.builtin_names.join(" "));
471 }
472 desc
473 }
474
475 fn build_help(&self) -> String {
477 let mut doc = BASE_HELP.to_string();
478
479 let has_config = !self.builtin_names.is_empty()
481 || self.username.is_some()
482 || self.hostname.is_some()
483 || self.limits.is_some()
484 || !self.env_vars.is_empty();
485
486 if has_config {
487 doc.push_str("\nCONFIGURATION\n");
488
489 if !self.builtin_names.is_empty() {
490 doc.push_str(" Custom commands: ");
491 doc.push_str(&self.builtin_names.join(", "));
492 doc.push('\n');
493 }
494
495 if let Some(ref username) = self.username {
496 doc.push_str(&format!(" User: {} (whoami)\n", username));
497 }
498 if let Some(ref hostname) = self.hostname {
499 doc.push_str(&format!(" Host: {} (hostname)\n", hostname));
500 }
501
502 if let Some(ref limits) = self.limits {
503 doc.push_str(&format!(
504 " Limits: {} commands, {} iterations, {} depth\n",
505 limits.max_commands, limits.max_loop_iterations, limits.max_function_depth
506 ));
507 }
508
509 if !self.env_vars.is_empty() {
510 doc.push_str(" Environment: ");
511 let keys: Vec<&str> = self.env_vars.iter().map(|(k, _)| k.as_str()).collect();
512 doc.push_str(&keys.join(", "));
513 doc.push('\n');
514 }
515 }
516
517 if !self.builtin_hints.is_empty() {
519 doc.push_str("\nNOTES\n");
520 for hint in &self.builtin_hints {
521 doc.push_str(&format!(" {hint}\n"));
522 }
523 }
524
525 if let Some(warning) = self.language_warning() {
527 doc.push_str(&format!("\nWARNINGS\n {warning}\n"));
528 }
529
530 doc
531 }
532
533 fn language_warning(&self) -> Option<String> {
536 let mut missing = Vec::new();
537
538 let has_perl = self.builtin_names.iter().any(|n| n == "perl");
539 if !has_perl {
540 missing.push("perl");
541 }
542
543 let has_python = self
544 .builtin_names
545 .iter()
546 .any(|n| n == "python" || n == "python3");
547 if !has_python {
548 missing.push("python/python3");
549 }
550
551 if missing.is_empty() {
552 None
553 } else {
554 Some(format!("{} not available.", missing.join(", ")))
555 }
556 }
557
558 fn build_system_prompt(&self) -> String {
560 let mut prompt = String::from("# Bash Tool\n\n");
561
562 prompt.push_str("Virtual bash interpreter with virtual filesystem.\n");
564
565 if let Some(ref username) = self.username {
567 prompt.push_str(&format!("Home: /home/{}\n", username));
568 }
569
570 prompt.push('\n');
571
572 prompt.push_str("Input: {\"commands\": \"<bash commands>\"}\n");
574 prompt.push_str("Output: {stdout, stderr, exit_code}\n");
575
576 if !self.builtin_hints.is_empty() {
578 prompt.push('\n');
579 for hint in &self.builtin_hints {
580 prompt.push_str(&format!("Note: {hint}\n"));
581 }
582 }
583
584 if let Some(warning) = self.language_warning() {
586 prompt.push_str(&format!("\nWarning: {warning}\n"));
587 }
588
589 prompt
590 }
591}
592
593#[async_trait]
594impl Tool for BashTool {
595 fn name(&self) -> &str {
596 "bashkit"
597 }
598
599 fn short_description(&self) -> &str {
600 "Virtual bash interpreter with virtual filesystem"
601 }
602
603 fn description(&self) -> String {
604 self.build_description()
605 }
606
607 fn help(&self) -> String {
608 self.build_help()
609 }
610
611 fn system_prompt(&self) -> String {
612 self.build_system_prompt()
613 }
614
615 fn input_schema(&self) -> serde_json::Value {
616 let schema = schema_for!(ToolRequest);
617 serde_json::to_value(schema).unwrap_or_default()
618 }
619
620 fn output_schema(&self) -> serde_json::Value {
621 let schema = schema_for!(ToolResponse);
622 serde_json::to_value(schema).unwrap_or_default()
623 }
624
625 fn version(&self) -> &str {
626 VERSION
627 }
628
629 async fn execute(&mut self, req: ToolRequest) -> ToolResponse {
630 if req.commands.is_empty() {
631 return ToolResponse {
632 stdout: String::new(),
633 stderr: String::new(),
634 exit_code: 0,
635 error: None,
636 };
637 }
638
639 let mut bash = self.create_bash();
640
641 let fut = async {
642 match bash.exec(&req.commands).await {
643 Ok(result) => result.into(),
644 Err(e) => ToolResponse {
645 stdout: String::new(),
646 stderr: e.to_string(),
647 exit_code: 1,
648 error: Some(error_kind(&e)),
649 },
650 }
651 };
652
653 if let Some(ms) = req.timeout_ms {
654 let dur = Duration::from_millis(ms);
655 match tokio::time::timeout(dur, fut).await {
656 Ok(resp) => resp,
657 Err(_elapsed) => timeout_response(dur),
658 }
659 } else {
660 fut.await
661 }
662 }
663
664 async fn execute_with_status(
665 &mut self,
666 req: ToolRequest,
667 mut status_callback: Box<dyn FnMut(ToolStatus) + Send>,
668 ) -> ToolResponse {
669 status_callback(ToolStatus::new("validate").with_percent(0.0));
670
671 if req.commands.is_empty() {
672 status_callback(ToolStatus::new("complete").with_percent(100.0));
673 return ToolResponse {
674 stdout: String::new(),
675 stderr: String::new(),
676 exit_code: 0,
677 error: None,
678 };
679 }
680
681 status_callback(ToolStatus::new("parse").with_percent(10.0));
682
683 let mut bash = self.create_bash();
684
685 status_callback(ToolStatus::new("execute").with_percent(20.0));
686
687 let status_cb = Arc::new(Mutex::new(status_callback));
689 let status_cb_output = status_cb.clone();
690 let output_cb: OutputCallback = Box::new(move |stdout_chunk, stderr_chunk| {
691 if let Ok(mut cb) = status_cb_output.lock() {
692 if !stdout_chunk.is_empty() {
693 cb(ToolStatus::stdout(stdout_chunk));
694 }
695 if !stderr_chunk.is_empty() {
696 cb(ToolStatus::stderr(stderr_chunk));
697 }
698 }
699 });
700
701 let timeout_ms = req.timeout_ms;
702
703 let fut = async {
704 let response = match bash.exec_streaming(&req.commands, output_cb).await {
705 Ok(result) => result.into(),
706 Err(e) => ToolResponse {
707 stdout: String::new(),
708 stderr: e.to_string(),
709 exit_code: 1,
710 error: Some(error_kind(&e)),
711 },
712 };
713
714 if let Ok(mut cb) = status_cb.lock() {
715 cb(ToolStatus::new("complete").with_percent(100.0));
716 }
717
718 response
719 };
720
721 if let Some(ms) = timeout_ms {
722 let dur = Duration::from_millis(ms);
723 match tokio::time::timeout(dur, fut).await {
724 Ok(resp) => resp,
725 Err(_elapsed) => timeout_response(dur),
726 }
727 } else {
728 fut.await
729 }
730 }
731}
732
733fn error_kind(e: &Error) -> String {
735 match e {
736 Error::Parse(_) | Error::ParseAt { .. } => "parse_error".to_string(),
737 Error::Execution(_) => "execution_error".to_string(),
738 Error::Io(_) => "io_error".to_string(),
739 Error::CommandNotFound(_) => "command_not_found".to_string(),
740 Error::ResourceLimit(_) => "resource_limit".to_string(),
741 Error::Network(_) => "network_error".to_string(),
742 Error::Regex(_) => "regex_error".to_string(),
743 Error::Internal(_) => "internal_error".to_string(),
744 }
745}
746
747fn timeout_response(dur: Duration) -> ToolResponse {
749 ToolResponse {
750 stdout: String::new(),
751 stderr: format!(
752 "bashkit: execution timed out after {:.1}s\n",
753 dur.as_secs_f64()
754 ),
755 exit_code: 124,
756 error: Some("timeout".to_string()),
757 }
758}
759
760#[cfg(test)]
761#[allow(clippy::unwrap_used)]
762mod tests {
763 use super::*;
764
765 #[test]
766 fn test_bash_tool_builder() {
767 let tool = BashTool::builder()
768 .username("testuser")
769 .hostname("testhost")
770 .env("FOO", "bar")
771 .limits(ExecutionLimits::new().max_commands(100))
772 .build();
773
774 assert_eq!(tool.username, Some("testuser".to_string()));
775 assert_eq!(tool.hostname, Some("testhost".to_string()));
776 assert_eq!(tool.env_vars, vec![("FOO".to_string(), "bar".to_string())]);
777 }
778
779 #[test]
780 fn test_tool_trait_methods() {
781 let tool = BashTool::default();
782
783 assert_eq!(tool.name(), "bashkit");
785 assert_eq!(
786 tool.short_description(),
787 "Virtual bash interpreter with virtual filesystem"
788 );
789 assert!(tool.description().contains("Virtual bash interpreter"));
790 assert!(tool.description().contains("Supported tools:"));
791 assert!(tool.help().contains("BASH(1)"));
792 assert!(tool.help().contains("SYNOPSIS"));
793 assert!(tool.system_prompt().contains("# Bash Tool"));
794 assert_eq!(tool.version(), VERSION);
795 }
796
797 #[test]
798 fn test_tool_description_with_config() {
799 let tool = BashTool::builder()
800 .username("agent")
801 .hostname("sandbox")
802 .env("API_KEY", "secret")
803 .limits(ExecutionLimits::new().max_commands(50))
804 .build();
805
806 let helptext = tool.help();
808 assert!(helptext.contains("CONFIGURATION"));
809 assert!(helptext.contains("User: agent"));
810 assert!(helptext.contains("Host: sandbox"));
811 assert!(helptext.contains("50 commands"));
812 assert!(helptext.contains("API_KEY"));
813
814 let sysprompt = tool.system_prompt();
816 assert!(sysprompt.contains("# Bash Tool"));
817 assert!(sysprompt.contains("Home: /home/agent"));
818 }
819
820 #[test]
821 fn test_tool_schemas() {
822 let tool = BashTool::default();
823 let input_schema = tool.input_schema();
824 let output_schema = tool.output_schema();
825
826 assert!(input_schema["properties"]["commands"].is_object());
828
829 assert!(output_schema["properties"]["stdout"].is_object());
831 assert!(output_schema["properties"]["stderr"].is_object());
832 assert!(output_schema["properties"]["exit_code"].is_object());
833 }
834
835 #[test]
836 fn test_tool_status() {
837 let status = ToolStatus::new("execute")
838 .with_message("Running commands")
839 .with_percent(50.0)
840 .with_eta(5000);
841
842 assert_eq!(status.phase, "execute");
843 assert_eq!(status.message, Some("Running commands".to_string()));
844 assert_eq!(status.percent_complete, Some(50.0));
845 assert_eq!(status.eta_ms, Some(5000));
846 }
847
848 #[tokio::test]
849 async fn test_tool_execute_empty() {
850 let mut tool = BashTool::default();
851 let req = ToolRequest {
852 commands: String::new(),
853 timeout_ms: None,
854 };
855 let resp = tool.execute(req).await;
856 assert_eq!(resp.exit_code, 0);
857 assert!(resp.error.is_none());
858 }
859
860 #[tokio::test]
861 async fn test_tool_execute_echo() {
862 let mut tool = BashTool::default();
863 let req = ToolRequest {
864 commands: "echo hello".to_string(),
865 timeout_ms: None,
866 };
867 let resp = tool.execute(req).await;
868 assert_eq!(resp.stdout, "hello\n");
869 assert_eq!(resp.exit_code, 0);
870 assert!(resp.error.is_none());
871 }
872
873 #[test]
874 fn test_builtin_hints_in_help_and_system_prompt() {
875 use crate::builtins::Builtin;
876 use crate::error::Result;
877 use crate::interpreter::ExecResult;
878
879 struct HintedBuiltin;
880
881 #[async_trait]
882 impl Builtin for HintedBuiltin {
883 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
884 Ok(ExecResult::ok(String::new()))
885 }
886 fn llm_hint(&self) -> Option<&'static str> {
887 Some("mycommand: Processes CSV. Max 10MB. No streaming.")
888 }
889 }
890
891 let tool = BashTool::builder()
892 .builtin("mycommand", Box::new(HintedBuiltin))
893 .build();
894
895 let helptext = tool.help();
897 assert!(helptext.contains("NOTES"), "help should have NOTES section");
898 assert!(
899 helptext.contains("mycommand: Processes CSV"),
900 "help should contain the hint"
901 );
902
903 let sysprompt = tool.system_prompt();
905 assert!(
906 sysprompt.contains("mycommand: Processes CSV"),
907 "system_prompt should contain the hint"
908 );
909 }
910
911 #[test]
912 fn test_no_hints_without_hinted_builtins() {
913 let tool = BashTool::default();
914
915 let helptext = tool.help();
916 assert!(
917 !helptext.contains("NOTES"),
918 "help should not have NOTES without hinted builtins"
919 );
920
921 let sysprompt = tool.system_prompt();
922 assert!(
923 !sysprompt.contains("Note:"),
924 "system_prompt should not have notes without hinted builtins"
925 );
926 }
927
928 #[test]
929 fn test_language_warning_default() {
930 let tool = BashTool::default();
931
932 let sysprompt = tool.system_prompt();
933 assert!(
934 sysprompt.contains("Warning: perl, python/python3 not available."),
935 "system_prompt should have single combined warning"
936 );
937
938 let helptext = tool.help();
939 assert!(
940 helptext.contains("WARNINGS"),
941 "help should have WARNINGS section"
942 );
943 assert!(
944 helptext.contains("perl, python/python3 not available."),
945 "help should have single combined warning"
946 );
947 }
948
949 #[test]
950 fn test_language_warning_suppressed_by_custom_builtins() {
951 use crate::builtins::Builtin;
952 use crate::error::Result;
953 use crate::interpreter::ExecResult;
954
955 struct NoopBuiltin;
956
957 #[async_trait]
958 impl Builtin for NoopBuiltin {
959 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
960 Ok(ExecResult::ok(String::new()))
961 }
962 }
963
964 let tool = BashTool::builder()
965 .builtin("python", Box::new(NoopBuiltin))
966 .builtin("perl", Box::new(NoopBuiltin))
967 .build();
968
969 let sysprompt = tool.system_prompt();
970 assert!(
971 !sysprompt.contains("Warning:"),
972 "no warning when all languages registered"
973 );
974
975 let helptext = tool.help();
976 assert!(
977 !helptext.contains("WARNINGS"),
978 "no WARNINGS section when all languages registered"
979 );
980 }
981
982 #[test]
983 fn test_language_warning_partial() {
984 use crate::builtins::Builtin;
985 use crate::error::Result;
986 use crate::interpreter::ExecResult;
987
988 struct NoopBuiltin;
989
990 #[async_trait]
991 impl Builtin for NoopBuiltin {
992 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
993 Ok(ExecResult::ok(String::new()))
994 }
995 }
996
997 let tool = BashTool::builder()
999 .builtin("python3", Box::new(NoopBuiltin))
1000 .build();
1001
1002 let sysprompt = tool.system_prompt();
1003 assert!(
1004 sysprompt.contains("Warning: perl not available."),
1005 "should warn about perl only"
1006 );
1007 assert!(
1008 !sysprompt.contains("python/python3"),
1009 "python warning suppressed when python3 registered"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_duplicate_hints_deduplicated() {
1015 use crate::builtins::Builtin;
1016 use crate::error::Result;
1017 use crate::interpreter::ExecResult;
1018
1019 struct SameHint;
1020
1021 #[async_trait]
1022 impl Builtin for SameHint {
1023 async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
1024 Ok(ExecResult::ok(String::new()))
1025 }
1026 fn llm_hint(&self) -> Option<&'static str> {
1027 Some("same hint")
1028 }
1029 }
1030
1031 let tool = BashTool::builder()
1032 .builtin("cmd1", Box::new(SameHint))
1033 .builtin("cmd2", Box::new(SameHint))
1034 .build();
1035
1036 let helptext = tool.help();
1037 assert_eq!(
1039 helptext.matches("same hint").count(),
1040 1,
1041 "Duplicate hints should be deduplicated"
1042 );
1043 }
1044
1045 #[cfg(feature = "python")]
1046 #[test]
1047 fn test_python_hint_via_builder() {
1048 let tool = BashTool::builder().python().build();
1049
1050 let helptext = tool.help();
1051 assert!(helptext.contains("python"), "help should mention python");
1052 assert!(
1053 helptext.contains("no open()"),
1054 "help should document open() limitation"
1055 );
1056 assert!(
1057 helptext.contains("No HTTP"),
1058 "help should document HTTP limitation"
1059 );
1060
1061 let sysprompt = tool.system_prompt();
1062 assert!(
1063 sysprompt.contains("python"),
1064 "system_prompt should mention python"
1065 );
1066
1067 assert!(
1069 !sysprompt.contains("python/python3 not available"),
1070 "python warning should not appear when Monty python enabled"
1071 );
1072 }
1073
1074 #[tokio::test]
1075 async fn test_tool_execute_with_status() {
1076 use std::sync::{Arc, Mutex};
1077
1078 let mut tool = BashTool::default();
1079 let req = ToolRequest {
1080 commands: "echo test".to_string(),
1081 timeout_ms: None,
1082 };
1083
1084 let phases = Arc::new(Mutex::new(Vec::new()));
1085 let phases_clone = phases.clone();
1086
1087 let resp = tool
1088 .execute_with_status(
1089 req,
1090 Box::new(move |status| {
1091 phases_clone
1092 .lock()
1093 .expect("lock poisoned")
1094 .push(status.phase.clone());
1095 }),
1096 )
1097 .await;
1098
1099 assert_eq!(resp.stdout, "test\n");
1100 let phases = phases.lock().expect("lock poisoned");
1101 assert!(phases.contains(&"validate".to_string()));
1102 assert!(phases.contains(&"complete".to_string()));
1103 }
1104
1105 #[tokio::test]
1106 async fn test_execute_with_status_streams_output() {
1107 let mut tool = BashTool::default();
1108 let req = ToolRequest {
1109 commands: "for i in a b c; do echo $i; done".to_string(),
1110 timeout_ms: None,
1111 };
1112
1113 let events = Arc::new(Mutex::new(Vec::new()));
1114 let events_clone = events.clone();
1115
1116 let resp = tool
1117 .execute_with_status(
1118 req,
1119 Box::new(move |status| {
1120 events_clone.lock().expect("lock poisoned").push(status);
1121 }),
1122 )
1123 .await;
1124
1125 assert_eq!(resp.stdout, "a\nb\nc\n");
1126 assert_eq!(resp.exit_code, 0);
1127
1128 let events = events.lock().expect("lock poisoned");
1129 let output_events: Vec<_> = events.iter().filter(|s| s.phase == "output").collect();
1131 assert_eq!(
1132 output_events.len(),
1133 3,
1134 "expected 3 output events, got {output_events:?}"
1135 );
1136 assert_eq!(output_events[0].output.as_deref(), Some("a\n"));
1137 assert_eq!(output_events[0].stream.as_deref(), Some("stdout"));
1138 assert_eq!(output_events[1].output.as_deref(), Some("b\n"));
1139 assert_eq!(output_events[2].output.as_deref(), Some("c\n"));
1140 }
1141
1142 #[tokio::test]
1143 async fn test_execute_with_status_streams_list_commands() {
1144 let mut tool = BashTool::default();
1145 let req = ToolRequest {
1146 commands: "echo start; echo end".to_string(),
1147 timeout_ms: None,
1148 };
1149
1150 let events = Arc::new(Mutex::new(Vec::new()));
1151 let events_clone = events.clone();
1152
1153 let resp = tool
1154 .execute_with_status(
1155 req,
1156 Box::new(move |status| {
1157 events_clone.lock().expect("lock poisoned").push(status);
1158 }),
1159 )
1160 .await;
1161
1162 assert_eq!(resp.stdout, "start\nend\n");
1163
1164 let events = events.lock().expect("lock poisoned");
1165 let output_events: Vec<_> = events.iter().filter(|s| s.phase == "output").collect();
1166 assert_eq!(
1167 output_events.len(),
1168 2,
1169 "expected 2 output events, got {output_events:?}"
1170 );
1171 assert_eq!(output_events[0].output.as_deref(), Some("start\n"));
1172 assert_eq!(output_events[1].output.as_deref(), Some("end\n"));
1173 }
1174
1175 #[tokio::test]
1176 async fn test_execute_with_status_no_duplicate_output() {
1177 let mut tool = BashTool::default();
1178 let req = ToolRequest {
1180 commands: "echo start; for i in 1 2 3; do echo $i; done; echo end".to_string(),
1181 timeout_ms: None,
1182 };
1183
1184 let events = Arc::new(Mutex::new(Vec::new()));
1185 let events_clone = events.clone();
1186
1187 let resp = tool
1188 .execute_with_status(
1189 req,
1190 Box::new(move |status| {
1191 events_clone.lock().expect("lock poisoned").push(status);
1192 }),
1193 )
1194 .await;
1195
1196 assert_eq!(resp.stdout, "start\n1\n2\n3\nend\n");
1197
1198 let events = events.lock().expect("lock poisoned");
1199 let output_events: Vec<_> = events
1200 .iter()
1201 .filter(|s| s.phase == "output")
1202 .map(|s| s.output.as_deref().unwrap_or(""))
1203 .collect();
1204 assert_eq!(
1205 output_events,
1206 vec!["start\n", "1\n", "2\n", "3\n", "end\n"],
1207 "should have exactly 5 distinct output events"
1208 );
1209 }
1210
1211 #[test]
1212 fn test_tool_status_stdout_constructor() {
1213 let status = ToolStatus::stdout("hello\n");
1214 assert_eq!(status.phase, "output");
1215 assert_eq!(status.output.as_deref(), Some("hello\n"));
1216 assert_eq!(status.stream.as_deref(), Some("stdout"));
1217 assert!(status.message.is_none());
1218 }
1219
1220 #[test]
1221 fn test_tool_status_stderr_constructor() {
1222 let status = ToolStatus::stderr("error\n");
1223 assert_eq!(status.phase, "output");
1224 assert_eq!(status.output.as_deref(), Some("error\n"));
1225 assert_eq!(status.stream.as_deref(), Some("stderr"));
1226 }
1227
1228 #[tokio::test]
1229 async fn test_tool_execute_timeout() {
1230 let mut tool = BashTool::default();
1231 let req = ToolRequest {
1232 commands: "sleep 10".to_string(),
1233 timeout_ms: Some(100),
1234 };
1235 let resp = tool.execute(req).await;
1236 assert_eq!(resp.exit_code, 124);
1237 assert!(resp.stderr.contains("timed out"));
1238 assert_eq!(resp.error, Some("timeout".to_string()));
1239 }
1240
1241 #[tokio::test]
1242 async fn test_tool_execute_no_timeout() {
1243 let mut tool = BashTool::default();
1244 let req = ToolRequest {
1245 commands: "echo fast".to_string(),
1246 timeout_ms: Some(5000),
1247 };
1248 let resp = tool.execute(req).await;
1249 assert_eq!(resp.exit_code, 0);
1250 assert_eq!(resp.stdout, "fast\n");
1251 }
1252
1253 #[test]
1254 fn test_tool_request_new() {
1255 let req = ToolRequest::new("echo test");
1256 assert_eq!(req.commands, "echo test");
1257 assert_eq!(req.timeout_ms, None);
1258 }
1259
1260 #[test]
1261 fn test_tool_request_deserialize_without_timeout() {
1262 let json = r#"{"commands":"echo hello"}"#;
1263 let req: ToolRequest = serde_json::from_str(json).unwrap();
1264 assert_eq!(req.commands, "echo hello");
1265 assert_eq!(req.timeout_ms, None);
1266 }
1267
1268 #[test]
1269 fn test_tool_request_deserialize_with_timeout() {
1270 let json = r#"{"commands":"echo hello","timeout_ms":5000}"#;
1271 let req: ToolRequest = serde_json::from_str(json).unwrap();
1272 assert_eq!(req.commands, "echo hello");
1273 assert_eq!(req.timeout_ms, Some(5000));
1274 }
1275
1276 #[tokio::test]
1278 async fn test_create_bash_preserves_builtins() {
1279 use crate::builtins::{Builtin, Context};
1280 use crate::ExecResult;
1281 use async_trait::async_trait;
1282
1283 struct TestBuiltin;
1284
1285 #[async_trait]
1286 impl Builtin for TestBuiltin {
1287 async fn execute(&self, _ctx: Context<'_>) -> crate::Result<ExecResult> {
1288 Ok(ExecResult::ok("test_output\n"))
1289 }
1290 }
1291
1292 let mut tool = BashToolBuilder::new()
1293 .builtin("testcmd", Box::new(TestBuiltin))
1294 .build();
1295
1296 let mut bash1 = tool.create_bash();
1298 let result1 = bash1.exec("testcmd").await.unwrap();
1299 assert!(
1300 result1.stdout.contains("test_output"),
1301 "first call should have custom builtin"
1302 );
1303
1304 let mut bash2 = tool.create_bash();
1306 let result2 = bash2.exec("testcmd").await.unwrap();
1307 assert!(
1308 result2.stdout.contains("test_output"),
1309 "second call should still have custom builtin"
1310 );
1311 }
1312}