1use 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
32const BASH_UPDATE_THROTTLE_MS: u64 = 100;
34
35#[derive(Debug, Clone, JsonSchema, Deserialize)]
37pub struct BashInput {
38 pub command: String,
40 #[serde(default)]
42 pub timeout: Option<f64>,
43}
44
45#[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#[derive(Debug, Clone, Default)]
56pub struct BashToolOptions {
57 pub command_prefix: Option<String>,
59 pub default_timeout: Option<f64>,
64}
65
66pub 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
96pub 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 emit_update(&on_update, String::new(), None, None);
135
136 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 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, };
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 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 let capture = execute_shell_with_capture(&env, &command, capture_opts)
207 .await
208 .map_err(exec_err_to_agent)?;
209
210 {
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 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 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
271struct 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
280fn 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
296fn 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
321fn 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
370fn 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}