1use std::path::PathBuf;
22use std::sync::Arc;
23
24use pi_agent::{AgentMessage, AgentTool};
25use tokio_util::sync::CancellationToken;
26
27use crate::core::messages::{BashExecutionFields, BashExecutionMessage};
28
29use crate::core::tools::bash::{BashOperations, BashTool, BashToolOptions};
30
31use super::AgentSession;
32#[derive(Clone, Debug, PartialEq)]
39pub struct BashResult {
40 pub output: String,
42 pub exit_code: Option<i32>,
44 pub cancelled: bool,
46 pub truncated: bool,
48 pub full_output_path: Option<String>,
50}
51
52#[derive(Debug, thiserror::Error)]
54pub enum BashExecError {
55 #[error("{message}")]
59 Execution {
60 message: String,
62 result: BashResult,
64 },
65 #[error(transparent)]
67 Session(#[from] crate::core::sessions::SessionError),
68}
69
70#[derive(Clone, Default)]
72pub struct ExecuteBashOptions {
73 pub exclude_from_context: bool,
76 pub operations: Option<Arc<dyn BashOperations>>,
79}
80
81impl std::fmt::Debug for ExecuteBashOptions {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.debug_struct("ExecuteBashOptions")
84 .field("exclude_from_context", &self.exclude_from_context)
85 .field("operations", &self.operations.as_ref().map(|_| "Some(..)"))
86 .finish()
87 }
88}
89
90impl AgentSession {
91 pub async fn execute_bash<F>(
105 &self,
106 command: &str,
107 on_chunk: Option<F>,
108 options: ExecuteBashOptions,
109 ) -> Result<BashResult, BashExecError>
110 where
111 F: FnMut(&str) + Send + 'static,
112 {
113 let token = self.begin_bash_abort();
114 let (prefix, shell_path) = {
115 let settings = self.lock_settings();
116 (
117 settings.get_shell_command_prefix(),
118 settings.get_shell_path(),
119 )
120 };
121 let resolved = match prefix {
122 Some(prefix) if !prefix.is_empty() => format!("{prefix}\n{command}"),
123 _ => command.to_owned(),
124 };
125
126 let result = run_bash(
127 self.cwd.clone(),
128 resolved.clone(),
129 shell_path,
130 options.operations.clone(),
131 on_chunk,
132 token.clone(),
133 )
134 .await;
135 self.clear_bash_abort();
136
137 let parsed = match result {
138 Ok(parsed) => parsed,
139 Err(err) => {
140 let message = err.to_string();
141 let parsed = parse_bash_result_from_error(&message, &resolved);
142 let err = BashExecError::Execution {
143 message,
144 result: parsed,
145 };
146 return Err(err);
147 }
148 };
149
150 self.record_bash_result(command, parsed.clone(), &options)
152 .await?;
153 Ok(parsed)
154 }
155
156 pub async fn record_bash_result(
168 &self,
169 command: &str,
170 result: BashResult,
171 options: &ExecuteBashOptions,
172 ) -> Result<(), BashExecError> {
173 let message = BashExecutionMessage::from_fields(BashExecutionFields {
174 command: command.to_owned(),
175 output: result.output,
176 exit_code: result.exit_code.map(i64::from),
177 cancelled: result.cancelled,
178 truncated: result.truncated,
179 full_output_path: result.full_output_path,
180 timestamp: pi_agent::now_millis(),
181 exclude_from_context: if options.exclude_from_context {
182 Some(true)
183 } else {
184 None
185 },
186 });
187 let agent_message: AgentMessage = AgentMessage::Custom(pi_agent::CustomAgentMessage::new(
189 "bashExecution",
190 bash_execution_payload(&message),
191 ));
192
193 if self.is_streaming() {
194 let mut inner = self.lock_inner();
196 inner.pending_bash_messages.push(message);
197 return Ok(());
198 }
199
200 let mut manager = self.session_manager.lock().await;
202 let id = manager.append_message(&agent_message)?;
203 drop(manager);
204 if let Some(entry) = self.session_manager.lock().await.get_entry(&id).cloned() {
205 self.emit_public(super::events::AgentSessionEvent::EntryAppended { entry });
206 }
207 Ok(())
208 }
209
210 #[must_use]
212 pub fn has_pending_bash_messages(&self) -> bool {
213 !self.lock_inner().pending_bash_messages.is_empty()
214 }
215
216 pub async fn flush_pending_bash_messages(&self) -> Result<(), BashExecError> {
227 let _flush_guard = self.bash_flush_lock.lock().await;
230 loop {
231 let message = {
232 let inner = self.lock_inner();
233 inner.pending_bash_messages.first().cloned()
234 };
235 let Some(message) = message else {
236 return Ok(());
237 };
238 let agent_message: AgentMessage =
239 AgentMessage::Custom(pi_agent::CustomAgentMessage::new(
240 "bashExecution",
241 bash_execution_payload(&message),
242 ));
243 let entry = {
244 let mut manager = self.session_manager.lock().await;
245 let id = manager.append_message(&agent_message)?;
246 manager.get_entry(&id).cloned()
247 };
248 {
249 let mut inner = self.lock_inner();
250 if inner.pending_bash_messages.first() == Some(&message) {
251 inner.pending_bash_messages.remove(0);
252 }
253 }
254 if let Some(entry) = entry {
255 self.emit_public(super::events::AgentSessionEvent::EntryAppended { entry });
256 }
257 }
258 }
259}
260
261async fn run_bash<F>(
266 cwd: String,
267 command: String,
268 shell_path: Option<String>,
269 operations: Option<Arc<dyn BashOperations>>,
270 on_chunk: Option<F>,
271 cancel: CancellationToken,
272) -> Result<BashResult, pi_agent::ToolError>
273where
274 F: FnMut(&str) + Send + 'static,
275{
276 let mut options = BashToolOptions::new(PathBuf::from(&cwd));
277 if let Some(shell) = shell_path.filter(|s| !s.is_empty()) {
278 options.shell_path = Some(PathBuf::from(shell));
279 }
280 if let Some(operations) = operations {
281 options.operations = Some(operations);
282 }
283 let tool = BashTool::with_options(options);
284
285 let mut args = serde_json::Map::new();
287 args.insert("command".to_owned(), serde_json::Value::String(command));
288
289 let (updates, mut rx) = make_chunk_channel(on_chunk);
292
293 let result = tool
294 .execute("agent-session-bash", args, cancel, updates)
295 .await;
296
297 while rx.recv().await.is_some() {}
299
300 let agent_result = result?;
301 Ok(parse_bash_result_from_agent_result(agent_result))
302}
303
304fn parse_bash_result_from_agent_result(result: pi_agent::AgentToolResult) -> BashResult {
306 let output = result
307 .content
308 .into_iter()
309 .find_map(|block| match block {
310 pi_ai::ToolResultContent::Text(text) => Some(text.text.to_string()),
311 pi_ai::ToolResultContent::Image(_) => None,
312 })
313 .unwrap_or_default();
314 let details: serde_json::Value = result.details;
315 let truncation = details.get("truncation").cloned();
316 let full_output_path = details
317 .get("fullOutputPath")
318 .and_then(serde_json::Value::as_str)
319 .map(str::to_owned);
320 let truncated = truncation
321 .as_ref()
322 .and_then(|value| value.get("truncated"))
323 .and_then(serde_json::Value::as_bool)
324 .unwrap_or(false);
325 BashResult {
327 output,
328 exit_code: Some(0),
329 cancelled: false,
330 truncated,
331 full_output_path,
332 }
333}
334
335fn parse_bash_result_from_error(message: &str, _command: &str) -> BashResult {
340 let exit_code = message.find("Command exited with code ").and_then(|idx| {
341 let rest = &message[idx + "Command exited with code ".len()..];
342 rest.split_whitespace()
343 .next()
344 .and_then(|token| token.trim().parse::<i32>().ok())
345 });
346 let cancelled = message.contains("Command aborted");
347 let timed_out = message.contains("Command timed out");
348 BashResult {
349 output: strip_status_suffix(message),
350 exit_code,
351 cancelled,
352 truncated: timed_out,
353 full_output_path: None,
354 }
355}
356
357fn strip_status_suffix(message: &str) -> String {
360 if let Some(idx) = message.rfind("\n\nCommand exited with code ") {
361 return message[..idx].to_owned();
362 }
363 if let Some(idx) = message.rfind("\n\nCommand aborted") {
364 return message[..idx].to_owned();
365 }
366 if let Some(idx) = message.rfind("\n\nCommand timed out") {
367 return message[..idx].to_owned();
368 }
369 message.to_owned()
370}
371
372fn bash_execution_payload(
374 message: &BashExecutionMessage,
375) -> serde_json::Map<String, serde_json::Value> {
376 let mut payload = serde_json::Map::new();
377 payload.insert(
378 "command".to_owned(),
379 serde_json::Value::String(message.command.clone()),
380 );
381 payload.insert(
382 "output".to_owned(),
383 serde_json::Value::String(message.output.clone()),
384 );
385 if let Some(exit_code) = message.exit_code {
386 payload.insert("exitCode".to_owned(), serde_json::Value::from(exit_code));
387 }
388 payload.insert(
389 "cancelled".to_owned(),
390 serde_json::Value::Bool(message.cancelled),
391 );
392 payload.insert(
393 "truncated".to_owned(),
394 serde_json::Value::Bool(message.truncated),
395 );
396 if let Some(path) = message.full_output_path.clone() {
397 payload.insert("fullOutputPath".to_owned(), serde_json::Value::String(path));
398 }
399 payload.insert(
400 "timestamp".to_owned(),
401 serde_json::Value::from(message.timestamp),
402 );
403 if message.exclude_from_context.unwrap_or(false) {
404 payload.insert(
405 "excludeFromContext".to_owned(),
406 serde_json::Value::Bool(true),
407 );
408 }
409 payload
410}
411
412fn make_chunk_channel<F>(
414 on_chunk: Option<F>,
415) -> (pi_agent::ToolUpdates, tokio::sync::mpsc::Receiver<()>)
416where
417 F: FnMut(&str) + Send + 'static,
418{
419 use std::sync::Mutex;
420 let callback: Arc<Mutex<Option<F>>> = Arc::new(Mutex::new(on_chunk));
421 let (tx, rx) = tokio::sync::mpsc::channel::<()>(64);
422 let tx = Arc::new(Mutex::new(Some(tx)));
423 let updates = pi_agent::ToolUpdates::new(move |result| {
424 let text = result
425 .content
426 .iter()
427 .find_map(|block| match block {
428 pi_ai::ToolResultContent::Text(text) => Some(text.text.to_string()),
429 pi_ai::ToolResultContent::Image(_) => None,
430 })
431 .unwrap_or_default();
432 if text.is_empty() {
433 return;
434 }
435 let Some(mut cb) = callback.lock().ok().and_then(|mut g| g.take()) else {
436 return;
437 };
438 cb(&text);
439 if let Ok(mut guard) = callback.lock() {
441 *guard = Some(cb);
442 }
443 if let Ok(tx_guard) = tx.lock()
447 && let Some(tx) = tx_guard.as_ref()
448 {
449 let _ = tx.try_send(());
450 }
451 });
452 (updates, rx)
453}