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. 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 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 let capture = execute_shell_with_capture(&env, &command, capture_opts)
235 .await
236 .map_err(exec_err_to_agent)?;
237
238 {
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 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 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 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
305struct 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
314fn 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
330fn 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
355fn 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
404fn 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}