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. 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            cwd: Some(cwd),
225            env: Some(execution.env),
226            inherit_env: execution.inherit_env,
227            timeout,
228            cancel: Some(&signal),
229            on_chunk: Some(on_chunk),
230            return_execution_errors: true,
231        };
232
233        // execute_shell_with_capture wants `&Arc<dyn ExecutionEnv>`.
234        let capture = execute_shell_with_capture(&env, &command, capture_opts)
235            .await
236            .map_err(exec_err_to_agent)?;
237
238        // Final flush + emit the terminal output via on_update.
239        {
240            let mut st = throttle.lock().await;
241            st.latest_output = capture.output.clone();
242            st.latest_truncation = if capture.truncated {
243                Some(capture.truncation.clone())
244            } else {
245                None
246            };
247            st.latest_full_path = capture
248                .full_output_path
249                .as_ref()
250                .map(|p| p.to_string_lossy().into_owned());
251            st.dirty = true;
252            flush_now(&mut st, &on_update);
253        }
254
255        // Build the terminal output text + details, applying truncation notices.
256        let (output_text, details) = build_bash_output(&capture);
257        let details_value = serde_json::to_value(details).unwrap_or(serde_json::Value::Null);
258
259        // Resolution: cancelled → aborted; timeout → timeout; other exec error;
260        // non-zero exit → Tool error. All become Err(AgentError).
261        let append_status = |status: String| -> String {
262            if output_text.is_empty() {
263                status
264            } else {
265                format!("{output_text}\n\n{status}")
266            }
267        };
268        if capture.cancelled {
269            return Err(AgentError::Tool(append_status("Command aborted".into())));
270        }
271        if let Some(ee) = &capture.execution_error {
272            if ee.code == ExecutionErrorCode::Timeout {
273                // `timeout` is `Option<f64>` in seconds; `{timeout:?}` used to render
274                // `Some(2.0)`, so the message read "timed out after Some(2.0) seconds".
275                let limit = match timeout {
276                    Some(secs) => format!("after {secs}s"),
277                    None => "".to_string(),
278                };
279                return Err(AgentError::Tool(append_status(format!(
280                    "Command timed out {limit}"
281                ))));
282            }
283            return Err(AgentError::Tool(ee.to_string()));
284        }
285        if let Some(code) = capture.exit_code {
286            if code != 0 {
287                return Err(AgentError::Tool(append_status(format!(
288                    "Command exited with code {code}"
289                ))));
290            }
291        }
292        let text = if output_text.is_empty() {
293            "(no output)".to_string()
294        } else {
295            output_text
296        };
297        Ok(AgentToolResult {
298            content: vec![TextContentOrImage::text(text)],
299            details: details_value,
300            ..Default::default()
301        })
302    }
303}
304
305/// Throttle state, shared between the on_chunk callback and the final flush.
306struct ThrottleState {
307    dirty: bool,
308    last_flush: Instant,
309    latest_output: String,
310    latest_truncation: Option<TruncationResult>,
311    latest_full_path: Option<String>,
312}
313
314/// Flush a dirty throttle state by emitting an `on_update`. Mirrors
315/// `emitOutputUpdate`.
316fn flush_now(st: &mut ThrottleState, on_update: &Arc<dyn Fn(ToolResultPartial) + Send + Sync>) {
317    if !st.dirty {
318        return;
319    }
320    st.dirty = false;
321    st.last_flush = Instant::now();
322    emit_update(
323        on_update,
324        st.latest_output.clone(),
325        st.latest_truncation.clone(),
326        st.latest_full_path.clone(),
327    );
328}
329
330/// Emit a single `on_update` payload built from the given capture snapshot.
331/// Mirrors the TS `onUpdate({ content:[{text:progress.output}], details:{...} })`.
332fn emit_update(
333    on_update: &Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
334    output: String,
335    truncation: Option<TruncationResult>,
336    full_output_path: Option<String>,
337) {
338    let details = match (&truncation, &full_output_path) {
339        (None, None) => serde_json::Value::Null,
340        _ => {
341            let details = BashToolDetails {
342                truncation,
343                full_output_path,
344            };
345            serde_json::to_value(details).unwrap_or(serde_json::Value::Null)
346        }
347    };
348    on_update(AgentToolResult {
349        content: vec![TextContentOrImage::text(output)],
350        details,
351        ..Default::default()
352    });
353}
354
355/// Build the terminal `(output_text, details)` from a capture, appending the
356/// truncation continuation notice (mirrors the TS block).
357fn build_bash_output(capture: &ShellCaptureResult) -> (String, BashToolDetails) {
358    let mut output_text = capture.output.clone();
359    let details = if capture.truncated {
360        let t = &capture.truncation;
361        let start_line = t.total_lines.saturating_sub(t.output_lines) + 1;
362        let end_line = t.total_lines;
363        let path_str = capture
364            .full_output_path
365            .as_ref()
366            .map(|p| p.to_string_lossy().into_owned());
367        if t.last_line_partial {
368            let last_line_size = format_size(capture.last_line_bytes);
369            output_text.push_str(&format!(
370                "\n\n[Showing last {} of line {end_line} (line is {last_line_size}). Full output: {}]",
371                format_size(t.output_bytes),
372                path_str.as_deref().unwrap_or("?")
373            ));
374        } else if matches!(
375            t.truncated_by,
376            Some(crate::truncate::TruncationLimit::Lines)
377        ) {
378            output_text.push_str(&format!(
379                "\n\n[Showing lines {start_line}-{end_line} of {}. Full output: {}]",
380                t.total_lines,
381                path_str.as_deref().unwrap_or("?")
382            ));
383        } else {
384            output_text.push_str(&format!(
385                "\n\n[Showing lines {start_line}-{end_line} of {} ({} limit). Full output: {}]",
386                t.total_lines,
387                format_size(DEFAULT_MAX_BYTES),
388                path_str.as_deref().unwrap_or("?")
389            ));
390        }
391        BashToolDetails {
392            truncation: Some(capture.truncation.clone()),
393            full_output_path: path_str,
394        }
395    } else {
396        BashToolDetails {
397            truncation: None,
398            full_output_path: None,
399        }
400    };
401    (output_text, details)
402}
403
404/// Validate a timeout (seconds). Mirrors `validateTimeout`.
405fn validate_timeout(timeout: Option<f64>) -> Result<(), AgentError> {
406    let Some(secs) = timeout else {
407        return Ok(());
408    };
409    if !secs.is_finite() || secs <= 0.0 {
410        return Err(AgentError::Tool(
411            "Invalid timeout: must be a finite number of seconds".into(),
412        ));
413    }
414    let max = 2_147_483_647.0 / 1000.0;
415    if secs > max {
416        return Err(AgentError::Tool(format!(
417            "Invalid timeout: maximum is {max} seconds"
418        )));
419    }
420    Ok(())
421}
422
423fn exec_err_to_agent(e: ExecutionError) -> AgentError {
424    AgentError::Tool(e.to_string())
425}