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                if elapsed >= Duration::from_millis(BASH_UPDATE_THROTTLE_MS) {
177                    flush_now(&mut st, &on_update_for_cb);
178                }
179            },
180        );
181
182        let cwd = self.env.cwd().to_path_buf();
183        let env = self.env.clone();
184        let capture_opts = ShellCaptureOptions {
185            cwd: Some(cwd),
186            env: None,
187            inherit_env: true,
188            timeout,
189            cancel: Some(&signal),
190            on_chunk: Some(on_chunk),
191            return_execution_errors: true,
192        };
193
194        // execute_shell_with_capture wants `&Arc<dyn ExecutionEnv>`.
195        let capture = execute_shell_with_capture(&env, &command, capture_opts)
196            .await
197            .map_err(exec_err_to_agent)?;
198
199        // Final flush + emit the terminal output via on_update.
200        {
201            let mut st = throttle.lock().await;
202            st.latest_output = capture.output.clone();
203            st.latest_truncation = if capture.truncated {
204                Some(capture.truncation.clone())
205            } else {
206                None
207            };
208            st.latest_full_path = capture
209                .full_output_path
210                .as_ref()
211                .map(|p| p.to_string_lossy().into_owned());
212            st.dirty = true;
213            flush_now(&mut st, &on_update);
214        }
215
216        // Build the terminal output text + details, applying truncation notices.
217        let (output_text, details) = build_bash_output(&capture);
218        let details_value = serde_json::to_value(details).unwrap_or(serde_json::Value::Null);
219
220        // Resolution: cancelled → aborted; timeout → timeout; other exec error;
221        // non-zero exit → Tool error. All become Err(AgentError).
222        let append_status = |status: String| -> String {
223            if output_text.is_empty() {
224                status
225            } else {
226                format!("{output_text}\n\n{status}")
227            }
228        };
229        if capture.cancelled {
230            return Err(AgentError::Tool(append_status("Command aborted".into())));
231        }
232        if let Some(ee) = &capture.execution_error {
233            if ee.code == ExecutionErrorCode::Timeout {
234                return Err(AgentError::Tool(append_status(format!(
235                    "Command timed out after {timeout:?} seconds"
236                ))));
237            }
238            return Err(AgentError::Tool(ee.to_string()));
239        }
240        if let Some(code) = capture.exit_code {
241            if code != 0 {
242                return Err(AgentError::Tool(append_status(format!(
243                    "Command exited with code {code}"
244                ))));
245            }
246        }
247        let text = if output_text.is_empty() {
248            "(no output)".to_string()
249        } else {
250            output_text
251        };
252        Ok(AgentToolResult {
253            content: vec![TextContentOrImage::text(text)],
254            details: details_value,
255            ..Default::default()
256        })
257    }
258}
259
260/// Throttle state, shared between the on_chunk callback and the final flush.
261struct ThrottleState {
262    dirty: bool,
263    last_flush: Instant,
264    latest_output: String,
265    latest_truncation: Option<TruncationResult>,
266    latest_full_path: Option<String>,
267}
268
269/// Flush a dirty throttle state by emitting an `on_update`. Mirrors
270/// `emitOutputUpdate`.
271fn flush_now(st: &mut ThrottleState, on_update: &Arc<dyn Fn(ToolResultPartial) + Send + Sync>) {
272    if !st.dirty {
273        return;
274    }
275    st.dirty = false;
276    st.last_flush = Instant::now();
277    emit_update(
278        on_update,
279        st.latest_output.clone(),
280        st.latest_truncation.clone(),
281        st.latest_full_path.clone(),
282    );
283}
284
285/// Emit a single `on_update` payload built from the given capture snapshot.
286/// Mirrors the TS `onUpdate({ content:[{text:progress.output}], details:{...} })`.
287fn emit_update(
288    on_update: &Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
289    output: String,
290    truncation: Option<TruncationResult>,
291    full_output_path: Option<String>,
292) {
293    let details = match (&truncation, &full_output_path) {
294        (None, None) => serde_json::Value::Null,
295        _ => {
296            let details = BashToolDetails {
297                truncation,
298                full_output_path,
299            };
300            serde_json::to_value(details).unwrap_or(serde_json::Value::Null)
301        }
302    };
303    on_update(AgentToolResult {
304        content: vec![TextContentOrImage::text(output)],
305        details,
306        ..Default::default()
307    });
308}
309
310/// Build the terminal `(output_text, details)` from a capture, appending the
311/// truncation continuation notice (mirrors the TS block).
312fn build_bash_output(capture: &ShellCaptureResult) -> (String, BashToolDetails) {
313    let mut output_text = capture.output.clone();
314    let details = if capture.truncated {
315        let t = &capture.truncation;
316        let start_line = t.total_lines.saturating_sub(t.output_lines) + 1;
317        let end_line = t.total_lines;
318        let path_str = capture
319            .full_output_path
320            .as_ref()
321            .map(|p| p.to_string_lossy().into_owned());
322        if t.last_line_partial {
323            let last_line_size = format_size(capture.last_line_bytes);
324            output_text.push_str(&format!(
325                "\n\n[Showing last {} of line {end_line} (line is {last_line_size}). Full output: {}]",
326                format_size(t.output_bytes),
327                path_str.as_deref().unwrap_or("?")
328            ));
329        } else if matches!(
330            t.truncated_by,
331            Some(crate::truncate::TruncationLimit::Lines)
332        ) {
333            output_text.push_str(&format!(
334                "\n\n[Showing lines {start_line}-{end_line} of {}. Full output: {}]",
335                t.total_lines,
336                path_str.as_deref().unwrap_or("?")
337            ));
338        } else {
339            output_text.push_str(&format!(
340                "\n\n[Showing lines {start_line}-{end_line} of {} ({} limit). Full output: {}]",
341                t.total_lines,
342                format_size(DEFAULT_MAX_BYTES),
343                path_str.as_deref().unwrap_or("?")
344            ));
345        }
346        BashToolDetails {
347            truncation: Some(capture.truncation.clone()),
348            full_output_path: path_str,
349        }
350    } else {
351        BashToolDetails {
352            truncation: None,
353            full_output_path: None,
354        }
355    };
356    (output_text, details)
357}
358
359/// Validate a timeout (seconds). Mirrors `validateTimeout`.
360fn validate_timeout(timeout: Option<f64>) -> Result<(), AgentError> {
361    let Some(secs) = timeout else {
362        return Ok(());
363    };
364    if !secs.is_finite() || secs <= 0.0 {
365        return Err(AgentError::Tool(
366            "Invalid timeout: must be a finite number of seconds".into(),
367        ));
368    }
369    let max = 2_147_483_647.0 / 1000.0;
370    if secs > max {
371        return Err(AgentError::Tool(format!(
372            "Invalid timeout: maximum is {max} seconds"
373        )));
374    }
375    Ok(())
376}
377
378fn exec_err_to_agent(e: ExecutionError) -> AgentError {
379    AgentError::Tool(e.to_string())
380}