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`]: Virtual 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//!     timeout_ms: None,
30//! }).await;
31//! assert_eq!(resp.stdout, "hello\n");
32//! # });
33//! ```
34
35use 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
44/// Library version from Cargo.toml
45pub const VERSION: &str = env!("CARGO_PKG_VERSION");
46
47/// List of built-in commands (organized by category)
48const 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
63/// Base help documentation template (generic help format)
64const 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// Note: system_prompt() is built dynamically in build_system_prompt()
135
136/// Request to execute bash commands
137#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
138pub struct ToolRequest {
139    /// Bash commands to execute (like `bash -c "commands"`)
140    pub commands: String,
141    /// Optional per-call timeout in milliseconds.
142    /// When set, execution is aborted after this duration and a response
143    /// with `exit_code = 124` is returned (matching the bash `timeout` convention).
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub timeout_ms: Option<u64>,
146}
147
148impl ToolRequest {
149    /// Create a request with just commands (no timeout).
150    pub fn new(commands: impl Into<String>) -> Self {
151        Self {
152            commands: commands.into(),
153            timeout_ms: None,
154        }
155    }
156}
157
158/// Response from executing a bash script
159#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
160pub struct ToolResponse {
161    /// Standard output from the script
162    pub stdout: String,
163    /// Standard error from the script
164    pub stderr: String,
165    /// Exit code (0 = success)
166    pub exit_code: i32,
167    /// Error message if execution failed before running
168    #[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/// Status update during tool execution
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct ToolStatus {
186    /// Current phase (e.g., "validate", "parse", "execute", "output", "complete")
187    pub phase: String,
188    /// Optional message
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub message: Option<String>,
191    /// Estimated completion percentage (0-100)
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub percent_complete: Option<f32>,
194    /// Estimated time remaining in milliseconds
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub eta_ms: Option<u64>,
197    /// Incremental stdout/stderr chunk (only present when `phase == "output"`)
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub output: Option<String>,
200    /// Which stream the output belongs to: `"stdout"` or `"stderr"`
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub stream: Option<String>,
203}
204
205impl ToolStatus {
206    /// Create a new status with phase
207    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    /// Create an output status carrying a stdout chunk.
219    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    /// Create an output status carrying a stderr chunk.
231    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    /// Set message
243    pub fn with_message(mut self, message: impl Into<String>) -> Self {
244        self.message = Some(message.into());
245        self
246    }
247
248    /// Set completion percentage
249    pub fn with_percent(mut self, percent: f32) -> Self {
250        self.percent_complete = Some(percent);
251        self
252    }
253
254    /// Set ETA
255    pub fn with_eta(mut self, eta_ms: u64) -> Self {
256        self.eta_ms = Some(eta_ms);
257        self
258    }
259}
260
261// ============================================================================
262// Tool Trait - Public Library Contract
263// ============================================================================
264
265/// Tool contract for LLM integration.
266///
267/// # Public Contract
268///
269/// This trait is a **public library contract**. Breaking changes require a major version bump.
270/// See `specs/009-tool-contract.md` for the full specification.
271///
272/// All tools must implement this trait to be usable by LLMs and agents.
273/// The trait provides introspection (schemas, docs) and execution methods.
274///
275/// # Implementors
276///
277/// - [`BashTool`]: Virtual bash interpreter
278#[async_trait]
279pub trait Tool: Send + Sync {
280    /// Tool identifier (e.g., "bashkit", "calculator")
281    fn name(&self) -> &str;
282
283    /// One-line description for tool listings
284    fn short_description(&self) -> &str;
285
286    /// Full description, may include dynamic config info
287    fn description(&self) -> String;
288
289    /// Full documentation for LLMs (human readable, with examples)
290    fn help(&self) -> String;
291
292    /// Condensed description for system prompts (token-efficient)
293    fn system_prompt(&self) -> String;
294
295    /// JSON Schema for input validation
296    fn input_schema(&self) -> serde_json::Value;
297
298    /// JSON Schema for output structure
299    fn output_schema(&self) -> serde_json::Value;
300
301    /// Library/tool version
302    fn version(&self) -> &str;
303
304    /// Execute the tool
305    async fn execute(&mut self, req: ToolRequest) -> ToolResponse;
306
307    /// Execute with status callbacks for progress tracking
308    async fn execute_with_status(
309        &mut self,
310        req: ToolRequest,
311        status_callback: Box<dyn FnMut(ToolStatus) + Send>,
312    ) -> ToolResponse;
313}
314
315// ============================================================================
316// BashTool - Implementation
317// ============================================================================
318
319/// Builder for configuring BashTool
320#[derive(Default)]
321pub struct BashToolBuilder {
322    /// Custom username for virtual identity
323    username: Option<String>,
324    /// Custom hostname for virtual identity
325    hostname: Option<String>,
326    /// Execution limits
327    limits: Option<ExecutionLimits>,
328    /// Environment variables to set
329    env_vars: Vec<(String, String)>,
330    /// Custom builtins (name, implementation). Arc enables reuse across create_bash calls.
331    builtins: Vec<(String, Arc<dyn Builtin>)>,
332}
333
334impl BashToolBuilder {
335    /// Create a new tool builder with defaults
336    pub fn new() -> Self {
337        Self::default()
338    }
339
340    /// Set custom username for virtual identity
341    pub fn username(mut self, username: impl Into<String>) -> Self {
342        self.username = Some(username.into());
343        self
344    }
345
346    /// Set custom hostname for virtual identity
347    pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
348        self.hostname = Some(hostname.into());
349        self
350    }
351
352    /// Set execution limits
353    pub fn limits(mut self, limits: ExecutionLimits) -> Self {
354        self.limits = Some(limits);
355        self
356    }
357
358    /// Add an environment variable
359    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    /// Register a custom builtin command
365    ///
366    /// Custom builtins extend the shell with domain-specific commands.
367    /// They will be documented in the tool's `help()` output.
368    /// If the builtin implements [`Builtin::llm_hint`], its hint will be
369    /// included in `help()` and `system_prompt()`.
370    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    /// Enable embedded Python (`python`/`python3` builtins) via Monty interpreter
376    /// with default resource limits.
377    ///
378    /// Requires the `python` feature flag. Python `pathlib.Path` operations are
379    /// bridged to the virtual filesystem. Limitations (no `open()`, no HTTP) are
380    /// automatically documented in `help()` and `system_prompt()`.
381    #[cfg(feature = "python")]
382    pub fn python(self) -> Self {
383        self.python_with_limits(crate::builtins::PythonLimits::default())
384    }
385
386    /// Enable embedded Python with custom resource limits.
387    #[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    /// Build the BashTool
395    pub fn build(self) -> BashTool {
396        let builtin_names: Vec<String> = self.builtins.iter().map(|(n, _)| n.clone()).collect();
397
398        // Collect LLM hints from builtins, deduplicated
399        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/// Virtual bash interpreter implementing the Tool trait
420#[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    /// Names of custom builtins (for documentation)
428    builtin_names: Vec<String>,
429    /// LLM hints from registered builtins
430    builtin_hints: Vec<String>,
431}
432
433impl BashTool {
434    /// Create a new tool builder
435    pub fn builder() -> BashToolBuilder {
436        BashToolBuilder::new()
437    }
438
439    /// Create a Bash instance with configured settings
440    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        // Clone Arc builtins so they survive across multiple create_bash calls
456        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    /// Build dynamic description with supported tools
464    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    /// Build dynamic help with configuration
476    fn build_help(&self) -> String {
477        let mut doc = BASE_HELP.to_string();
478
479        // Append configuration section if any dynamic config exists
480        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        // Append builtin hints (capabilities/limitations for LLMs)
518        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        // Append language interpreter warning
526        if let Some(warning) = self.language_warning() {
527            doc.push_str(&format!("\nWARNINGS\n       {warning}\n"));
528        }
529
530        doc
531    }
532
533    /// Single-line warning listing language interpreters not registered as builtins.
534    /// Returns `None` when all tracked languages are available.
535    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    /// Build dynamic system prompt
559    fn build_system_prompt(&self) -> String {
560        let mut prompt = String::from("# Bash Tool\n\n");
561
562        // Description with workspace info
563        prompt.push_str("Virtual bash interpreter with virtual filesystem.\n");
564
565        // Home directory info if username is set
566        if let Some(ref username) = self.username {
567            prompt.push_str(&format!("Home: /home/{}\n", username));
568        }
569
570        prompt.push('\n');
571
572        // Input/Output format
573        prompt.push_str("Input: {\"commands\": \"<bash commands>\"}\n");
574        prompt.push_str("Output: {stdout, stderr, exit_code}\n");
575
576        // Builtin hints (capabilities/limitations)
577        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        // Language interpreter warning
585        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        // Wire streaming: forward output chunks as ToolStatus events
688        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
733/// Extract error kind from Error for categorization
734fn 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
747/// Build a ToolResponse for a timed-out execution (exit code 124, like bash `timeout`).
748fn 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        // Test trait methods
784        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        // helptext should include configuration in man-page style
807        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        // system_prompt should include home
815        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        // Input schema should have commands property
827        assert!(input_schema["properties"]["commands"].is_object());
828
829        // Output schema should have stdout, stderr, exit_code
830        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        // Hint should appear in help
896        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        // Hint should appear in system_prompt
904        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        // python3 registered -> only perl warned
998        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        // Should appear exactly once
1038        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        // Python warning should be suppressed when python is enabled via Monty
1068        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        // Should have output events for each iteration
1130        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        // mix of list + loop: should get 5 distinct events, no duplicates
1179        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    // Issue #422: create_bash should not empty builtins after first call
1277    #[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        // First call
1297        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        // Second call should still have the builtin
1305        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}