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