Skip to main content

bashkit/
tool.rs

1//! Tool trait and BashTool implementation
2//!
3//! # Public Library Contract
4//!
5//! The `Tool` trait is a **public contract** - breaking changes require a major version bump.
6//! See `specs/009-tool-contract.md` for the full specification.
7//!
8//! # Architecture
9//!
10//! - [`Tool`] trait: Contract that all tools must implement
11//! - [`BashTool`]: Sandboxed bash interpreter implementing Tool
12//! - [`BashToolBuilder`]: Builder pattern for configuring BashTool
13//!
14//! # Example
15//!
16//! ```
17//! use bashkit::{BashTool, Tool, ToolRequest};
18//!
19//! # tokio_test::block_on(async {
20//! let mut tool = BashTool::default();
21//!
22//! // Introspection
23//! assert_eq!(tool.name(), "bashkit");
24//! assert!(!tool.help().is_empty());
25//!
26//! // Execution
27//! let resp = tool.execute(ToolRequest {
28//!     commands: "echo hello".to_string(),
29//! }).await;
30//! assert_eq!(resp.stdout, "hello\n");
31//! # });
32//! ```
33
34use crate::builtins::Builtin;
35use crate::error::Error;
36use crate::{Bash, ExecResult, ExecutionLimits};
37use async_trait::async_trait;
38use schemars::{schema_for, JsonSchema};
39use serde::{Deserialize, Serialize};
40
41/// Library version from Cargo.toml
42pub const VERSION: &str = env!("CARGO_PKG_VERSION");
43
44/// List of built-in commands
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";
46
47/// Base help documentation template (generic help format)
48const BASE_HELP: &str = r#"BASH(1)                          User Commands                         BASH(1)
49
50NAME
51       bashkit - sandboxed bash-like interpreter with virtual filesystem
52
53SYNOPSIS
54       {"commands": "<bash commands>"}
55
56DESCRIPTION
57       Bashkit executes bash commands in an isolated sandbox with a virtual
58       filesystem. All file operations are contained within the sandbox.
59
60       Supports full bash syntax including variables, pipelines, redirects,
61       loops, conditionals, functions, and arrays.
62
63BUILTINS
64       echo, cat, grep, sed, awk, jq, curl, head, tail, sort, uniq, cut, tr,
65       wc, date, sleep, mkdir, rm, cp, mv, touch, chmod, printf, test, [,
66       true, false, exit, cd, pwd, ls, find, xargs, basename, dirname, env,
67       export, read
68
69INPUT
70       commands    Bash commands to execute (like bash -c "commands")
71
72OUTPUT
73       stdout      Standard output from the commands
74       stderr      Standard error from the commands
75       exit_code   Exit status (0 = success)
76
77EXAMPLES
78       Simple echo:
79           {"commands": "echo 'Hello, World!'"}
80
81       Arithmetic:
82           {"commands": "x=5; y=3; echo $((x + y))"}
83
84       Pipeline:
85           {"commands": "echo -e 'apple\nbanana' | grep a"}
86
87       JSON processing:
88           {"commands": "echo '{\"n\":1}' | jq '.n'"}
89
90       File operations (virtual):
91           {"commands": "echo data > /tmp/f.txt && cat /tmp/f.txt"}
92
93       Run script from VFS:
94           {"commands": "source /path/to/script.sh"}
95
96EXIT STATUS
97       0      Success
98       1-125  Command-specific error
99       126    Command not executable
100       127    Command not found
101
102SEE ALSO
103       bash(1), sh(1)
104"#;
105
106// Note: system_prompt() is built dynamically in build_system_prompt()
107
108/// Request to execute bash commands
109#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
110pub struct ToolRequest {
111    /// Bash commands to execute (like `bash -c "commands"`)
112    pub commands: String,
113}
114
115/// Response from executing a bash script
116#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
117pub struct ToolResponse {
118    /// Standard output from the script
119    pub stdout: String,
120    /// Standard error from the script
121    pub stderr: String,
122    /// Exit code (0 = success)
123    pub exit_code: i32,
124    /// Error message if execution failed before running
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub error: Option<String>,
127}
128
129impl From<ExecResult> for ToolResponse {
130    fn from(result: ExecResult) -> Self {
131        Self {
132            stdout: result.stdout,
133            stderr: result.stderr,
134            exit_code: result.exit_code,
135            error: None,
136        }
137    }
138}
139
140/// Status update during tool execution
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct ToolStatus {
143    /// Current phase (e.g., "validate", "parse", "execute", "complete")
144    pub phase: String,
145    /// Optional message
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub message: Option<String>,
148    /// Estimated completion percentage (0-100)
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub percent_complete: Option<f32>,
151    /// Estimated time remaining in milliseconds
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub eta_ms: Option<u64>,
154}
155
156impl ToolStatus {
157    /// Create a new status with phase
158    pub fn new(phase: impl Into<String>) -> Self {
159        Self {
160            phase: phase.into(),
161            message: None,
162            percent_complete: None,
163            eta_ms: None,
164        }
165    }
166
167    /// Set message
168    pub fn with_message(mut self, message: impl Into<String>) -> Self {
169        self.message = Some(message.into());
170        self
171    }
172
173    /// Set completion percentage
174    pub fn with_percent(mut self, percent: f32) -> Self {
175        self.percent_complete = Some(percent);
176        self
177    }
178
179    /// Set ETA
180    pub fn with_eta(mut self, eta_ms: u64) -> Self {
181        self.eta_ms = Some(eta_ms);
182        self
183    }
184}
185
186// ============================================================================
187// Tool Trait - Public Library Contract
188// ============================================================================
189
190/// Tool contract for LLM integration.
191///
192/// # Public Contract
193///
194/// This trait is a **public library contract**. Breaking changes require a major version bump.
195/// See `specs/009-tool-contract.md` for the full specification.
196///
197/// All tools must implement this trait to be usable by LLMs and agents.
198/// The trait provides introspection (schemas, docs) and execution methods.
199///
200/// # Implementors
201///
202/// - [`BashTool`]: Sandboxed bash interpreter
203#[async_trait]
204pub trait Tool: Send + Sync {
205    /// Tool identifier (e.g., "bashkit", "calculator")
206    fn name(&self) -> &str;
207
208    /// One-line description for tool listings
209    fn short_description(&self) -> &str;
210
211    /// Full description, may include dynamic config info
212    fn description(&self) -> String;
213
214    /// Full documentation for LLMs (human readable, with examples)
215    fn help(&self) -> String;
216
217    /// Condensed description for system prompts (token-efficient)
218    fn system_prompt(&self) -> String;
219
220    /// JSON Schema for input validation
221    fn input_schema(&self) -> serde_json::Value;
222
223    /// JSON Schema for output structure
224    fn output_schema(&self) -> serde_json::Value;
225
226    /// Library/tool version
227    fn version(&self) -> &str;
228
229    /// Execute the tool
230    async fn execute(&mut self, req: ToolRequest) -> ToolResponse;
231
232    /// Execute with status callbacks for progress tracking
233    async fn execute_with_status(
234        &mut self,
235        req: ToolRequest,
236        status_callback: Box<dyn FnMut(ToolStatus) + Send>,
237    ) -> ToolResponse;
238}
239
240// ============================================================================
241// BashTool - Implementation
242// ============================================================================
243
244/// Builder for configuring BashTool
245#[derive(Default)]
246pub struct BashToolBuilder {
247    /// Custom username for sandbox identity
248    username: Option<String>,
249    /// Custom hostname for sandbox identity
250    hostname: Option<String>,
251    /// Execution limits
252    limits: Option<ExecutionLimits>,
253    /// Environment variables to set
254    env_vars: Vec<(String, String)>,
255    /// Custom builtins (name, implementation)
256    builtins: Vec<(String, Box<dyn Builtin>)>,
257}
258
259impl BashToolBuilder {
260    /// Create a new tool builder with defaults
261    pub fn new() -> Self {
262        Self::default()
263    }
264
265    /// Set custom username for sandbox identity
266    pub fn username(mut self, username: impl Into<String>) -> Self {
267        self.username = Some(username.into());
268        self
269    }
270
271    /// Set custom hostname for sandbox identity
272    pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
273        self.hostname = Some(hostname.into());
274        self
275    }
276
277    /// Set execution limits
278    pub fn limits(mut self, limits: ExecutionLimits) -> Self {
279        self.limits = Some(limits);
280        self
281    }
282
283    /// Add an environment variable
284    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
285        self.env_vars.push((key.into(), value.into()));
286        self
287    }
288
289    /// Register a custom builtin command
290    ///
291    /// Custom builtins extend the shell with domain-specific commands.
292    /// They will be documented in the tool's `help()` output.
293    /// If the builtin implements [`Builtin::llm_hint`], its hint will be
294    /// included in `help()` and `system_prompt()`.
295    pub fn builtin(mut self, name: impl Into<String>, builtin: Box<dyn Builtin>) -> Self {
296        self.builtins.push((name.into(), builtin));
297        self
298    }
299
300    /// Enable embedded Python (`python`/`python3` builtins) via Monty interpreter
301    /// with default resource limits.
302    ///
303    /// Requires the `python` feature flag. Python `pathlib.Path` operations are
304    /// bridged to the virtual filesystem. Limitations (no `open()`, no HTTP) are
305    /// automatically documented in `help()` and `system_prompt()`.
306    #[cfg(feature = "python")]
307    pub fn python(self) -> Self {
308        self.python_with_limits(crate::builtins::PythonLimits::default())
309    }
310
311    /// Enable embedded Python with custom resource limits.
312    #[cfg(feature = "python")]
313    pub fn python_with_limits(self, limits: crate::builtins::PythonLimits) -> Self {
314        use crate::builtins::Python;
315        self.builtin("python", Box::new(Python::with_limits(limits.clone())))
316            .builtin("python3", Box::new(Python::with_limits(limits)))
317    }
318
319    /// Build the BashTool
320    pub fn build(self) -> BashTool {
321        let builtin_names: Vec<String> = self.builtins.iter().map(|(n, _)| n.clone()).collect();
322
323        // Collect LLM hints from builtins, deduplicated
324        let mut builtin_hints: Vec<String> = self
325            .builtins
326            .iter()
327            .filter_map(|(_, b)| b.llm_hint().map(String::from))
328            .collect();
329        builtin_hints.sort();
330        builtin_hints.dedup();
331
332        BashTool {
333            username: self.username,
334            hostname: self.hostname,
335            limits: self.limits,
336            env_vars: self.env_vars,
337            builtins: self.builtins,
338            builtin_names,
339            builtin_hints,
340        }
341    }
342}
343
344/// Sandboxed bash interpreter implementing the Tool trait
345#[derive(Default)]
346pub struct BashTool {
347    username: Option<String>,
348    hostname: Option<String>,
349    limits: Option<ExecutionLimits>,
350    env_vars: Vec<(String, String)>,
351    builtins: Vec<(String, Box<dyn Builtin>)>,
352    /// Names of custom builtins (for documentation)
353    builtin_names: Vec<String>,
354    /// LLM hints from registered builtins
355    builtin_hints: Vec<String>,
356}
357
358impl BashTool {
359    /// Create a new tool builder
360    pub fn builder() -> BashToolBuilder {
361        BashToolBuilder::new()
362    }
363
364    /// Create a Bash instance with configured settings
365    fn create_bash(&mut self) -> Bash {
366        let mut builder = Bash::builder();
367
368        if let Some(ref username) = self.username {
369            builder = builder.username(username);
370        }
371        if let Some(ref hostname) = self.hostname {
372            builder = builder.hostname(hostname);
373        }
374        if let Some(ref limits) = self.limits {
375            builder = builder.limits(limits.clone());
376        }
377        for (key, value) in &self.env_vars {
378            builder = builder.env(key, value);
379        }
380        // Move builtins out to avoid borrow issues
381        for (name, builtin) in std::mem::take(&mut self.builtins) {
382            builder = builder.builtin(name, builtin);
383        }
384
385        builder.build()
386    }
387
388    /// Build dynamic description with supported tools
389    fn build_description(&self) -> String {
390        let mut desc = String::from(
391            "Sandboxed bash-like interpreter with virtual filesystem. Supported tools: ",
392        );
393        desc.push_str(BUILTINS);
394        if !self.builtin_names.is_empty() {
395            desc.push(' ');
396            desc.push_str(&self.builtin_names.join(" "));
397        }
398        desc
399    }
400
401    /// Build dynamic help with configuration
402    fn build_help(&self) -> String {
403        let mut doc = BASE_HELP.to_string();
404
405        // Append configuration section if any dynamic config exists
406        let has_config = !self.builtin_names.is_empty()
407            || self.username.is_some()
408            || self.hostname.is_some()
409            || self.limits.is_some()
410            || !self.env_vars.is_empty();
411
412        if has_config {
413            doc.push_str("\nCONFIGURATION\n");
414
415            if !self.builtin_names.is_empty() {
416                doc.push_str("       Custom commands: ");
417                doc.push_str(&self.builtin_names.join(", "));
418                doc.push('\n');
419            }
420
421            if let Some(ref username) = self.username {
422                doc.push_str(&format!("       User: {} (whoami)\n", username));
423            }
424            if let Some(ref hostname) = self.hostname {
425                doc.push_str(&format!("       Host: {} (hostname)\n", hostname));
426            }
427
428            if let Some(ref limits) = self.limits {
429                doc.push_str(&format!(
430                    "       Limits: {} commands, {} iterations, {} depth\n",
431                    limits.max_commands, limits.max_loop_iterations, limits.max_function_depth
432                ));
433            }
434
435            if !self.env_vars.is_empty() {
436                doc.push_str("       Environment: ");
437                let keys: Vec<&str> = self.env_vars.iter().map(|(k, _)| k.as_str()).collect();
438                doc.push_str(&keys.join(", "));
439                doc.push('\n');
440            }
441        }
442
443        // Append builtin hints (capabilities/limitations for LLMs)
444        if !self.builtin_hints.is_empty() {
445            doc.push_str("\nNOTES\n");
446            for hint in &self.builtin_hints {
447                doc.push_str(&format!("       {hint}\n"));
448            }
449        }
450
451        // Append language interpreter warning
452        if let Some(warning) = self.language_warning() {
453            doc.push_str(&format!("\nWARNINGS\n       {warning}\n"));
454        }
455
456        doc
457    }
458
459    /// Single-line warning listing language interpreters not registered as builtins.
460    /// Returns `None` when all tracked languages are available.
461    fn language_warning(&self) -> Option<String> {
462        let mut missing = Vec::new();
463
464        let has_perl = self.builtin_names.iter().any(|n| n == "perl");
465        if !has_perl {
466            missing.push("perl");
467        }
468
469        let has_python = self
470            .builtin_names
471            .iter()
472            .any(|n| n == "python" || n == "python3");
473        if !has_python {
474            missing.push("python/python3");
475        }
476
477        if missing.is_empty() {
478            None
479        } else {
480            Some(format!("{} not available.", missing.join(", ")))
481        }
482    }
483
484    /// Build dynamic system prompt
485    fn build_system_prompt(&self) -> String {
486        let mut prompt = String::from("# Bash Tool\n\n");
487
488        // Description with workspace info
489        prompt.push_str("Sandboxed bash-like interpreter with virtual filesystem.\n");
490
491        // Home directory info if username is set
492        if let Some(ref username) = self.username {
493            prompt.push_str(&format!("Home: /home/{}\n", username));
494        }
495
496        prompt.push('\n');
497
498        // Input/Output format
499        prompt.push_str("Input: {\"commands\": \"<bash commands>\"}\n");
500        prompt.push_str("Output: {stdout, stderr, exit_code}\n");
501
502        // Builtin hints (capabilities/limitations)
503        if !self.builtin_hints.is_empty() {
504            prompt.push('\n');
505            for hint in &self.builtin_hints {
506                prompt.push_str(&format!("Note: {hint}\n"));
507            }
508        }
509
510        // Language interpreter warning
511        if let Some(warning) = self.language_warning() {
512            prompt.push_str(&format!("\nWarning: {warning}\n"));
513        }
514
515        prompt
516    }
517}
518
519#[async_trait]
520impl Tool for BashTool {
521    fn name(&self) -> &str {
522        "bashkit"
523    }
524
525    fn short_description(&self) -> &str {
526        "Sandboxed bash interpreter with virtual filesystem"
527    }
528
529    fn description(&self) -> String {
530        self.build_description()
531    }
532
533    fn help(&self) -> String {
534        self.build_help()
535    }
536
537    fn system_prompt(&self) -> String {
538        self.build_system_prompt()
539    }
540
541    fn input_schema(&self) -> serde_json::Value {
542        let schema = schema_for!(ToolRequest);
543        serde_json::to_value(schema).unwrap_or_default()
544    }
545
546    fn output_schema(&self) -> serde_json::Value {
547        let schema = schema_for!(ToolResponse);
548        serde_json::to_value(schema).unwrap_or_default()
549    }
550
551    fn version(&self) -> &str {
552        VERSION
553    }
554
555    async fn execute(&mut self, req: ToolRequest) -> ToolResponse {
556        if req.commands.is_empty() {
557            return ToolResponse {
558                stdout: String::new(),
559                stderr: String::new(),
560                exit_code: 0,
561                error: None,
562            };
563        }
564
565        let mut bash = self.create_bash();
566
567        match bash.exec(&req.commands).await {
568            Ok(result) => result.into(),
569            Err(e) => ToolResponse {
570                stdout: String::new(),
571                stderr: e.to_string(),
572                exit_code: 1,
573                error: Some(error_kind(&e)),
574            },
575        }
576    }
577
578    async fn execute_with_status(
579        &mut self,
580        req: ToolRequest,
581        mut status_callback: Box<dyn FnMut(ToolStatus) + Send>,
582    ) -> ToolResponse {
583        status_callback(ToolStatus::new("validate").with_percent(0.0));
584
585        if req.commands.is_empty() {
586            status_callback(ToolStatus::new("complete").with_percent(100.0));
587            return ToolResponse {
588                stdout: String::new(),
589                stderr: String::new(),
590                exit_code: 0,
591                error: None,
592            };
593        }
594
595        status_callback(ToolStatus::new("parse").with_percent(10.0));
596
597        let mut bash = self.create_bash();
598
599        status_callback(ToolStatus::new("execute").with_percent(20.0));
600
601        let response = match bash.exec(&req.commands).await {
602            Ok(result) => result.into(),
603            Err(e) => ToolResponse {
604                stdout: String::new(),
605                stderr: e.to_string(),
606                exit_code: 1,
607                error: Some(error_kind(&e)),
608            },
609        };
610
611        status_callback(ToolStatus::new("complete").with_percent(100.0));
612
613        response
614    }
615}
616
617/// Extract error kind from Error for categorization
618fn error_kind(e: &Error) -> String {
619    match e {
620        Error::Parse(_) | Error::ParseAt { .. } => "parse_error".to_string(),
621        Error::Execution(_) => "execution_error".to_string(),
622        Error::Io(_) => "io_error".to_string(),
623        Error::CommandNotFound(_) => "command_not_found".to_string(),
624        Error::ResourceLimit(_) => "resource_limit".to_string(),
625        Error::Network(_) => "network_error".to_string(),
626        Error::Internal(_) => "internal_error".to_string(),
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    #[test]
635    fn test_bash_tool_builder() {
636        let tool = BashTool::builder()
637            .username("testuser")
638            .hostname("testhost")
639            .env("FOO", "bar")
640            .limits(ExecutionLimits::new().max_commands(100))
641            .build();
642
643        assert_eq!(tool.username, Some("testuser".to_string()));
644        assert_eq!(tool.hostname, Some("testhost".to_string()));
645        assert_eq!(tool.env_vars, vec![("FOO".to_string(), "bar".to_string())]);
646    }
647
648    #[test]
649    fn test_tool_trait_methods() {
650        let tool = BashTool::default();
651
652        // Test trait methods
653        assert_eq!(tool.name(), "bashkit");
654        assert_eq!(
655            tool.short_description(),
656            "Sandboxed bash interpreter with virtual filesystem"
657        );
658        assert!(tool
659            .description()
660            .contains("Sandboxed bash-like interpreter"));
661        assert!(tool.description().contains("Supported tools:"));
662        assert!(tool.help().contains("BASH(1)"));
663        assert!(tool.help().contains("SYNOPSIS"));
664        assert!(tool.system_prompt().contains("# Bash Tool"));
665        assert_eq!(tool.version(), VERSION);
666    }
667
668    #[test]
669    fn test_tool_description_with_config() {
670        let tool = BashTool::builder()
671            .username("agent")
672            .hostname("sandbox")
673            .env("API_KEY", "secret")
674            .limits(ExecutionLimits::new().max_commands(50))
675            .build();
676
677        // helptext should include configuration in man-page style
678        let helptext = tool.help();
679        assert!(helptext.contains("CONFIGURATION"));
680        assert!(helptext.contains("User: agent"));
681        assert!(helptext.contains("Host: sandbox"));
682        assert!(helptext.contains("50 commands"));
683        assert!(helptext.contains("API_KEY"));
684
685        // system_prompt should include home
686        let sysprompt = tool.system_prompt();
687        assert!(sysprompt.contains("# Bash Tool"));
688        assert!(sysprompt.contains("Home: /home/agent"));
689    }
690
691    #[test]
692    fn test_tool_schemas() {
693        let tool = BashTool::default();
694        let input_schema = tool.input_schema();
695        let output_schema = tool.output_schema();
696
697        // Input schema should have commands property
698        assert!(input_schema["properties"]["commands"].is_object());
699
700        // Output schema should have stdout, stderr, exit_code
701        assert!(output_schema["properties"]["stdout"].is_object());
702        assert!(output_schema["properties"]["stderr"].is_object());
703        assert!(output_schema["properties"]["exit_code"].is_object());
704    }
705
706    #[test]
707    fn test_tool_status() {
708        let status = ToolStatus::new("execute")
709            .with_message("Running commands")
710            .with_percent(50.0)
711            .with_eta(5000);
712
713        assert_eq!(status.phase, "execute");
714        assert_eq!(status.message, Some("Running commands".to_string()));
715        assert_eq!(status.percent_complete, Some(50.0));
716        assert_eq!(status.eta_ms, Some(5000));
717    }
718
719    #[tokio::test]
720    async fn test_tool_execute_empty() {
721        let mut tool = BashTool::default();
722        let req = ToolRequest {
723            commands: String::new(),
724        };
725        let resp = tool.execute(req).await;
726        assert_eq!(resp.exit_code, 0);
727        assert!(resp.error.is_none());
728    }
729
730    #[tokio::test]
731    async fn test_tool_execute_echo() {
732        let mut tool = BashTool::default();
733        let req = ToolRequest {
734            commands: "echo hello".to_string(),
735        };
736        let resp = tool.execute(req).await;
737        assert_eq!(resp.stdout, "hello\n");
738        assert_eq!(resp.exit_code, 0);
739        assert!(resp.error.is_none());
740    }
741
742    #[test]
743    fn test_builtin_hints_in_help_and_system_prompt() {
744        use crate::builtins::Builtin;
745        use crate::error::Result;
746        use crate::interpreter::ExecResult;
747
748        struct HintedBuiltin;
749
750        #[async_trait]
751        impl Builtin for HintedBuiltin {
752            async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
753                Ok(ExecResult::ok(String::new()))
754            }
755            fn llm_hint(&self) -> Option<&'static str> {
756                Some("mycommand: Processes CSV. Max 10MB. No streaming.")
757            }
758        }
759
760        let tool = BashTool::builder()
761            .builtin("mycommand", Box::new(HintedBuiltin))
762            .build();
763
764        // Hint should appear in help
765        let helptext = tool.help();
766        assert!(helptext.contains("NOTES"), "help should have NOTES section");
767        assert!(
768            helptext.contains("mycommand: Processes CSV"),
769            "help should contain the hint"
770        );
771
772        // Hint should appear in system_prompt
773        let sysprompt = tool.system_prompt();
774        assert!(
775            sysprompt.contains("mycommand: Processes CSV"),
776            "system_prompt should contain the hint"
777        );
778    }
779
780    #[test]
781    fn test_no_hints_without_hinted_builtins() {
782        let tool = BashTool::default();
783
784        let helptext = tool.help();
785        assert!(
786            !helptext.contains("NOTES"),
787            "help should not have NOTES without hinted builtins"
788        );
789
790        let sysprompt = tool.system_prompt();
791        assert!(
792            !sysprompt.contains("Note:"),
793            "system_prompt should not have notes without hinted builtins"
794        );
795    }
796
797    #[test]
798    fn test_language_warning_default() {
799        let tool = BashTool::default();
800
801        let sysprompt = tool.system_prompt();
802        assert!(
803            sysprompt.contains("Warning: perl, python/python3 not available."),
804            "system_prompt should have single combined warning"
805        );
806
807        let helptext = tool.help();
808        assert!(
809            helptext.contains("WARNINGS"),
810            "help should have WARNINGS section"
811        );
812        assert!(
813            helptext.contains("perl, python/python3 not available."),
814            "help should have single combined warning"
815        );
816    }
817
818    #[test]
819    fn test_language_warning_suppressed_by_custom_builtins() {
820        use crate::builtins::Builtin;
821        use crate::error::Result;
822        use crate::interpreter::ExecResult;
823
824        struct NoopBuiltin;
825
826        #[async_trait]
827        impl Builtin for NoopBuiltin {
828            async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
829                Ok(ExecResult::ok(String::new()))
830            }
831        }
832
833        let tool = BashTool::builder()
834            .builtin("python", Box::new(NoopBuiltin))
835            .builtin("perl", Box::new(NoopBuiltin))
836            .build();
837
838        let sysprompt = tool.system_prompt();
839        assert!(
840            !sysprompt.contains("Warning:"),
841            "no warning when all languages registered"
842        );
843
844        let helptext = tool.help();
845        assert!(
846            !helptext.contains("WARNINGS"),
847            "no WARNINGS section when all languages registered"
848        );
849    }
850
851    #[test]
852    fn test_language_warning_partial() {
853        use crate::builtins::Builtin;
854        use crate::error::Result;
855        use crate::interpreter::ExecResult;
856
857        struct NoopBuiltin;
858
859        #[async_trait]
860        impl Builtin for NoopBuiltin {
861            async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
862                Ok(ExecResult::ok(String::new()))
863            }
864        }
865
866        // python3 registered -> only perl warned
867        let tool = BashTool::builder()
868            .builtin("python3", Box::new(NoopBuiltin))
869            .build();
870
871        let sysprompt = tool.system_prompt();
872        assert!(
873            sysprompt.contains("Warning: perl not available."),
874            "should warn about perl only"
875        );
876        assert!(
877            !sysprompt.contains("python/python3"),
878            "python warning suppressed when python3 registered"
879        );
880    }
881
882    #[test]
883    fn test_duplicate_hints_deduplicated() {
884        use crate::builtins::Builtin;
885        use crate::error::Result;
886        use crate::interpreter::ExecResult;
887
888        struct SameHint;
889
890        #[async_trait]
891        impl Builtin for SameHint {
892            async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
893                Ok(ExecResult::ok(String::new()))
894            }
895            fn llm_hint(&self) -> Option<&'static str> {
896                Some("same hint")
897            }
898        }
899
900        let tool = BashTool::builder()
901            .builtin("cmd1", Box::new(SameHint))
902            .builtin("cmd2", Box::new(SameHint))
903            .build();
904
905        let helptext = tool.help();
906        // Should appear exactly once
907        assert_eq!(
908            helptext.matches("same hint").count(),
909            1,
910            "Duplicate hints should be deduplicated"
911        );
912    }
913
914    #[cfg(feature = "python")]
915    #[test]
916    fn test_python_hint_via_builder() {
917        let tool = BashTool::builder().python().build();
918
919        let helptext = tool.help();
920        assert!(helptext.contains("python"), "help should mention python");
921        assert!(
922            helptext.contains("no open()"),
923            "help should document open() limitation"
924        );
925        assert!(
926            helptext.contains("No HTTP"),
927            "help should document HTTP limitation"
928        );
929
930        let sysprompt = tool.system_prompt();
931        assert!(
932            sysprompt.contains("python"),
933            "system_prompt should mention python"
934        );
935
936        // Python warning should be suppressed when python is enabled via Monty
937        assert!(
938            !sysprompt.contains("python/python3 not available"),
939            "python warning should not appear when Monty python enabled"
940        );
941    }
942
943    #[tokio::test]
944    async fn test_tool_execute_with_status() {
945        use std::sync::{Arc, Mutex};
946
947        let mut tool = BashTool::default();
948        let req = ToolRequest {
949            commands: "echo test".to_string(),
950        };
951
952        let phases = Arc::new(Mutex::new(Vec::new()));
953        let phases_clone = phases.clone();
954
955        let resp = tool
956            .execute_with_status(
957                req,
958                Box::new(move |status| {
959                    phases_clone
960                        .lock()
961                        .expect("lock poisoned")
962                        .push(status.phase.clone());
963                }),
964            )
965            .await;
966
967        assert_eq!(resp.stdout, "test\n");
968        let phases = phases.lock().expect("lock poisoned");
969        assert!(phases.contains(&"validate".to_string()));
970        assert!(phases.contains(&"complete".to_string()));
971    }
972}