1use 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
35const BASH_UPDATE_THROTTLE_MS: u64 = 2_000;
37
38#[derive(Debug, Clone, JsonSchema, Deserialize)]
40pub struct BashInput {
41 pub command: String,
43 #[serde(default)]
45 pub timeout: Option<f64>,
46}
47
48#[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#[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
66pub type BashPrepare =
69 Arc<dyn Fn(&mut BashExecution) -> BoxFuture<'static, Result<(), String>> + Send + Sync>;
70
71#[derive(Clone, Default)]
73pub struct BashToolOptions {
74 pub command_prefix: Option<String>,
76 pub default_timeout: Option<f64>,
81 pub prepare: Option<BashPrepare>,
83}
84
85pub 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
115pub 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 emit_update(&on_update, String::new(), None, None);
163
164 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 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, };
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 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 let capture = execute_shell_with_capture(&env, &command, capture_opts)
236 .await
237 .map_err(exec_err_to_agent)?;
238
239 {
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 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 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 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
306struct 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
315fn 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
331fn 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
356fn 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
405fn 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}