Skip to main content

bashkit/
tool.rs

1//! Tool contract and `BashTool` implementation.
2//!
3//! # Public Library Contract
4//!
5//! `bashkit` follows the Everruns toolkit-library contract:
6//!
7//! ```text
8//! ToolBuilder (config) -> Tool (metadata) -> ToolExecution (single-use runtime)
9//! ```
10//!
11//! [`BashToolBuilder`] configures a reusable tool definition. [`BashTool`] exposes
12//! locale-aware metadata plus [`Tool::execution`] for validated, single-use runs.
13//! [`ToolExecution`] returns structured [`ToolOutput`] and can optionally stream
14//! [`ToolOutputChunk`] values during execution.
15//!
16//! # Architecture
17//!
18//! - [`Tool`] trait: shared metadata + execution contract
19//! - [`BashToolBuilder`]: reusable builder for config, schemas, OpenAI tool JSON,
20//!   and `tower::Service` integration
21//! - [`BashTool`]: immutable metadata object implementing [`Tool`]
22//! - [`ToolExecution`]: validated, single-use runtime for one call
23//!
24//! # Builder Example
25//!
26//! ```
27//! use bashkit::{BashTool, Tool};
28//!
29//! let builder = BashTool::builder()
30//!     .locale("en-US")
31//!     .username("agent")
32//!     .hostname("sandbox");
33//!
34//! let tool = builder.build();
35//! assert_eq!(tool.name(), "bashkit");
36//! assert_eq!(tool.display_name(), "Bash");
37//! assert!(builder.build_tool_definition()["function"]["parameters"].is_object());
38//! ```
39//!
40//! # Execution Example
41//!
42//! ```
43//! use bashkit::{BashTool, Tool};
44//! use futures_util::StreamExt;
45//!
46//! # tokio_test::block_on(async {
47//! let tool = BashTool::default();
48//! let execution = tool
49//!     .execution(serde_json::json!({"commands": "printf 'a\nb\n'"}))
50//!     .expect("valid args");
51//! let mut stream = execution.output_stream().expect("stream available");
52//!
53//! let handle = tokio::spawn(async move { execution.execute().await.expect("execution succeeds") });
54//! let first = stream.next().await.expect("first chunk");
55//! assert_eq!(first.kind, "stdout");
56//! assert!(first.data.as_str().is_some_and(|chunk| chunk.starts_with("a\n")));
57//!
58//! let output = handle.await.expect("join");
59//! assert_eq!(output.result["stdout"], "a\nb\n");
60//! # });
61//! ```
62
63use crate::builtins::{Builtin, Extension};
64use crate::error::Error;
65use crate::{Bash, ExecResult, ExecutionLimits, OutputCallback};
66use async_trait::async_trait;
67use futures_core::Stream;
68use serde::{Deserialize, Serialize};
69use std::collections::HashSet;
70use std::future::Future;
71use std::path::PathBuf;
72use std::pin::Pin;
73use std::sync::{Arc, Mutex};
74use std::task::{Context, Poll};
75use std::time::Duration;
76type ToolExecutionFuture = Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send>>;
77type ToolExecutionRunner = Box<
78    dyn FnOnce(Option<tokio::sync::mpsc::UnboundedSender<ToolOutputChunk>>) -> ToolExecutionFuture
79        + Send,
80>;
81
82/// Library version from Cargo.toml
83pub const VERSION: &str = env!("CARGO_PKG_VERSION");
84
85/// Standard `tower::Service` type for toolkit integrations.
86pub type ToolService =
87    tower::util::BoxCloneService<serde_json::Value, serde_json::Value, ToolError>;
88
89/// Tool execution error.
90///
91/// The split between [`ToolError::UserFacing`] and [`ToolError::Internal`] lets
92/// consumers decide what is safe to send back to the LLM. User-facing errors
93/// should be short, actionable, and locale-aware. Internal errors are for logs.
94#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
95pub enum ToolError {
96    /// Safe to show to the LLM/user.
97    #[error("{0}")]
98    UserFacing(String),
99    /// Internal failure for logs/diagnostics only.
100    #[error("{0}")]
101    Internal(String),
102}
103
104impl ToolError {
105    /// Whether the message is safe to show to the LLM.
106    pub fn is_user_facing(&self) -> bool {
107        matches!(self, Self::UserFacing(_))
108    }
109}
110
111/// Image payload returned by a tool.
112///
113/// `bashkit` does not currently emit images, but the contract keeps parity with
114/// other toolkit crates that may return screenshots or rendered assets.
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
116pub struct ToolImage {
117    pub base64: String,
118    pub media_type: String,
119}
120
121/// Consumer-facing metadata that never goes to the LLM.
122///
123/// Use [`ToolOutputMetadata::extra`] for kit-specific diagnostics such as exit
124/// codes, command counts, or bytes transferred.
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
126pub struct ToolOutputMetadata {
127    #[serde(with = "duration_millis")]
128    pub duration: Duration,
129    pub extra: serde_json::Value,
130}
131
132/// Structured execution result.
133///
134/// [`ToolOutput::result`] is the JSON payload intended for the LLM tool result.
135/// [`ToolOutput::metadata`] is reserved for the host application.
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
137pub struct ToolOutput {
138    pub result: serde_json::Value,
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub images: Vec<ToolImage>,
141    pub metadata: ToolOutputMetadata,
142}
143
144/// Incremental tool output chunk.
145///
146/// `kind` is consumer-routable (`stdout`, `stderr`, `progress`, ...). `data`
147/// stays JSON so non-text chunks can be added later without changing the type.
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
149pub struct ToolOutputChunk {
150    pub data: serde_json::Value,
151    pub kind: String,
152}
153
154/// Stream returned by [`ToolExecution::output_stream`].
155///
156/// This stream is informational. The final authoritative result still comes
157/// from [`ToolExecution::execute`].
158pub struct ToolOutputStream {
159    receiver: tokio::sync::mpsc::UnboundedReceiver<ToolOutputChunk>,
160}
161
162impl Stream for ToolOutputStream {
163    type Item = ToolOutputChunk;
164
165    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
166        self.receiver.poll_recv(cx)
167    }
168}
169
170#[derive(Default)]
171struct ToolExecutionStreamState {
172    sender: Option<tokio::sync::mpsc::UnboundedSender<ToolOutputChunk>>,
173    receiver: Option<tokio::sync::mpsc::UnboundedReceiver<ToolOutputChunk>>,
174}
175
176/// Stateful, single-use tool execution.
177///
178/// Build one with [`Tool::execution`]. Call [`ToolExecution::output_stream`]
179/// before [`ToolExecution::execute`] if you need live updates.
180pub struct ToolExecution {
181    runner: Option<ToolExecutionRunner>,
182    stream_state: Arc<Mutex<ToolExecutionStreamState>>,
183}
184
185impl ToolExecution {
186    pub(crate) fn new<F, Fut>(runner: F) -> Self
187    where
188        F: FnOnce(Option<tokio::sync::mpsc::UnboundedSender<ToolOutputChunk>>) -> Fut
189            + Send
190            + 'static,
191        Fut: Future<Output = Result<ToolOutput, ToolError>> + Send + 'static,
192    {
193        Self {
194            runner: Some(Box::new(move |sender| Box::pin(runner(sender)))),
195            stream_state: Arc::new(Mutex::new(ToolExecutionStreamState::default())),
196        }
197    }
198
199    /// Stream incremental output. Must be called before [`Self::execute`].
200    pub fn output_stream(&self) -> Option<ToolOutputStream> {
201        let mut state = match self.stream_state.lock() {
202            Ok(state) => state,
203            Err(poisoned) => poisoned.into_inner(),
204        };
205        if state.receiver.is_none() {
206            let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
207            state.sender = Some(sender);
208            state.receiver = Some(receiver);
209        }
210        state
211            .receiver
212            .take()
213            .map(|receiver| ToolOutputStream { receiver })
214    }
215
216    /// Run the execution to completion.
217    pub async fn execute(mut self) -> Result<ToolOutput, ToolError> {
218        let sender = match self.stream_state.lock() {
219            Ok(state) => state.sender.clone(),
220            Err(poisoned) => poisoned.into_inner().sender.clone(),
221        };
222        let Some(runner) = self.runner.take() else {
223            return Err(ToolError::Internal(
224                "tool execution may only be run once".to_string(),
225            ));
226        };
227        runner(sender).await
228    }
229}
230
231mod duration_millis {
232    use serde::{Deserialize, Deserializer, Serializer};
233    use std::time::Duration;
234
235    pub fn serialize<S>(value: &Duration, serializer: S) -> Result<S::Ok, S::Error>
236    where
237        S: Serializer,
238    {
239        serializer.serialize_u64(value.as_millis() as u64)
240    }
241
242    pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
243    where
244        D: Deserializer<'de>,
245    {
246        let millis = u64::deserialize(deserializer)?;
247        Ok(Duration::from_millis(millis))
248    }
249}
250
251/// List of built-in commands (organized by category)
252#[cfg(feature = "jq")]
253const BUILTINS: &str = "\
254echo printf cat read \
255grep sed awk jq head tail sort uniq cut tr wc nl paste column comm diff strings tac rev \
256cd pwd ls find mkdir mktemp rm rmdir cp mv touch chmod chown ln \
257file stat less tar gzip gunzip du df \
258test [ true false exit return break continue \
259export set unset local shift source eval declare typeset readonly shopt getopts \
260sleep date seq expr yes wait timeout xargs tee watch \
261basename dirname realpath \
262pushd popd dirs \
263whoami hostname uname id env printenv history \
264curl wget \
265od xxd hexdump base64 \
266kill";
267
268#[cfg(not(feature = "jq"))]
269const BUILTINS: &str = "\
270echo printf cat read \
271grep sed awk head tail sort uniq cut tr wc nl paste column comm diff strings tac rev \
272cd pwd ls find mkdir mktemp rm rmdir cp mv touch chmod chown ln \
273file stat less tar gzip gunzip du df \
274test [ true false exit return break continue \
275export set unset local shift source eval declare typeset readonly shopt getopts \
276sleep date seq expr yes wait timeout xargs tee watch \
277basename dirname realpath \
278pushd popd dirs \
279whoami hostname uname id env printenv history \
280curl wget \
281od xxd hexdump base64 \
282kill";
283
284/// Request to execute bash commands
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct ToolRequest {
287    /// Bash commands to execute (like `bash -c "commands"`)
288    pub commands: String,
289    /// Optional per-call timeout in milliseconds.
290    /// When set, execution is aborted after this duration and a response
291    /// with `exit_code = 124` is returned (matching the bash `timeout` convention).
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub timeout_ms: Option<u64>,
294}
295
296impl ToolRequest {
297    /// Create a request with just commands (no timeout).
298    pub fn new(commands: impl Into<String>) -> Self {
299        Self {
300            commands: commands.into(),
301            timeout_ms: None,
302        }
303    }
304}
305
306/// Response from executing a bash script
307#[derive(Debug, Clone, Default, Serialize, Deserialize)]
308pub struct ToolResponse {
309    /// Standard output from the script
310    pub stdout: String,
311    /// Standard error from the script
312    pub stderr: String,
313    /// Exit code (0 = success)
314    pub exit_code: i32,
315    /// Error message if execution failed before running
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub error: Option<String>,
318    /// Whether stdout was truncated due to output size limits
319    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
320    pub stdout_truncated: bool,
321    /// Whether stderr was truncated due to output size limits
322    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
323    pub stderr_truncated: bool,
324    /// Final environment state after execution (opt-in via `capture_final_env`)
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub final_env: Option<std::collections::HashMap<String, String>>,
327}
328
329impl From<ExecResult> for ToolResponse {
330    fn from(result: ExecResult) -> Self {
331        Self {
332            stdout: result.stdout,
333            stderr: result.stderr,
334            exit_code: result.exit_code,
335            error: None,
336            stdout_truncated: result.stdout_truncated,
337            stderr_truncated: result.stderr_truncated,
338            final_env: result.final_env,
339        }
340    }
341}
342
343/// JSON schema for the stable tool request contract.
344pub(crate) fn tool_request_schema() -> serde_json::Value {
345    serde_json::json!({
346        "type": "object",
347        "required": ["commands"],
348        "properties": {
349            "commands": {
350                "type": "string",
351                "description": "Bash commands to execute"
352            },
353            "timeout_ms": {
354                "type": ["integer", "null"],
355                "format": "uint64",
356                "minimum": 0,
357                "description": "Optional per-call timeout in milliseconds"
358            }
359        }
360    })
361}
362
363/// JSON schema for the stable tool response contract.
364pub(crate) fn tool_response_schema() -> serde_json::Value {
365    serde_json::json!({
366        "type": "object",
367        "required": ["stdout", "stderr", "exit_code"],
368        "properties": {
369            "stdout": {
370                "type": "string",
371                "description": "Standard output from the script"
372            },
373            "stderr": {
374                "type": "string",
375                "description": "Standard error from the script"
376            },
377            "exit_code": {
378                "type": "integer",
379                "format": "int32",
380                "description": "Exit code; 0 means success"
381            },
382            "error": {
383                "type": ["string", "null"],
384                "description": "Error message if execution failed before running"
385            },
386            "stdout_truncated": {
387                "type": "boolean",
388                "default": false,
389                "description": "Whether stdout was truncated due to output size limits"
390            },
391            "stderr_truncated": {
392                "type": "boolean",
393                "default": false,
394                "description": "Whether stderr was truncated due to output size limits"
395            },
396            "final_env": {
397                "type": ["object", "null"],
398                "additionalProperties": { "type": "string" },
399                "description": "Final environment state when requested"
400            }
401        }
402    })
403}
404
405/// Status update during tool execution
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct ToolStatus {
408    /// Current phase (e.g., "validate", "parse", "execute", "output", "complete")
409    pub phase: String,
410    /// Optional message
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub message: Option<String>,
413    /// Estimated completion percentage (0-100)
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub percent_complete: Option<f32>,
416    /// Estimated time remaining in milliseconds
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub eta_ms: Option<u64>,
419    /// Incremental stdout/stderr chunk (only present when `phase == "output"`)
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub output: Option<String>,
422    /// Which stream the output belongs to: `"stdout"` or `"stderr"`
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub stream: Option<String>,
425}
426
427impl ToolStatus {
428    /// Create a new status with phase
429    pub fn new(phase: impl Into<String>) -> Self {
430        Self {
431            phase: phase.into(),
432            message: None,
433            percent_complete: None,
434            eta_ms: None,
435            output: None,
436            stream: None,
437        }
438    }
439
440    /// Create an output status carrying a stdout chunk.
441    pub fn stdout(chunk: impl Into<String>) -> Self {
442        Self {
443            phase: "output".to_string(),
444            message: None,
445            percent_complete: None,
446            eta_ms: None,
447            output: Some(chunk.into()),
448            stream: Some("stdout".to_string()),
449        }
450    }
451
452    /// Create an output status carrying a stderr chunk.
453    pub fn stderr(chunk: impl Into<String>) -> Self {
454        Self {
455            phase: "output".to_string(),
456            message: None,
457            percent_complete: None,
458            eta_ms: None,
459            output: Some(chunk.into()),
460            stream: Some("stderr".to_string()),
461        }
462    }
463
464    /// Set message
465    pub fn with_message(mut self, message: impl Into<String>) -> Self {
466        self.message = Some(message.into());
467        self
468    }
469
470    /// Set completion percentage
471    pub fn with_percent(mut self, percent: f32) -> Self {
472        self.percent_complete = Some(percent);
473        self
474    }
475
476    /// Set ETA
477    pub fn with_eta(mut self, eta_ms: u64) -> Self {
478        self.eta_ms = Some(eta_ms);
479        self
480    }
481}
482
483// ============================================================================
484// Tool Trait - Public Library Contract
485// ============================================================================
486
487/// Tool contract for LLM integration.
488///
489/// # Public Contract
490///
491/// This trait is a **public library contract**. Breaking changes require a major version bump.
492/// See `specs/tool-contract.md` for the full specification.
493///
494/// All tools must implement this trait to be usable by LLMs and agents.
495/// The trait provides introspection (schemas, docs) and execution methods.
496///
497/// # Implementors
498///
499/// - [`BashTool`]: Virtual bash interpreter
500#[async_trait]
501pub trait Tool: Send + Sync {
502    /// Tool identifier (e.g., "bashkit", "calculator")
503    fn name(&self) -> &str;
504
505    /// Human-readable display name for UI.
506    fn display_name(&self) -> &str;
507
508    /// One-line description for tool listings
509    fn short_description(&self) -> &str;
510
511    /// Token-efficient description for LLMs.
512    fn description(&self) -> &str;
513
514    /// Full documentation for LLMs (markdown, with examples)
515    fn help(&self) -> String;
516
517    /// Condensed description for system prompts (token-efficient)
518    fn system_prompt(&self) -> String;
519
520    /// Locale used for user-facing text.
521    fn locale(&self) -> &str;
522
523    /// JSON Schema for input validation
524    fn input_schema(&self) -> serde_json::Value;
525
526    /// JSON Schema for output structure
527    fn output_schema(&self) -> serde_json::Value;
528
529    /// Library/tool version
530    fn version(&self) -> &str;
531
532    /// Create a single-use execution.
533    fn execution(&self, args: serde_json::Value) -> Result<ToolExecution, ToolError>;
534
535    /// Execute the tool
536    async fn execute(&self, req: ToolRequest) -> ToolResponse;
537
538    /// Execute with status callbacks for progress tracking
539    async fn execute_with_status(
540        &self,
541        req: ToolRequest,
542        status_callback: Box<dyn FnMut(ToolStatus) + Send>,
543    ) -> ToolResponse;
544}
545
546// ============================================================================
547// BashTool - Implementation
548// ============================================================================
549
550/// One configuration step applied to a fresh [`BashBuilder`](crate::BashBuilder)
551/// each time the tool creates a shell. `Fn` (not `FnOnce`) and `Arc`-wrapped so
552/// it can run on every `create_bash` call and the tool stays `Clone`.
553type ToolConfigStep = Arc<dyn Fn(crate::BashBuilder) -> crate::BashBuilder + Send + Sync>;
554
555/// Builder for configuring BashTool
556#[derive(Default)]
557pub struct BashToolBuilder {
558    /// Locale for user-facing text.
559    locale: String,
560    /// Custom username for virtual identity
561    username: Option<String>,
562    /// Custom hostname for virtual identity
563    hostname: Option<String>,
564    /// Execution limits
565    limits: Option<ExecutionLimits>,
566    /// Initial working directory for the shell
567    cwd: Option<PathBuf>,
568    /// Environment variables to set
569    env_vars: Vec<(String, String)>,
570    /// Custom builtins (name, implementation). Arc enables reuse across create_bash calls.
571    builtins: Vec<(String, Arc<dyn Builtin>)>,
572    /// Extra `BashBuilder` configuration applied per shell via [`Self::configure`].
573    /// The convenience setters above cover the common knobs and feed the help /
574    /// system-prompt text; `configure` is the thin-adapter escape hatch for the
575    /// rest of the `BashBuilder` surface (network, mounts, hooks, git/ssh, …).
576    config: Vec<ToolConfigStep>,
577}
578
579impl BashToolBuilder {
580    /// Create a new tool builder with defaults
581    pub fn new() -> Self {
582        Self {
583            locale: "en-US".to_string(),
584            ..Self::default()
585        }
586    }
587
588    /// Set locale for descriptions, prompts, help, and user-facing errors.
589    pub fn locale(mut self, locale: &str) -> Self {
590        self.locale = locale.to_string();
591        self
592    }
593
594    /// Configure the underlying [`BashBuilder`](crate::BashBuilder) directly.
595    ///
596    /// This is the escape hatch that makes `BashToolBuilder` a thin adapter:
597    /// any `BashBuilder` capability not surfaced by a convenience method
598    /// (network allowlist, mounts, hooks, git/ssh config, …) is reachable here.
599    /// The closure runs on a fresh builder each time the tool creates a shell,
600    /// after the convenience setters above, so it can extend or override them.
601    ///
602    /// Note: `help()` / `system_prompt()` text is generated from the values set
603    /// via the convenience setters (`username`, `hostname`, `limits`, `cwd`,
604    /// `env`, custom builtins), **not** from inside this closure. Changes made
605    /// only here run for real but are not reflected in the documentation text —
606    /// use the convenience setters for any value that should be documented.
607    pub fn configure<F>(mut self, f: F) -> Self
608    where
609        F: Fn(crate::BashBuilder) -> crate::BashBuilder + Send + Sync + 'static,
610    {
611        self.config.push(Arc::new(f));
612        self
613    }
614
615    /// Set custom username for virtual identity
616    pub fn username(mut self, username: impl Into<String>) -> Self {
617        self.username = Some(username.into());
618        self
619    }
620
621    /// Set custom hostname for virtual identity
622    pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
623        self.hostname = Some(hostname.into());
624        self
625    }
626
627    /// Set execution limits
628    pub fn limits(mut self, limits: ExecutionLimits) -> Self {
629        self.limits = Some(limits);
630        self
631    }
632
633    /// Set the initial working directory for the shell
634    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
635        self.cwd = Some(cwd.into());
636        self
637    }
638
639    /// Add an environment variable
640    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
641        self.env_vars.push((key.into(), value.into()));
642        self
643    }
644
645    /// Register a custom builtin command
646    ///
647    /// Custom builtins extend the shell with domain-specific commands.
648    /// They will be documented in the tool's `help()` output.
649    /// If the builtin implements [`Builtin::llm_hint`], its hint will be
650    /// included in `help()` and `system_prompt()`.
651    pub fn builtin(mut self, name: impl Into<String>, builtin: Box<dyn Builtin>) -> Self {
652        self.builtins.push((name.into(), Arc::from(builtin)));
653        self
654    }
655
656    /// Register a capability extension.
657    ///
658    /// Extensions contribute a related set of builtins as one unit. They will
659    /// be documented in the tool's `help()` output like custom builtins. Later
660    /// registrations with the same name replace earlier registrations during
661    /// shell creation.
662    pub fn extension<E>(mut self, extension: E) -> Self
663    where
664        E: Extension,
665    {
666        for (name, builtin) in extension.builtins() {
667            self.builtins.push((name, Arc::from(builtin)));
668        }
669        self
670    }
671
672    /// Enable embedded Python (`python`/`python3` builtins) via Monty interpreter
673    /// with default resource limits.
674    ///
675    /// Requires the `python` feature flag. Python `pathlib.Path` operations are
676    /// bridged to the virtual filesystem. Limitations (no `open()`, no HTTP) are
677    /// automatically documented in `help()` and `system_prompt()`.
678    ///
679    /// For security, execution is runtime-gated: set
680    /// `BASHKIT_ALLOW_INPROCESS_PYTHON=1` before invoking `python`/`python3`.
681    #[cfg(feature = "python")]
682    pub fn python(self) -> Self {
683        self.python_with_limits(crate::builtins::PythonLimits::default())
684    }
685
686    /// Enable embedded Python with custom resource limits.
687    #[cfg(feature = "python")]
688    pub fn python_with_limits(self, limits: crate::builtins::PythonLimits) -> Self {
689        use crate::builtins::Python;
690        self.builtin("python", Box::new(Python::with_limits(limits.clone())))
691            .builtin("python3", Box::new(Python::with_limits(limits)))
692    }
693
694    /// Enable embedded TypeScript/JavaScript execution via ZapCode with defaults.
695    ///
696    /// Requires the `typescript` feature flag.
697    #[cfg(feature = "typescript")]
698    pub fn typescript(self) -> Self {
699        self.typescript_with_config(crate::builtins::TypeScriptConfig::default())
700    }
701
702    /// Enable embedded TypeScript with custom resource limits.
703    #[cfg(feature = "typescript")]
704    pub fn typescript_with_limits(self, limits: crate::builtins::TypeScriptLimits) -> Self {
705        self.extension(crate::builtins::TypeScriptExtension::with_limits(limits))
706    }
707
708    /// Enable embedded TypeScript with full configuration control.
709    #[cfg(feature = "typescript")]
710    pub fn typescript_with_config(self, config: crate::builtins::TypeScriptConfig) -> Self {
711        self.extension(crate::builtins::TypeScriptExtension::with_config(config))
712    }
713
714    /// Enable embedded TypeScript with external function handlers.
715    #[cfg(feature = "typescript")]
716    pub fn typescript_with_external_handler(
717        self,
718        limits: crate::builtins::TypeScriptLimits,
719        external_fns: Vec<String>,
720        handler: crate::builtins::TypeScriptExternalFnHandler,
721    ) -> Self {
722        self.extension(crate::builtins::TypeScriptExtension::with_external_handler(
723            limits,
724            external_fns,
725            handler,
726        ))
727    }
728
729    /// Build the BashTool
730    pub fn build(&self) -> BashTool {
731        let mut seen_builtin_names = HashSet::new();
732        let builtin_names: Vec<String> = self
733            .builtins
734            .iter()
735            .filter_map(|(name, _)| {
736                if seen_builtin_names.insert(name) {
737                    Some(name.clone())
738                } else {
739                    None
740                }
741            })
742            .collect();
743
744        // Collect LLM hints from builtins, deduplicated
745        let mut builtin_hints: Vec<String> = self
746            .builtins
747            .iter()
748            .filter_map(|(_, b)| b.llm_hint().map(String::from))
749            .collect();
750        builtin_hints.sort();
751        builtin_hints.dedup();
752
753        let locale = self.locale.clone();
754        let display_name = localized(locale.as_str(), "Bash", "Баш");
755
756        BashTool {
757            locale,
758            display_name: display_name.to_string(),
759            short_desc: localized(
760                self.locale.as_str(),
761                "Run bash commands in an isolated virtual filesystem",
762                "Виконує bash-команди в ізольованій віртуальній файловій системі",
763            )
764            .to_string(),
765            description: build_bash_description(self.locale.as_str(), &builtin_names),
766            username: self.username.clone(),
767            hostname: self.hostname.clone(),
768            limits: self.limits.clone(),
769            cwd: self.cwd.clone(),
770            env_vars: self.env_vars.clone(),
771            builtins: self.builtins.clone(),
772            config: self.config.clone(),
773            builtin_names,
774            builtin_hints,
775        }
776    }
777
778    /// Build a `tower::Service<Value, Response = Value, Error = ToolError>`.
779    pub fn build_service(&self) -> ToolService {
780        let tool = self.build();
781        tower::util::BoxCloneService::new(tower::service_fn(move |args| {
782            let tool = tool.clone();
783            async move {
784                let execution = tool.execution(args)?;
785                let output = execution.execute().await?;
786                Ok(output.result)
787            }
788        }))
789    }
790
791    /// Build an OpenAI-compatible tool definition.
792    pub fn build_tool_definition(&self) -> serde_json::Value {
793        let tool = self.build();
794        serde_json::json!({
795            "type": "function",
796            "function": {
797                "name": tool.name(),
798                "description": tool.description(),
799                "parameters": self.build_input_schema(),
800            }
801        })
802    }
803
804    /// Build the input schema without constructing a full tool.
805    pub fn build_input_schema(&self) -> serde_json::Value {
806        tool_request_schema()
807    }
808
809    /// Build the output schema for `ToolOutput::result`.
810    pub fn build_output_schema(&self) -> serde_json::Value {
811        tool_response_schema()
812    }
813}
814
815/// Virtual bash interpreter implementing the Tool trait
816#[derive(Clone)]
817pub struct BashTool {
818    locale: String,
819    display_name: String,
820    short_desc: String,
821    description: String,
822    username: Option<String>,
823    hostname: Option<String>,
824    limits: Option<ExecutionLimits>,
825    cwd: Option<PathBuf>,
826    env_vars: Vec<(String, String)>,
827    builtins: Vec<(String, Arc<dyn Builtin>)>,
828    /// Extra `BashBuilder` configuration applied per execution (via `configure`).
829    config: Vec<ToolConfigStep>,
830    /// Names of custom builtins (for documentation)
831    builtin_names: Vec<String>,
832    /// LLM hints from registered builtins
833    builtin_hints: Vec<String>,
834}
835
836impl BashTool {
837    /// Create a new tool builder
838    pub fn builder() -> BashToolBuilder {
839        BashToolBuilder::new()
840    }
841
842    /// Create a Bash instance with configured settings.
843    ///
844    /// Each configured step runs against a fresh `BashBuilder`, so the tool can
845    /// build an isolated shell per execution from cloneable configuration.
846    fn create_bash(&self) -> Bash {
847        let mut builder = Bash::builder();
848
849        if let Some(ref username) = self.username {
850            builder = builder.username(username);
851        }
852        if let Some(ref hostname) = self.hostname {
853            builder = builder.hostname(hostname);
854        }
855        if let Some(ref limits) = self.limits {
856            builder = builder.limits(limits.clone());
857        }
858        if let Some(ref cwd) = self.cwd {
859            builder = builder.cwd(cwd.clone());
860        }
861        for (key, value) in &self.env_vars {
862            builder = builder.env(key, value);
863        }
864        // Clone Arc builtins so they survive across multiple create_bash calls
865        for (name, builtin) in &self.builtins {
866            builder = builder.builtin(name.clone(), Box::new(Arc::clone(builtin)));
867        }
868        // Thin-adapter escape hatch: apply any extra BashBuilder configuration
869        // last so it can extend or override the convenience settings above.
870        for step in &self.config {
871            builder = step(builder);
872        }
873
874        builder.build()
875    }
876
877    /// Build dynamic help with configuration
878    fn build_help(&self) -> String {
879        build_bash_help(self)
880    }
881
882    /// Single-line warning listing language interpreters not registered as builtins.
883    /// Returns `None` when all tracked languages are available.
884    fn language_warning(&self) -> Option<String> {
885        let mut missing = Vec::new();
886
887        let has_perl = self.builtin_names.iter().any(|n| n == "perl");
888        if !has_perl {
889            missing.push("perl");
890        }
891
892        let has_python = self
893            .builtin_names
894            .iter()
895            .any(|n| n == "python" || n == "python3");
896        if !has_python {
897            missing.push("python/python3");
898        }
899
900        if missing.is_empty() {
901            None
902        } else {
903            Some(format!("{} not available.", missing.join(", ")))
904        }
905    }
906
907    /// Build dynamic system prompt
908    fn build_system_prompt(&self) -> String {
909        build_bash_system_prompt(self)
910    }
911
912    async fn run_request_with_stream(
913        &self,
914        req: ToolRequest,
915        stream_sender: Option<tokio::sync::mpsc::UnboundedSender<ToolOutputChunk>>,
916    ) -> ToolResponse {
917        if req.commands.is_empty() {
918            return ToolResponse {
919                stdout: String::new(),
920                stderr: String::new(),
921                exit_code: 0,
922                error: None,
923                ..Default::default()
924            };
925        }
926
927        let tool = self.clone();
928        let mut bash = tool.create_bash();
929
930        let fut = async {
931            let result = if let Some(sender) = stream_sender {
932                let output_cb: OutputCallback = Box::new(move |stdout_chunk, stderr_chunk| {
933                    if !stdout_chunk.is_empty() {
934                        let _ = sender.send(ToolOutputChunk {
935                            data: serde_json::json!(stdout_chunk),
936                            kind: "stdout".to_string(),
937                        });
938                    }
939                    if !stderr_chunk.is_empty() {
940                        let _ = sender.send(ToolOutputChunk {
941                            data: serde_json::json!(stderr_chunk),
942                            kind: "stderr".to_string(),
943                        });
944                    }
945                });
946                bash.exec_streaming(&req.commands, output_cb).await
947            } else {
948                bash.exec(&req.commands).await
949            };
950
951            match result {
952                Ok(result) => result.into(),
953                Err(err) => ToolResponse {
954                    stdout: String::new(),
955                    stderr: err.to_string(),
956                    exit_code: 1,
957                    error: Some(error_kind(&err)),
958                    ..Default::default()
959                },
960            }
961        };
962
963        if let Some(ms) = req.timeout_ms {
964            let duration = Duration::from_millis(ms);
965            match tokio::time::timeout(duration, fut).await {
966                Ok(response) => response,
967                Err(_) => timeout_response(duration),
968            }
969        } else {
970            fut.await
971        }
972    }
973}
974
975impl Default for BashTool {
976    fn default() -> Self {
977        BashToolBuilder::new().build()
978    }
979}
980
981#[async_trait]
982impl Tool for BashTool {
983    fn name(&self) -> &str {
984        "bashkit"
985    }
986
987    fn display_name(&self) -> &str {
988        &self.display_name
989    }
990
991    fn short_description(&self) -> &str {
992        &self.short_desc
993    }
994
995    fn description(&self) -> &str {
996        &self.description
997    }
998
999    fn help(&self) -> String {
1000        self.build_help()
1001    }
1002
1003    fn system_prompt(&self) -> String {
1004        self.build_system_prompt()
1005    }
1006
1007    fn locale(&self) -> &str {
1008        &self.locale
1009    }
1010
1011    fn input_schema(&self) -> serde_json::Value {
1012        tool_request_schema()
1013    }
1014
1015    fn output_schema(&self) -> serde_json::Value {
1016        tool_response_schema()
1017    }
1018
1019    fn version(&self) -> &str {
1020        VERSION
1021    }
1022
1023    fn execution(&self, args: serde_json::Value) -> Result<ToolExecution, ToolError> {
1024        let req = tool_request_from_value(self.locale(), args)?;
1025        let tool = self.clone();
1026
1027        Ok(ToolExecution::new(move |stream_sender| async move {
1028            let start = crate::time_compat::Instant::now();
1029            let response = tool.run_request_with_stream(req, stream_sender).await;
1030            tool_output_from_response(response, start.elapsed())
1031        }))
1032    }
1033
1034    async fn execute(&self, req: ToolRequest) -> ToolResponse {
1035        if req.commands.is_empty() {
1036            return ToolResponse {
1037                stdout: String::new(),
1038                stderr: String::new(),
1039                exit_code: 0,
1040                error: None,
1041                ..Default::default()
1042            };
1043        }
1044
1045        let mut bash = self.create_bash();
1046
1047        let fut = async {
1048            match bash.exec(&req.commands).await {
1049                Ok(result) => result.into(),
1050                Err(e) => ToolResponse {
1051                    stdout: String::new(),
1052                    stderr: e.to_string(),
1053                    exit_code: 1,
1054                    error: Some(error_kind(&e)),
1055                    ..Default::default()
1056                },
1057            }
1058        };
1059
1060        if let Some(ms) = req.timeout_ms {
1061            let dur = Duration::from_millis(ms);
1062            match tokio::time::timeout(dur, fut).await {
1063                Ok(resp) => resp,
1064                Err(_elapsed) => timeout_response(dur),
1065            }
1066        } else {
1067            fut.await
1068        }
1069    }
1070
1071    async fn execute_with_status(
1072        &self,
1073        req: ToolRequest,
1074        mut status_callback: Box<dyn FnMut(ToolStatus) + Send>,
1075    ) -> ToolResponse {
1076        status_callback(ToolStatus::new("validate").with_percent(0.0));
1077
1078        if req.commands.is_empty() {
1079            status_callback(ToolStatus::new("complete").with_percent(100.0));
1080            return ToolResponse {
1081                stdout: String::new(),
1082                stderr: String::new(),
1083                exit_code: 0,
1084                error: None,
1085                ..Default::default()
1086            };
1087        }
1088
1089        status_callback(ToolStatus::new("parse").with_percent(10.0));
1090
1091        let mut bash = self.create_bash();
1092
1093        status_callback(ToolStatus::new("execute").with_percent(20.0));
1094
1095        // Wire streaming: forward output chunks as ToolStatus events
1096        let status_cb = Arc::new(Mutex::new(status_callback));
1097        let status_cb_output = status_cb.clone();
1098        let output_cb: OutputCallback = Box::new(move |stdout_chunk, stderr_chunk| {
1099            if let Ok(mut cb) = status_cb_output.lock() {
1100                if !stdout_chunk.is_empty() {
1101                    cb(ToolStatus::stdout(stdout_chunk));
1102                }
1103                if !stderr_chunk.is_empty() {
1104                    cb(ToolStatus::stderr(stderr_chunk));
1105                }
1106            }
1107        });
1108
1109        let timeout_ms = req.timeout_ms;
1110
1111        let fut = async {
1112            let response = match bash.exec_streaming(&req.commands, output_cb).await {
1113                Ok(result) => result.into(),
1114                Err(e) => ToolResponse {
1115                    stdout: String::new(),
1116                    stderr: e.to_string(),
1117                    exit_code: 1,
1118                    error: Some(error_kind(&e)),
1119                    ..Default::default()
1120                },
1121            };
1122
1123            if let Ok(mut cb) = status_cb.lock() {
1124                cb(ToolStatus::new("complete").with_percent(100.0));
1125            }
1126
1127            response
1128        };
1129
1130        if let Some(ms) = timeout_ms {
1131            let dur = Duration::from_millis(ms);
1132            match tokio::time::timeout(dur, fut).await {
1133                Ok(resp) => resp,
1134                Err(_elapsed) => timeout_response(dur),
1135            }
1136        } else {
1137            fut.await
1138        }
1139    }
1140}
1141
1142/// Extract error kind from Error for categorization
1143fn error_kind(e: &Error) -> String {
1144    match e {
1145        Error::Parse { .. } => "parse_error".to_string(),
1146        Error::Execution(_) => "execution_error".to_string(),
1147        Error::Io(_) => "io_error".to_string(),
1148        Error::ResourceLimit(_) => "resource_limit".to_string(),
1149        Error::Network(_) => "network_error".to_string(),
1150        Error::Regex(_) => "regex_error".to_string(),
1151        Error::CommandFailure(_) => "execution_error".to_string(),
1152        Error::Internal(_) => "internal_error".to_string(),
1153        Error::Cancelled => "cancelled".to_string(),
1154    }
1155}
1156
1157/// Build a ToolResponse for a timed-out execution (exit code 124, like bash `timeout`).
1158pub(crate) fn timeout_response(dur: Duration) -> ToolResponse {
1159    ToolResponse {
1160        stdout: String::new(),
1161        stderr: format!(
1162            "bashkit: execution timed out after {:.1}s\n",
1163            dur.as_secs_f64()
1164        ),
1165        exit_code: 124,
1166        error: Some("timeout".to_string()),
1167        ..Default::default()
1168    }
1169}
1170
1171pub(crate) fn localized<'a>(locale: &str, en: &'a str, uk: &'a str) -> &'a str {
1172    if locale.starts_with("uk") { uk } else { en }
1173}
1174
1175fn build_bash_description(locale: &str, builtin_names: &[String]) -> String {
1176    let mut desc = localized(
1177        locale,
1178        "Run bash commands in an isolated virtual filesystem",
1179        "Виконує bash-команди в ізольованій віртуальній файловій системі",
1180    )
1181    .to_string();
1182    if !builtin_names.is_empty() {
1183        desc.push_str(". ");
1184        desc.push_str(localized(
1185            locale,
1186            "Custom commands",
1187            "Користувацькі команди",
1188        ));
1189        desc.push_str(": ");
1190        desc.push_str(&builtin_names.join(", "));
1191    }
1192    desc
1193}
1194
1195fn build_bash_system_prompt(tool: &BashTool) -> String {
1196    let mut parts = vec![format!(
1197        "{}: {}.",
1198        tool.name(),
1199        localized(
1200            tool.locale(),
1201            "run bash commands in an isolated virtual filesystem",
1202            "виконує bash-команди в ізольованій віртуальній файловій системі",
1203        )
1204    )];
1205
1206    parts.push(
1207        localized(
1208            tool.locale(),
1209            "Returns JSON with stdout, stderr, exit_code.",
1210            "Повертає JSON з stdout, stderr, exit_code.",
1211        )
1212        .to_string(),
1213    );
1214
1215    if let Some(username) = &tool.username {
1216        parts.push(format!(
1217            "{} /home/{username}.",
1218            localized(tool.locale(), "Home", "Домівка")
1219        ));
1220    }
1221
1222    // Operational guidance for LLM agents (issue #608)
1223    parts.push(
1224        localized(
1225            tool.locale(),
1226            "Use bash syntax; do not assume /bin/sh portability ($RANDOM, arrays, [[ ]] require bash).",
1227            "Використовуйте синтаксис bash; не покладайтеся на /bin/sh ($RANDOM, масиви, [[ ]] потребують bash).",
1228        )
1229        .to_string(),
1230    );
1231    parts.push(
1232        localized(
1233            tool.locale(),
1234            "Use `source` only when current-shell state must persist; otherwise run scripts directly.",
1235            "Використовуйте `source` лише коли стан оболонки має зберігатися; інакше запускайте скрипти напряму.",
1236        )
1237        .to_string(),
1238    );
1239    parts.push(
1240        localized(
1241            tool.locale(),
1242            "For large multi-file writes, prefer incremental batches over one giant script.",
1243            "Для масових записів файлів віддавайте перевагу інкрементальним пакетам замість одного великого скрипту.",
1244        )
1245        .to_string(),
1246    );
1247
1248    // Surface configured limits
1249    if let Some(ref limits) = tool.limits {
1250        let mut limit_parts = Vec::new();
1251        limit_parts.push(format!("max_commands={}", limits.max_commands));
1252        limit_parts.push(format!(
1253            "max_loop_iterations={}",
1254            limits.max_loop_iterations
1255        ));
1256        parts.push(format!(
1257            "{}: {}.",
1258            localized(tool.locale(), "Limits", "Ліміти"),
1259            limit_parts.join(", ")
1260        ));
1261    }
1262
1263    if !tool.builtin_hints.is_empty() {
1264        parts.extend(tool.builtin_hints.iter().cloned());
1265    }
1266
1267    if let Some(warning) = tool.language_warning() {
1268        parts.push(warning);
1269    }
1270
1271    parts.join(" ")
1272}
1273
1274fn build_bash_help(tool: &BashTool) -> String {
1275    let mut doc = String::new();
1276    doc.push_str(&format!("# {}\n\n", tool.display_name()));
1277    doc.push_str(tool.description());
1278    doc.push_str(".\n\n");
1279    doc.push_str(&format!(
1280        "**Version:** {}\n**Name:** `{}`\n**Locale:** `{}`\n\n",
1281        tool.version(),
1282        tool.name(),
1283        tool.locale()
1284    ));
1285
1286    doc.push_str("## Parameters\n\n");
1287    doc.push_str("| Name | Type | Required | Default | Description |\n");
1288    doc.push_str("|------|------|----------|---------|-------------|\n");
1289    doc.push_str("| `commands` | string | yes | — | Bash commands to execute |\n");
1290    doc.push_str("| `timeout_ms` | integer | no | — | Per-call timeout in milliseconds |\n\n");
1291
1292    doc.push_str("## Result\n\n");
1293    doc.push_str("| Field | Type | Description |\n");
1294    doc.push_str("|------|------|-------------|\n");
1295    doc.push_str("| `stdout` | string | Standard output |\n");
1296    doc.push_str("| `stderr` | string | Standard error |\n");
1297    doc.push_str("| `exit_code` | integer | Shell exit code |\n");
1298    doc.push_str("| `error` | string | Error category when execution fails |\n\n");
1299
1300    doc.push_str("## Examples\n\n");
1301    doc.push_str("```json\n");
1302    doc.push_str("{\"commands\":\"echo hello\"}\n");
1303    doc.push_str("```\n\n");
1304
1305    doc.push_str("```json\n");
1306    doc.push_str(
1307        "{\"commands\":\"echo data > /tmp/f.txt && cat /tmp/f.txt\",\"timeout_ms\":5000}\n",
1308    );
1309    doc.push_str("```\n\n");
1310
1311    doc.push_str("## Behavior\n\n");
1312    doc.push_str("- Filesystem is virtual and isolated per execution.\n");
1313    doc.push_str("- Standard bash syntax is supported, including pipes, redirects, loops, functions, and arrays.\n");
1314    doc.push_str("- Builtins available by default: `");
1315    doc.push_str(BUILTINS);
1316    doc.push_str("`\n");
1317    if !tool.builtin_names.is_empty() {
1318        doc.push_str("- Custom commands: `");
1319        doc.push_str(&tool.builtin_names.join("`, `"));
1320        doc.push_str("`\n");
1321    }
1322    if let Some(username) = &tool.username {
1323        doc.push_str(&format!("- User: `{username}`\n"));
1324    }
1325    if let Some(hostname) = &tool.hostname {
1326        doc.push_str(&format!("- Host: `{hostname}`\n"));
1327    }
1328    if let Some(limits) = &tool.limits {
1329        doc.push_str(&format!(
1330            "- Limits: {} commands, {} loop iterations, {} function depth\n",
1331            limits.max_commands, limits.max_loop_iterations, limits.max_function_depth
1332        ));
1333    }
1334    if !tool.env_vars.is_empty() {
1335        let env_keys: Vec<&str> = tool.env_vars.iter().map(|(key, _)| key.as_str()).collect();
1336        doc.push_str("- Environment variables: `");
1337        doc.push_str(&env_keys.join("`, `"));
1338        doc.push_str("`\n");
1339    }
1340    if !tool.builtin_hints.is_empty() {
1341        doc.push_str("\n## Notes\n\n");
1342        for hint in &tool.builtin_hints {
1343            doc.push_str("- ");
1344            doc.push_str(hint);
1345            doc.push('\n');
1346        }
1347    }
1348    if let Some(warning) = tool.language_warning() {
1349        doc.push_str("\n## Warnings\n\n");
1350        doc.push_str("- ");
1351        doc.push_str(&warning);
1352        doc.push('\n');
1353    }
1354
1355    doc
1356}
1357
1358pub(crate) fn tool_request_from_value(
1359    locale: &str,
1360    args: serde_json::Value,
1361) -> Result<ToolRequest, ToolError> {
1362    let Some(obj) = args.as_object() else {
1363        return Err(ToolError::UserFacing(
1364            localized(
1365                locale,
1366                "tool arguments must be a JSON object",
1367                "аргументи інструмента мають бути JSON-об'єктом",
1368            )
1369            .to_string(),
1370        ));
1371    };
1372
1373    let Some(commands) = obj.get("commands").and_then(|value| value.as_str()) else {
1374        return Err(ToolError::UserFacing(
1375            localized(
1376                locale,
1377                "`commands` is required",
1378                "поле `commands` є обов'язковим",
1379            )
1380            .to_string(),
1381        ));
1382    };
1383
1384    let timeout_ms = match obj.get("timeout_ms") {
1385        Some(value) => Some(value.as_u64().ok_or_else(|| {
1386            ToolError::UserFacing(
1387                localized(
1388                    locale,
1389                    "`timeout_ms` must be an integer",
1390                    "поле `timeout_ms` має бути цілим числом",
1391                )
1392                .to_string(),
1393            )
1394        })?),
1395        None => None,
1396    };
1397
1398    Ok(ToolRequest {
1399        commands: commands.to_string(),
1400        timeout_ms,
1401    })
1402}
1403
1404pub(crate) fn tool_output_from_response(
1405    response: ToolResponse,
1406    duration: Duration,
1407) -> Result<ToolOutput, ToolError> {
1408    let exit_code = response.exit_code;
1409    let result = serde_json::to_value(response)
1410        .map_err(|err| ToolError::Internal(format!("failed to serialize tool response: {err}")))?;
1411    Ok(ToolOutput {
1412        result,
1413        images: Vec::new(),
1414        metadata: ToolOutputMetadata {
1415            duration,
1416            extra: serde_json::json!({ "exit_code": exit_code }),
1417        },
1418    })
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423    use super::*;
1424
1425    #[test]
1426    fn test_bash_tool_builder() {
1427        let tool = BashTool::builder()
1428            .username("testuser")
1429            .hostname("testhost")
1430            .env("FOO", "bar")
1431            .limits(ExecutionLimits::new().max_commands(100))
1432            .build();
1433
1434        assert_eq!(tool.username, Some("testuser".to_string()));
1435        assert_eq!(tool.hostname, Some("testhost".to_string()));
1436        assert_eq!(tool.env_vars, vec![("FOO".to_string(), "bar".to_string())]);
1437    }
1438
1439    #[test]
1440    fn test_tool_trait_methods() {
1441        let tool = BashTool::default();
1442
1443        // Test trait methods
1444        assert_eq!(tool.name(), "bashkit");
1445        assert_eq!(tool.display_name(), "Bash");
1446        assert_eq!(
1447            tool.short_description(),
1448            "Run bash commands in an isolated virtual filesystem"
1449        );
1450        assert_eq!(
1451            tool.description(),
1452            "Run bash commands in an isolated virtual filesystem"
1453        );
1454        assert_eq!(tool.locale(), "en-US");
1455        assert!(tool.help().contains("# Bash"));
1456        assert!(tool.help().contains("## Parameters"));
1457        assert!(tool.system_prompt().starts_with("bashkit:"));
1458        assert_eq!(tool.version(), VERSION);
1459    }
1460
1461    #[test]
1462    fn test_jq_help_matches_enabled_feature() {
1463        let helptext = BashTool::default().help();
1464        assert_eq!(helptext.contains("awk jq head"), cfg!(feature = "jq"));
1465    }
1466
1467    #[test]
1468    fn test_tool_description_with_config() {
1469        let tool = BashTool::builder()
1470            .username("agent")
1471            .hostname("sandbox")
1472            .env("API_KEY", "secret")
1473            .limits(ExecutionLimits::new().max_commands(50))
1474            .build();
1475
1476        // helptext should include configuration in markdown
1477        let helptext = tool.help();
1478        assert!(helptext.contains("User: `agent`"));
1479        assert!(helptext.contains("Host: `sandbox`"));
1480        assert!(helptext.contains("50 commands"));
1481        assert!(helptext.contains("API_KEY"));
1482
1483        // system_prompt should include home and operational guidance
1484        let sysprompt = tool.system_prompt();
1485        assert!(sysprompt.starts_with("bashkit:"));
1486        assert!(sysprompt.contains("Home /home/agent."));
1487        assert!(
1488            sysprompt.contains("bash syntax"),
1489            "system_prompt should warn about bash vs sh"
1490        );
1491        assert!(
1492            sysprompt.contains("incremental batches"),
1493            "system_prompt should recommend chunked writes"
1494        );
1495        assert!(
1496            sysprompt.contains("max_commands=50"),
1497            "system_prompt should surface configured limits"
1498        );
1499    }
1500
1501    #[test]
1502    fn test_tool_schemas() {
1503        let tool = BashTool::default();
1504        let input_schema = tool.input_schema();
1505        let output_schema = tool.output_schema();
1506
1507        // Input schema should have commands property
1508        assert!(input_schema["properties"]["commands"].is_object());
1509
1510        // Output schema should have stdout, stderr, exit_code
1511        assert!(output_schema["properties"]["stdout"].is_object());
1512        assert!(output_schema["properties"]["stderr"].is_object());
1513        assert!(output_schema["properties"]["exit_code"].is_object());
1514    }
1515
1516    #[test]
1517    fn test_builder_contract_helpers() {
1518        let builder = BashTool::builder().username("agent");
1519        let definition = builder.build_tool_definition();
1520        let input_schema = builder.build_input_schema();
1521        let output_schema = builder.build_output_schema();
1522
1523        assert_eq!(definition["type"], "function");
1524        assert_eq!(definition["function"]["name"], "bashkit");
1525        assert_eq!(definition["function"]["parameters"], input_schema);
1526        assert!(output_schema["properties"]["stdout"].is_object());
1527    }
1528
1529    #[tokio::test]
1530    async fn test_builder_service_executes() {
1531        use tower::ServiceExt;
1532
1533        let service = BashTool::builder().build_service();
1534        let result = service
1535            .oneshot(serde_json::json!({"commands": "echo hello"}))
1536            .await
1537            .unwrap_or_else(|err| panic!("service should execute: {err}"));
1538
1539        assert_eq!(result["stdout"], "hello\n");
1540        assert_eq!(result["exit_code"], 0);
1541    }
1542
1543    #[test]
1544    fn test_execution_rejects_invalid_args() {
1545        let tool = BashTool::default();
1546        let err = tool
1547            .execution(serde_json::json!({"timeout_ms": 10}))
1548            .err()
1549            .unwrap_or_else(|| panic!("execution should reject missing commands"));
1550        assert_eq!(
1551            err,
1552            ToolError::UserFacing("`commands` is required".to_string())
1553        );
1554    }
1555
1556    #[tokio::test]
1557    async fn test_execution_returns_tool_output() {
1558        let tool = BashTool::default();
1559        let execution = tool
1560            .execution(serde_json::json!({"commands": "echo hello"}))
1561            .unwrap_or_else(|err| panic!("execution should be created: {err}"));
1562        let output = execution
1563            .execute()
1564            .await
1565            .unwrap_or_else(|err| panic!("execution should succeed: {err}"));
1566
1567        assert_eq!(output.result["stdout"], "hello\n");
1568        assert_eq!(output.metadata.extra["exit_code"], 0);
1569        assert!(output.metadata.duration >= Duration::from_millis(0));
1570    }
1571
1572    #[tokio::test]
1573    async fn test_configure_applies_to_underlying_bash_builder() {
1574        // The configure escape hatch reaches BashBuilder directly, and runs
1575        // after the convenience setters so it can override them.
1576        let tool = BashTool::builder()
1577            .env("FROM_CONVENIENCE", "a")
1578            .configure(|b| b.env("FROM_CONFIGURE", "b").username("via_configure"))
1579            .build();
1580        let output = tool
1581            .execution(
1582                serde_json::json!({"commands": "echo $FROM_CONVENIENCE $FROM_CONFIGURE $USER"}),
1583            )
1584            .unwrap_or_else(|err| panic!("execution should be created: {err}"))
1585            .execute()
1586            .await
1587            .unwrap_or_else(|err| panic!("execution should succeed: {err}"));
1588        assert_eq!(output.result["stdout"], "a b via_configure\n");
1589    }
1590
1591    #[tokio::test]
1592    async fn test_execution_stream_emits_output_chunks() {
1593        use futures_util::StreamExt;
1594
1595        let tool = BashTool::default();
1596        let execution = tool
1597            .execution(serde_json::json!({"commands": "for i in 1 2; do echo $i; done"}))
1598            .unwrap_or_else(|err| panic!("execution should be created: {err}"));
1599        let mut stream = execution
1600            .output_stream()
1601            .unwrap_or_else(|| panic!("stream should be available"));
1602
1603        let handle = tokio::spawn(async move {
1604            execution
1605                .execute()
1606                .await
1607                .unwrap_or_else(|err| panic!("execution should succeed: {err}"))
1608        });
1609
1610        let mut chunks = Vec::new();
1611        while let Some(chunk) = stream.next().await {
1612            chunks.push(chunk);
1613        }
1614
1615        let output = handle
1616            .await
1617            .unwrap_or_else(|err| panic!("join should succeed: {err}"));
1618
1619        assert_eq!(output.result["stdout"], "1\n2\n");
1620        assert_eq!(chunks.len(), 2);
1621        assert_eq!(chunks[0].kind, "stdout");
1622        assert_eq!(chunks[0].data, serde_json::json!("1\n"));
1623    }
1624
1625    #[test]
1626    fn test_locale_localizes_user_facing_text() {
1627        let tool = BashTool::builder().locale("uk-UA").build();
1628        assert_eq!(tool.display_name(), "Баш");
1629        assert_eq!(
1630            tool.description(),
1631            "Виконує bash-команди в ізольованій віртуальній файловій системі"
1632        );
1633        assert!(tool.system_prompt().starts_with("bashkit:"));
1634    }
1635
1636    #[test]
1637    fn test_tool_status() {
1638        let status = ToolStatus::new("execute")
1639            .with_message("Running commands")
1640            .with_percent(50.0)
1641            .with_eta(5000);
1642
1643        assert_eq!(status.phase, "execute");
1644        assert_eq!(status.message, Some("Running commands".to_string()));
1645        assert_eq!(status.percent_complete, Some(50.0));
1646        assert_eq!(status.eta_ms, Some(5000));
1647    }
1648
1649    #[tokio::test]
1650    async fn test_tool_execute_empty() {
1651        let tool = BashTool::default();
1652        let req = ToolRequest {
1653            commands: String::new(),
1654            timeout_ms: None,
1655        };
1656        let resp = tool.execute(req).await;
1657        assert_eq!(resp.exit_code, 0);
1658        assert!(resp.error.is_none());
1659    }
1660
1661    #[tokio::test]
1662    async fn test_tool_execute_echo() {
1663        let tool = BashTool::default();
1664        let req = ToolRequest {
1665            commands: "echo hello".to_string(),
1666            timeout_ms: None,
1667        };
1668        let resp = tool.execute(req).await;
1669        assert_eq!(resp.stdout, "hello\n");
1670        assert_eq!(resp.exit_code, 0);
1671        assert!(resp.error.is_none());
1672    }
1673
1674    #[test]
1675    fn test_bash_tool_builder_cwd() {
1676        let tool = BashTool::builder().cwd("/tmp/work").build();
1677        assert_eq!(tool.cwd, Some(PathBuf::from("/tmp/work")));
1678    }
1679
1680    #[tokio::test]
1681    async fn test_tool_cwd_sets_starting_directory() {
1682        let tool = BashTool::builder().cwd("/tmp").build();
1683        let req = ToolRequest {
1684            commands: "pwd".to_string(),
1685            timeout_ms: None,
1686        };
1687        let resp = tool.execute(req).await;
1688        assert_eq!(resp.stdout, "/tmp\n");
1689        assert_eq!(resp.exit_code, 0);
1690    }
1691
1692    #[test]
1693    fn test_builtin_hints_in_help_and_system_prompt() {
1694        use crate::builtins::Builtin;
1695        use crate::error::Result;
1696        use crate::interpreter::ExecResult;
1697
1698        struct HintedBuiltin;
1699
1700        #[async_trait]
1701        impl Builtin for HintedBuiltin {
1702            async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
1703                Ok(ExecResult::ok(String::new()))
1704            }
1705            fn llm_hint(&self) -> Option<&'static str> {
1706                Some("mycommand: Processes CSV. Max 10MB. No streaming.")
1707            }
1708        }
1709
1710        let tool = BashTool::builder()
1711            .builtin("mycommand", Box::new(HintedBuiltin))
1712            .build();
1713
1714        // Hint should appear in help
1715        let helptext = tool.help();
1716        assert!(
1717            helptext.contains("## Notes"),
1718            "help should have Notes section"
1719        );
1720        assert!(
1721            helptext.contains("mycommand: Processes CSV"),
1722            "help should contain the hint"
1723        );
1724
1725        // Hint should appear in system_prompt
1726        let sysprompt = tool.system_prompt();
1727        assert!(
1728            sysprompt.contains("mycommand: Processes CSV"),
1729            "system_prompt should contain the hint"
1730        );
1731    }
1732
1733    #[tokio::test]
1734    async fn test_extension_builtins_in_tool() {
1735        use crate::builtins::{Builtin, Extension};
1736        use crate::error::Result;
1737        use crate::interpreter::ExecResult;
1738
1739        struct ToolHello;
1740
1741        #[async_trait]
1742        impl Builtin for ToolHello {
1743            async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
1744                Ok(ExecResult::ok("hello from tool extension\n".to_string()))
1745            }
1746        }
1747
1748        struct ToolExtension;
1749
1750        impl Extension for ToolExtension {
1751            fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)> {
1752                vec![(
1753                    "tool-hello".to_string(),
1754                    Box::new(ToolHello) as Box<dyn Builtin>,
1755                )]
1756            }
1757        }
1758
1759        let tool = BashTool::builder().extension(ToolExtension).build();
1760        let resp = tool
1761            .execute(ToolRequest {
1762                commands: "tool-hello".to_string(),
1763                timeout_ms: None,
1764            })
1765            .await;
1766
1767        assert_eq!(resp.exit_code, 0);
1768        assert_eq!(resp.stdout, "hello from tool extension\n");
1769        assert!(tool.help().contains("tool-hello"));
1770    }
1771
1772    #[tokio::test]
1773    async fn test_tool_extension_duplicate_names_document_once() {
1774        use crate::builtins::{Builtin, Extension};
1775        use crate::error::Result;
1776        use crate::interpreter::ExecResult;
1777
1778        struct First;
1779        struct Second;
1780
1781        #[async_trait]
1782        impl Builtin for First {
1783            async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
1784                Ok(ExecResult::ok("first\n".to_string()))
1785            }
1786        }
1787
1788        #[async_trait]
1789        impl Builtin for Second {
1790            async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
1791                Ok(ExecResult::ok("second\n".to_string()))
1792            }
1793        }
1794
1795        struct OverrideExtension;
1796
1797        impl Extension for OverrideExtension {
1798            fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)> {
1799                vec![("dup".to_string(), Box::new(Second) as Box<dyn Builtin>)]
1800            }
1801        }
1802
1803        let tool = BashTool::builder()
1804            .builtin("dup", Box::new(First))
1805            .extension(OverrideExtension)
1806            .build();
1807        let resp = tool
1808            .execute(ToolRequest {
1809                commands: "dup".to_string(),
1810                timeout_ms: None,
1811            })
1812            .await;
1813
1814        assert_eq!(resp.stdout, "second\n");
1815        assert_eq!(tool.help().matches("`dup`").count(), 1);
1816    }
1817
1818    #[test]
1819    fn test_no_hints_without_hinted_builtins() {
1820        let tool = BashTool::default();
1821
1822        let helptext = tool.help();
1823        assert!(
1824            !helptext.contains("## Notes"),
1825            "help should not have Notes without hinted builtins"
1826        );
1827
1828        let sysprompt = tool.system_prompt();
1829        assert!(
1830            !sysprompt.contains("Processes CSV"),
1831            "system_prompt should not have hints without hinted builtins"
1832        );
1833    }
1834
1835    #[test]
1836    fn test_language_warning_default() {
1837        let tool = BashTool::default();
1838
1839        let sysprompt = tool.system_prompt();
1840        assert!(
1841            sysprompt.contains("perl, python/python3 not available."),
1842            "system_prompt should have single combined warning"
1843        );
1844
1845        let helptext = tool.help();
1846        assert!(
1847            helptext.contains("## Warnings"),
1848            "help should have Warnings section"
1849        );
1850        assert!(
1851            helptext.contains("perl, python/python3 not available."),
1852            "help should have single combined warning"
1853        );
1854    }
1855
1856    #[test]
1857    fn test_language_warning_suppressed_by_custom_builtins() {
1858        use crate::builtins::Builtin;
1859        use crate::error::Result;
1860        use crate::interpreter::ExecResult;
1861
1862        struct NoopBuiltin;
1863
1864        #[async_trait]
1865        impl Builtin for NoopBuiltin {
1866            async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
1867                Ok(ExecResult::ok(String::new()))
1868            }
1869        }
1870
1871        let tool = BashTool::builder()
1872            .builtin("python", Box::new(NoopBuiltin))
1873            .builtin("perl", Box::new(NoopBuiltin))
1874            .build();
1875
1876        let sysprompt = tool.system_prompt();
1877        assert!(
1878            !sysprompt.contains("not available"),
1879            "no warning when all languages registered"
1880        );
1881
1882        let helptext = tool.help();
1883        assert!(
1884            !helptext.contains("## Warnings"),
1885            "no Warnings section when all languages registered"
1886        );
1887    }
1888
1889    #[test]
1890    fn test_language_warning_partial() {
1891        use crate::builtins::Builtin;
1892        use crate::error::Result;
1893        use crate::interpreter::ExecResult;
1894
1895        struct NoopBuiltin;
1896
1897        #[async_trait]
1898        impl Builtin for NoopBuiltin {
1899            async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
1900                Ok(ExecResult::ok(String::new()))
1901            }
1902        }
1903
1904        // python3 registered -> only perl warned
1905        let tool = BashTool::builder()
1906            .builtin("python3", Box::new(NoopBuiltin))
1907            .build();
1908
1909        let sysprompt = tool.system_prompt();
1910        assert!(
1911            sysprompt.contains("perl not available."),
1912            "should warn about perl only"
1913        );
1914        assert!(
1915            !sysprompt.contains("python/python3"),
1916            "python warning suppressed when python3 registered"
1917        );
1918    }
1919
1920    #[test]
1921    fn test_duplicate_hints_deduplicated() {
1922        use crate::builtins::Builtin;
1923        use crate::error::Result;
1924        use crate::interpreter::ExecResult;
1925
1926        struct SameHint;
1927
1928        #[async_trait]
1929        impl Builtin for SameHint {
1930            async fn execute(&self, _ctx: crate::builtins::Context<'_>) -> Result<ExecResult> {
1931                Ok(ExecResult::ok(String::new()))
1932            }
1933            fn llm_hint(&self) -> Option<&'static str> {
1934                Some("same hint")
1935            }
1936        }
1937
1938        let tool = BashTool::builder()
1939            .builtin("cmd1", Box::new(SameHint))
1940            .builtin("cmd2", Box::new(SameHint))
1941            .build();
1942
1943        let helptext = tool.help();
1944        // Should appear exactly once
1945        assert_eq!(
1946            helptext.matches("same hint").count(),
1947            1,
1948            "Duplicate hints should be deduplicated"
1949        );
1950    }
1951
1952    #[cfg(feature = "python")]
1953    #[test]
1954    fn test_python_hint_via_builder() {
1955        let tool = BashTool::builder().python().build();
1956
1957        let helptext = tool.help();
1958        assert!(helptext.contains("python"), "help should mention python");
1959        assert!(
1960            helptext.contains("open() against the VFS"),
1961            "help should document VFS-scoped open() support"
1962        );
1963        assert!(
1964            helptext.contains("No HTTP"),
1965            "help should document HTTP limitation"
1966        );
1967
1968        let sysprompt = tool.system_prompt();
1969        assert!(
1970            sysprompt.contains("python"),
1971            "system_prompt should mention python"
1972        );
1973
1974        // Python warning should be suppressed when python is enabled via Monty
1975        assert!(
1976            !sysprompt.contains("python/python3 not available"),
1977            "python warning should not appear when Monty python enabled"
1978        );
1979    }
1980
1981    #[tokio::test]
1982    async fn test_tool_execute_with_status() {
1983        use std::sync::{Arc, Mutex};
1984
1985        let tool = BashTool::default();
1986        let req = ToolRequest {
1987            commands: "echo test".to_string(),
1988            timeout_ms: None,
1989        };
1990
1991        let phases = Arc::new(Mutex::new(Vec::new()));
1992        let phases_clone = phases.clone();
1993
1994        let resp = tool
1995            .execute_with_status(
1996                req,
1997                Box::new(move |status| {
1998                    phases_clone
1999                        .lock()
2000                        .expect("lock poisoned")
2001                        .push(status.phase.clone());
2002                }),
2003            )
2004            .await;
2005
2006        assert_eq!(resp.stdout, "test\n");
2007        let phases = phases.lock().expect("lock poisoned");
2008        assert!(phases.contains(&"validate".to_string()));
2009        assert!(phases.contains(&"complete".to_string()));
2010    }
2011
2012    #[tokio::test]
2013    async fn test_execute_with_status_streams_output() {
2014        let tool = BashTool::default();
2015        let req = ToolRequest {
2016            commands: "for i in a b c; do echo $i; done".to_string(),
2017            timeout_ms: None,
2018        };
2019
2020        let events = Arc::new(Mutex::new(Vec::new()));
2021        let events_clone = events.clone();
2022
2023        let resp = tool
2024            .execute_with_status(
2025                req,
2026                Box::new(move |status| {
2027                    events_clone.lock().expect("lock poisoned").push(status);
2028                }),
2029            )
2030            .await;
2031
2032        assert_eq!(resp.stdout, "a\nb\nc\n");
2033        assert_eq!(resp.exit_code, 0);
2034
2035        let events = events.lock().expect("lock poisoned");
2036        // Should have output events for each iteration
2037        let output_events: Vec<_> = events.iter().filter(|s| s.phase == "output").collect();
2038        assert_eq!(
2039            output_events.len(),
2040            3,
2041            "expected 3 output events, got {output_events:?}"
2042        );
2043        assert_eq!(output_events[0].output.as_deref(), Some("a\n"));
2044        assert_eq!(output_events[0].stream.as_deref(), Some("stdout"));
2045        assert_eq!(output_events[1].output.as_deref(), Some("b\n"));
2046        assert_eq!(output_events[2].output.as_deref(), Some("c\n"));
2047    }
2048
2049    #[tokio::test]
2050    async fn test_execute_with_status_streams_list_commands() {
2051        let tool = BashTool::default();
2052        let req = ToolRequest {
2053            commands: "echo start; echo end".to_string(),
2054            timeout_ms: None,
2055        };
2056
2057        let events = Arc::new(Mutex::new(Vec::new()));
2058        let events_clone = events.clone();
2059
2060        let resp = tool
2061            .execute_with_status(
2062                req,
2063                Box::new(move |status| {
2064                    events_clone.lock().expect("lock poisoned").push(status);
2065                }),
2066            )
2067            .await;
2068
2069        assert_eq!(resp.stdout, "start\nend\n");
2070
2071        let events = events.lock().expect("lock poisoned");
2072        let output_events: Vec<_> = events.iter().filter(|s| s.phase == "output").collect();
2073        assert_eq!(
2074            output_events.len(),
2075            2,
2076            "expected 2 output events, got {output_events:?}"
2077        );
2078        assert_eq!(output_events[0].output.as_deref(), Some("start\n"));
2079        assert_eq!(output_events[1].output.as_deref(), Some("end\n"));
2080    }
2081
2082    #[tokio::test]
2083    async fn test_execute_with_status_no_duplicate_output() {
2084        let tool = BashTool::default();
2085        // mix of list + loop: should get 5 distinct events, no duplicates
2086        let req = ToolRequest {
2087            commands: "echo start; for i in 1 2 3; do echo $i; done; echo end".to_string(),
2088            timeout_ms: None,
2089        };
2090
2091        let events = Arc::new(Mutex::new(Vec::new()));
2092        let events_clone = events.clone();
2093
2094        let resp = tool
2095            .execute_with_status(
2096                req,
2097                Box::new(move |status| {
2098                    events_clone.lock().expect("lock poisoned").push(status);
2099                }),
2100            )
2101            .await;
2102
2103        assert_eq!(resp.stdout, "start\n1\n2\n3\nend\n");
2104
2105        let events = events.lock().expect("lock poisoned");
2106        let output_events: Vec<_> = events
2107            .iter()
2108            .filter(|s| s.phase == "output")
2109            .map(|s| s.output.as_deref().unwrap_or(""))
2110            .collect();
2111        assert_eq!(
2112            output_events,
2113            vec!["start\n", "1\n", "2\n", "3\n", "end\n"],
2114            "should have exactly 5 distinct output events"
2115        );
2116    }
2117
2118    #[test]
2119    fn test_tool_status_stdout_constructor() {
2120        let status = ToolStatus::stdout("hello\n");
2121        assert_eq!(status.phase, "output");
2122        assert_eq!(status.output.as_deref(), Some("hello\n"));
2123        assert_eq!(status.stream.as_deref(), Some("stdout"));
2124        assert!(status.message.is_none());
2125    }
2126
2127    #[test]
2128    fn test_tool_status_stderr_constructor() {
2129        let status = ToolStatus::stderr("error\n");
2130        assert_eq!(status.phase, "output");
2131        assert_eq!(status.output.as_deref(), Some("error\n"));
2132        assert_eq!(status.stream.as_deref(), Some("stderr"));
2133    }
2134
2135    #[tokio::test]
2136    async fn test_tool_execute_timeout() {
2137        let tool = BashTool::default();
2138        let req = ToolRequest {
2139            commands: "sleep 10".to_string(),
2140            timeout_ms: Some(100),
2141        };
2142        let resp = tool.execute(req).await;
2143        assert_eq!(resp.exit_code, 124);
2144        assert!(resp.stderr.contains("timed out"));
2145        assert_eq!(resp.error, Some("timeout".to_string()));
2146    }
2147
2148    #[tokio::test]
2149    async fn test_tool_execute_no_timeout() {
2150        let tool = BashTool::default();
2151        let req = ToolRequest {
2152            commands: "echo fast".to_string(),
2153            timeout_ms: Some(5000),
2154        };
2155        let resp = tool.execute(req).await;
2156        assert_eq!(resp.exit_code, 0);
2157        assert_eq!(resp.stdout, "fast\n");
2158    }
2159
2160    #[test]
2161    fn test_tool_request_new() {
2162        let req = ToolRequest::new("echo test");
2163        assert_eq!(req.commands, "echo test");
2164        assert_eq!(req.timeout_ms, None);
2165    }
2166
2167    #[test]
2168    fn test_tool_request_deserialize_without_timeout() {
2169        let json = r#"{"commands":"echo hello"}"#;
2170        let req: ToolRequest = serde_json::from_str(json).unwrap();
2171        assert_eq!(req.commands, "echo hello");
2172        assert_eq!(req.timeout_ms, None);
2173    }
2174
2175    #[test]
2176    fn test_tool_request_deserialize_with_timeout() {
2177        let json = r#"{"commands":"echo hello","timeout_ms":5000}"#;
2178        let req: ToolRequest = serde_json::from_str(json).unwrap();
2179        assert_eq!(req.commands, "echo hello");
2180        assert_eq!(req.timeout_ms, Some(5000));
2181    }
2182
2183    // Issue #422: create_bash should not empty builtins after first call
2184    #[tokio::test]
2185    async fn test_create_bash_preserves_builtins() {
2186        use crate::ExecResult;
2187        use crate::builtins::{Builtin, Context};
2188        use async_trait::async_trait;
2189
2190        struct TestBuiltin;
2191
2192        #[async_trait]
2193        impl Builtin for TestBuiltin {
2194            async fn execute(&self, _ctx: Context<'_>) -> crate::Result<ExecResult> {
2195                Ok(ExecResult::ok("test_output\n"))
2196            }
2197        }
2198
2199        let tool = BashToolBuilder::new()
2200            .builtin("testcmd", Box::new(TestBuiltin))
2201            .build();
2202
2203        // First call
2204        let mut bash1 = tool.create_bash();
2205        let result1 = bash1.exec("testcmd").await.unwrap();
2206        assert!(
2207            result1.stdout.contains("test_output"),
2208            "first call should have custom builtin"
2209        );
2210
2211        // Second call should still have the builtin
2212        let mut bash2 = tool.create_bash();
2213        let result2 = bash2.exec("testcmd").await.unwrap();
2214        assert!(
2215            result2.stdout.contains("test_output"),
2216            "second call should still have custom builtin"
2217        );
2218    }
2219}