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