Skip to main content

rpi_tools/tools/
bash.rs

1//! Mirrors `packages/agent/src/harness/tools/bash.ts` — the built-in `bash`
2//! `AgentTool`. Drives `execute_shell_with_capture` with a 2-second checkpoint
3//! throttle, and converts ALL failures (non-zero exit, timeout, abort,
4//! executionError) into `Err(AgentError)` — the agent loop encodes those into an
5//! error `ToolResultMessage`.
6//!
7//! Throttle mirrors the TS `BASH_CHECKPOINT_INTERVAL_MS`: at most one
8//! checkpoint update per 2-second window; a final flush always follows capture
9//! resolution.
10
11use futures::future::BoxFuture;
12use std::collections::HashMap;
13use std::path::PathBuf;
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16
17use async_trait::async_trait;
18use rpi_agent::agent_tool::AgentTool;
19use rpi_agent::error::AgentError;
20use rpi_agent::types::{AgentToolResult, TextContentOrImage, ToolResultPartial};
21use rpi_ai::types::{ConstrainedSamplingConfig, ConstrainedStrictness, Tool};
22use schemars::JsonSchema;
23use serde::Deserialize;
24use tokio::sync::Mutex;
25use tokio_util::sync::CancellationToken;
26
27use crate::env::ExecutionEnv;
28use crate::error::{ExecutionError, ExecutionErrorCode};
29use crate::shell_output::{
30    execute_shell_with_capture, ShellCaptureOptions, ShellCaptureProgress, ShellCaptureResult,
31};
32use crate::tools::tool_context::ExecutionToolContext;
33use crate::truncate::{format_size, TruncationResult, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES};
34
35/// Throttle window for bash `on_update` flushes. Mirrors `BASH_UPDATE_THROTTLE_MS`.
36const BASH_UPDATE_THROTTLE_MS: u64 = 2_000;
37
38/// Input for the bash tool. Mirrors TS `BashToolInput`. `timeout` is in seconds.
39#[derive(Debug, Clone, JsonSchema, Deserialize)]
40pub struct BashInput {
41    /// Bash command to execute.
42    pub command: String,
43    /// Timeout in seconds (optional, no default timeout).
44    #[serde(default)]
45    pub timeout: Option<f64>,
46}
47
48/// Structured details for the bash tool. Mirrors TS `BashToolDetails`.
49#[derive(Debug, Clone, serde::Serialize)]
50pub struct BashToolDetails {
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub truncation: Option<TruncationResult>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub full_output_path: Option<String>,
55}
56
57/// Mutable execution description passed to the optional prepare hook.
58#[derive(Debug, Clone)]
59pub struct BashExecution {
60    pub command: String,
61    pub cwd: PathBuf,
62    pub env: HashMap<String, String>,
63    pub inherit_env: bool,
64}
65
66/// Async hook called immediately before shell execution, matching native Pi's
67/// `BashPrepare` extension point.
68pub type BashPrepare =
69    Arc<dyn Fn(&mut BashExecution) -> BoxFuture<'static, Result<(), String>> + Send + Sync>;
70
71/// Options for `create_bash_tool`. Mirrors TS `BashToolOptions`.
72#[derive(Clone, Default)]
73pub struct BashToolOptions {
74    /// Prepended to the command (separated by `\n`) before execution.
75    pub command_prefix: Option<String>,
76    /// Fallback timeout in seconds applied when the model doesn't pass one.
77    /// pi has no default, but a model that forgets `timeout` can hang the run
78    /// forever (reported as "卡住") — rpi injects this at the harness build
79    /// site. A model-supplied timeout still wins.
80    pub default_timeout: Option<f64>,
81    /// Optional async hook that can adjust command, cwd, and environment.
82    pub prepare: Option<BashPrepare>,
83}
84
85/// The built-in `bash` tool. Holds an `Arc<dyn ExecutionEnv>` (read-only view
86/// suffices — bash does not touch the mutation queue).
87pub struct BashTool {
88    schema: Tool,
89    env: Arc<dyn ExecutionEnv>,
90    options: BashToolOptions,
91}
92
93impl BashTool {
94    fn schema() -> Tool {
95        let params = schemars::schema_for!(BashInput);
96        Tool {
97            name: "bash".to_string(),
98            description: format!(
99                "Execute a Bash command in the current working directory using Bash/POSIX syntax, even on Windows. PowerShell cmdlets (Get-ChildItem, Select-Object, Select-String) are not valid here; use ls, find, rg, and POSIX pipelines instead. Returns stdout and \
100                 stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {kb}KB \
101                 (whichever is hit first). If truncated, full output is saved to a temp file. \
102                 Optionally provide a timeout in seconds.",
103                kb = DEFAULT_MAX_BYTES / 1024
104            ),
105            parameters: rpi_ai::types::Schema::new(
106                serde_json::to_value(params).unwrap_or_default(),
107            ),
108            constrained_sampling: Some(ConstrainedSamplingConfig::JsonSchema {
109                strict: ConstrainedStrictness::Prefer,
110            }),
111        }
112    }
113}
114
115/// Build the `bash` tool from a context. Mirrors TS `createBashTool`.
116pub fn create_bash_tool(
117    context: &ExecutionToolContext,
118    options: Option<BashToolOptions>,
119) -> Arc<dyn AgentTool> {
120    Arc::new(BashTool {
121        schema: BashTool::schema(),
122        env: context.env().clone(),
123        options: options.unwrap_or_default(),
124    })
125}
126
127#[async_trait]
128impl AgentTool for BashTool {
129    fn schema(&self) -> &Tool {
130        &self.schema
131    }
132    fn label(&self) -> &str {
133        "bash"
134    }
135
136    async fn execute(
137        &self,
138        _tool_call_id: &str,
139        params: serde_json::Value,
140        signal: CancellationToken,
141        on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
142    ) -> Result<AgentToolResult, AgentError> {
143        let input: BashInput = serde_json::from_value(params)
144            .map_err(|e| AgentError::Validation(format!("bash input invalid: {e}")))?;
145        validate_timeout(input.timeout)?;
146        let mut execution = BashExecution {
147            command: match &self.options.command_prefix {
148                Some(p) => format!("{p}\n{}", input.command),
149                None => input.command,
150            },
151            cwd: self.env.cwd().to_path_buf(),
152            env: HashMap::new(),
153            inherit_env: true,
154        };
155        if let Some(prepare) = &self.options.prepare {
156            prepare(&mut execution).await.map_err(AgentError::Tool)?;
157        }
158        let command = execution.command;
159        let timeout = input.timeout.or(self.options.default_timeout);
160
161        // Initial empty update (mirrors TS `onUpdate?.({ content: [], details: undefined })`).
162        emit_update(&on_update, String::new(), None, None);
163
164        // The throttle state. Shared between the on_chunk callback (which marks
165        // dirty + schedules a flush) and the post-capture final flush.
166        let throttle = Arc::new(Mutex::new(ThrottleState {
167            dirty: false,
168            last_flush: Instant::now(),
169            latest_output: String::new(),
170            latest_truncation: None,
171            latest_full_path: None,
172        }));
173
174        let throttle_for_cb = throttle.clone();
175        let on_update_for_cb = on_update.clone();
176        let on_chunk: Box<dyn FnMut(&str, &dyn Fn() -> ShellCaptureProgress) + Send> = Box::new(
177            move |_chunk, get_progress: &dyn Fn() -> ShellCaptureProgress| {
178                let progress = get_progress();
179                // Snapshot the fields we need from the progress before touching
180                // the async mutex — the on_chunk callback runs on whatever thread
181                // the env's stdout/stderr callback is invoked from, which may be
182                // a runtime worker. Use try_lock so we never block (and never
183                // panic on a held lock); a missed flush is fine — the final
184                // post-capture flush always emits the terminal state.
185                let new_output = progress.output.clone();
186                let new_truncation = if progress.truncation.truncated {
187                    Some(progress.truncation.clone())
188                } else {
189                    None
190                };
191                let new_full_path = progress
192                    .full_output_path
193                    .as_ref()
194                    .map(|p| p.to_string_lossy().into_owned());
195                let mut st = match throttle_for_cb.try_lock() {
196                    Ok(g) => g,
197                    Err(_) => return, // busy — skip this update; final flush covers it
198                };
199                st.latest_output = new_output;
200                st.latest_truncation = new_truncation;
201                st.latest_full_path = new_full_path;
202                st.dirty = true;
203                let elapsed = st.last_flush.elapsed();
204                let throttle_duration = Duration::from_millis(BASH_UPDATE_THROTTLE_MS);
205                if elapsed >= throttle_duration {
206                    flush_now(&mut st, &on_update_for_cb);
207                } else {
208                    // Schedule a delayed flush (mirrors TS setTimeout)
209                    let remaining = throttle_duration - elapsed;
210                    let throttle_clone = throttle_for_cb.clone();
211                    let on_update_clone = on_update_for_cb.clone();
212                    tokio::spawn(async move {
213                        tokio::time::sleep(remaining).await;
214                        let mut st = throttle_clone.lock().await;
215                        flush_now(&mut st, &on_update_clone);
216                    });
217                }
218            },
219        );
220
221        let cwd = execution.cwd;
222        let env = self.env.clone();
223        let capture_opts = ShellCaptureOptions {
224            shell: None,
225            cwd: Some(cwd),
226            env: Some(execution.env),
227            inherit_env: execution.inherit_env,
228            timeout,
229            cancel: Some(&signal),
230            on_chunk: Some(on_chunk),
231            return_execution_errors: true,
232        };
233
234        // execute_shell_with_capture wants `&Arc<dyn ExecutionEnv>`.
235        let capture = execute_shell_with_capture(&env, &command, capture_opts)
236            .await
237            .map_err(exec_err_to_agent)?;
238
239        // Final flush + emit the terminal output via on_update.
240        {
241            let mut st = throttle.lock().await;
242            st.latest_output = capture.output.clone();
243            st.latest_truncation = if capture.truncated {
244                Some(capture.truncation.clone())
245            } else {
246                None
247            };
248            st.latest_full_path = capture
249                .full_output_path
250                .as_ref()
251                .map(|p| p.to_string_lossy().into_owned());
252            st.dirty = true;
253            flush_now(&mut st, &on_update);
254        }
255
256        // Build the terminal output text + details, applying truncation notices.
257        let (output_text, details) = build_bash_output(&capture);
258        let details_value = serde_json::to_value(details).unwrap_or(serde_json::Value::Null);
259
260        // Resolution: cancelled → aborted; timeout → timeout; other exec error;
261        // non-zero exit → Tool error. All become Err(AgentError).
262        let append_status = |status: String| -> String {
263            if output_text.is_empty() {
264                status
265            } else {
266                format!("{output_text}\n\n{status}")
267            }
268        };
269        if capture.cancelled {
270            return Err(AgentError::Tool(append_status("Command aborted".into())));
271        }
272        if let Some(ee) = &capture.execution_error {
273            if ee.code == ExecutionErrorCode::Timeout {
274                // `timeout` is `Option<f64>` in seconds; `{timeout:?}` used to render
275                // `Some(2.0)`, so the message read "timed out after Some(2.0) seconds".
276                let limit = match timeout {
277                    Some(secs) => format!("after {secs}s"),
278                    None => "".to_string(),
279                };
280                return Err(AgentError::Tool(append_status(format!(
281                    "Command timed out {limit}"
282                ))));
283            }
284            return Err(AgentError::Tool(ee.to_string()));
285        }
286        if let Some(code) = capture.exit_code {
287            if code != 0 {
288                return Err(AgentError::Tool(append_status(format!(
289                    "Command exited with code {code}"
290                ))));
291            }
292        }
293        let text = if output_text.is_empty() {
294            "(no output)".to_string()
295        } else {
296            output_text
297        };
298        Ok(AgentToolResult {
299            content: vec![TextContentOrImage::text(text)],
300            details: details_value,
301            ..Default::default()
302        })
303    }
304}
305
306/// Throttle state, shared between the on_chunk callback and the final flush.
307struct ThrottleState {
308    dirty: bool,
309    last_flush: Instant,
310    latest_output: String,
311    latest_truncation: Option<TruncationResult>,
312    latest_full_path: Option<String>,
313}
314
315/// Flush a dirty throttle state by emitting an `on_update`. Mirrors
316/// `emitOutputUpdate`.
317fn flush_now(st: &mut ThrottleState, on_update: &Arc<dyn Fn(ToolResultPartial) + Send + Sync>) {
318    if !st.dirty {
319        return;
320    }
321    st.dirty = false;
322    st.last_flush = Instant::now();
323    emit_update(
324        on_update,
325        st.latest_output.clone(),
326        st.latest_truncation.clone(),
327        st.latest_full_path.clone(),
328    );
329}
330
331/// Emit a single `on_update` payload built from the given capture snapshot.
332/// Mirrors the TS `onUpdate({ content:[{text:progress.output}], details:{...} })`.
333fn emit_update(
334    on_update: &Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
335    output: String,
336    truncation: Option<TruncationResult>,
337    full_output_path: Option<String>,
338) {
339    let details = match (&truncation, &full_output_path) {
340        (None, None) => serde_json::Value::Null,
341        _ => {
342            let details = BashToolDetails {
343                truncation,
344                full_output_path,
345            };
346            serde_json::to_value(details).unwrap_or(serde_json::Value::Null)
347        }
348    };
349    on_update(AgentToolResult {
350        content: vec![TextContentOrImage::text(output)],
351        details,
352        ..Default::default()
353    });
354}
355
356/// Build the terminal `(output_text, details)` from a capture, appending the
357/// truncation continuation notice (mirrors the TS block).
358fn build_bash_output(capture: &ShellCaptureResult) -> (String, BashToolDetails) {
359    let mut output_text = capture.output.clone();
360    let details = if capture.truncated {
361        let t = &capture.truncation;
362        let start_line = t.total_lines.saturating_sub(t.output_lines) + 1;
363        let end_line = t.total_lines;
364        let path_str = capture
365            .full_output_path
366            .as_ref()
367            .map(|p| p.to_string_lossy().into_owned());
368        if t.last_line_partial {
369            let last_line_size = format_size(capture.last_line_bytes);
370            output_text.push_str(&format!(
371                "\n\n[Showing last {} of line {end_line} (line is {last_line_size}). Full output: {}]",
372                format_size(t.output_bytes),
373                path_str.as_deref().unwrap_or("?")
374            ));
375        } else if matches!(
376            t.truncated_by,
377            Some(crate::truncate::TruncationLimit::Lines)
378        ) {
379            output_text.push_str(&format!(
380                "\n\n[Showing lines {start_line}-{end_line} of {}. Full output: {}]",
381                t.total_lines,
382                path_str.as_deref().unwrap_or("?")
383            ));
384        } else {
385            output_text.push_str(&format!(
386                "\n\n[Showing lines {start_line}-{end_line} of {} ({} limit). Full output: {}]",
387                t.total_lines,
388                format_size(DEFAULT_MAX_BYTES),
389                path_str.as_deref().unwrap_or("?")
390            ));
391        }
392        BashToolDetails {
393            truncation: Some(capture.truncation.clone()),
394            full_output_path: path_str,
395        }
396    } else {
397        BashToolDetails {
398            truncation: None,
399            full_output_path: None,
400        }
401    };
402    (output_text, details)
403}
404
405/// Validate a timeout (seconds). Mirrors `validateTimeout`.
406fn validate_timeout(timeout: Option<f64>) -> Result<(), AgentError> {
407    let Some(secs) = timeout else {
408        return Ok(());
409    };
410    if !secs.is_finite() || secs <= 0.0 {
411        return Err(AgentError::Tool(
412            "Invalid timeout: must be a finite number of seconds".into(),
413        ));
414    }
415    let max = 2_147_483_647.0 / 1000.0;
416    if secs > max {
417        return Err(AgentError::Tool(format!(
418            "Invalid timeout: maximum is {max} seconds"
419        )));
420    }
421    Ok(())
422}
423
424fn exec_err_to_agent(e: ExecutionError) -> AgentError {
425    AgentError::Tool(e.to_string())
426}