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 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 let capture = execute_shell_with_capture(&env, &command, capture_opts)
196 .await
197 .map_err(exec_err_to_agent)?;
198
199 {
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 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 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
260struct 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
269fn 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
285fn 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
310fn 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
359fn 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}