Skip to main content

pi/core/agent_session/
bash.rs

1//! Product bash execution impls.
2//!
3//! Ports `.references/pi/packages/coding-agent/src/core/agent-session.ts`
4//! `executeBash`, `recordBashResult`, `abortBash`, `isBashRunning`,
5//! `hasPendingBashMessages`, and `_flushPendingBashMessages`.
6//!
7//! Behaviour preserved from the TypeScript contract:
8//! - `execute_bash` prepends `settings.shellCommandPrefix` (when set), runs
9//!   through the configured `BashOperations`, streams output chunks through
10//!   `on_chunk`, and records a `BashExecutionMessage` entry on the session.
11//! - `exclude_from_context: true` (`!!` prefix) records the entry with the
12//!   flag set so [`crate::core::messages`] drops it from LLM context.
13//! - While the agent is streaming, recorded bash messages are queued and
14//!   flushed on `agent_end` so `tool_use` / `tool_result` ordering is preserved.
15//! - `abort_bash` cancels the in-flight token; the next call can start a new
16//!   command.
17//!
18//! Lock order: never hold `AgentSessionInner` across `.await`. The session
19//! manager async mutex is acquired for append-only persistence.
20
21use 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/// Result of a product bash execution (TypeScript `BashResult`).
33///
34/// Mirrors the wire shape used by the `bash` RPC response and by
35/// `BashExecutionMessage` persistence. Defined here (rather than re-exported
36/// from `modes/rpc/types.rs`) so the agent-session slice owns the canonical
37/// product shape without creating a `modes → core` dependency.
38#[derive(Clone, Debug, PartialEq)]
39pub struct BashResult {
40    /// Combined stdout + stderr (sanitized, possibly truncated).
41    pub output: String,
42    /// Process exit code (`None` when killed/cancelled).
43    pub exit_code: Option<i32>,
44    /// Whether the command was aborted before completion.
45    pub cancelled: bool,
46    /// Whether `output` is a truncated view of the full stream.
47    pub truncated: bool,
48    /// Spill-file path captured when `truncated` is true.
49    pub full_output_path: Option<String>,
50}
51
52/// Errors produced by [`AgentSession::execute_bash`].
53#[derive(Debug, thiserror::Error)]
54pub enum BashExecError {
55    /// Underlying bash execution failure (non-zero exit, abort, or timeout).
56    /// Carries the formatted output and parsed result so callers can record
57    /// the attempt just like the TypeScript reference does.
58    #[error("{message}")]
59    Execution {
60        /// Human-readable error text (mirrors `ToolError::message`).
61        message: String,
62        /// Parsed bash result for session persistence.
63        result: BashResult,
64    },
65    /// Session persistence failure.
66    #[error(transparent)]
67    Session(#[from] crate::core::sessions::SessionError),
68}
69
70/// Options accepted by [`AgentSession::execute_bash`].
71#[derive(Clone, Default)]
72pub struct ExecuteBashOptions {
73    /// When `true`, the recorded message is excluded from LLM context
74    /// (TypeScript `!!` prefix).
75    pub exclude_from_context: bool,
76    /// Custom operations backend (TypeScript `BashOperations`). Defaults to
77    /// local shell execution using the settings-configured `shellPath`.
78    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    /// Execute a bash command, stream output, and persist the result.
92    ///
93    /// The command runs in [`AgentSession::cwd`] resolved against the
94    /// settings-configured shell path and command prefix. `on_chunk` receives
95    /// merged stdout/stderr chunks as UTF-8 strings as they arrive.
96    ///
97    /// On non-zero exit / abort / timeout the returned `Err` carries the
98    /// parsed [`BashResult`] so the caller can still inspect the output.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`BashExecError::Execution`] when the command fails, or
103    /// [`BashExecError::Session`] when persistence fails.
104    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        // Persist + record (may queue if agent is streaming).
151        self.record_bash_result(command, parsed.clone(), &options)
152            .await?;
153        Ok(parsed)
154    }
155
156    /// Record a bash result in session history.
157    ///
158    /// Used by [`Self::execute_bash`] and by extensions that handle bash
159    /// execution themselves. While the agent is streaming, the entry is
160    /// queued and flushed on `agent_end` to preserve `tool_use` / `tool_result`
161    /// ordering.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`BashExecError::Session`] when persistence fails on the
166    /// immediate (non-deferred) path.
167    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        // Serialize a bash-execution custom message (role="bashExecution").
188        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            // Defer to agent_end.
195            let mut inner = self.lock_inner();
196            inner.pending_bash_messages.push(message);
197            return Ok(());
198        }
199
200        // Persist immediately.
201        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    /// Whether there are pending bash messages waiting to be flushed.
211    #[must_use]
212    pub fn has_pending_bash_messages(&self) -> bool {
213        !self.lock_inner().pending_bash_messages.is_empty()
214    }
215
216    /// Append pending bash messages in order, removing each only after its
217    /// session append succeeds.
218    ///
219    /// Called by the pump after `agent_end` (TypeScript `_flushPendingBashMessages`).
220    ///
221    /// # Errors
222    ///
223    /// Returns [`BashExecError::Session`] on the first persistence failure.
224    /// The failed message and every unattempted message remain queued in their
225    /// original order so a later flush can retry without loss.
226    pub async fn flush_pending_bash_messages(&self) -> Result<(), BashExecError> {
227        // Serialize flush attempts without using the session-manager lock as the
228        // serializer: lock order forbids taking `inner` while manager is held.
229        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
261/// Run a bash command through a `BashTool`, capturing the parsed result.
262///
263/// The tool wires `on_chunk` through `ToolUpdates` partial snapshots and
264/// returns the formatted output / exit code / cancellation / spill metadata.
265async 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    // Build the args map the BashTool expects (`BashToolInput`).
286    let mut args = serde_json::Map::new();
287    args.insert("command".to_owned(), serde_json::Value::String(command));
288
289    // ToolUpdates sink: forward partial text to on_chunk. The BashTool emits
290    // snapshots on a 100ms throttle, so this is naturally backpressured.
291    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    // Drain any straggling partial snapshots before computing the final result.
298    while rx.recv().await.is_some() {}
299
300    let agent_result = result?;
301    Ok(parse_bash_result_from_agent_result(agent_result))
302}
303
304/// Convert a successful `AgentToolResult` into a `BashResult`.
305fn 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    // Success implies exit code 0; non-zero exits propagate as ToolError.
326    BashResult {
327        output,
328        exit_code: Some(0),
329        cancelled: false,
330        truncated,
331        full_output_path,
332    }
333}
334
335/// Convert a `ToolError` message into a `BashResult`.
336///
337/// Recognizes `"Command exited with code N"`, the `"aborted"` sentinel, and
338/// the `"timeout:N"` sentinel produced by the local `BashOperations`.
339fn 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
357/// Strip the trailing `Command ...` status line that the `BashTool` appends so
358/// the recorded `output` field matches the raw stream.
359fn 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
372/// Serialize a [`BashExecutionMessage`] into a custom-agent-message payload.
373fn 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
412/// Build a `ToolUpdates` channel that forwards partial text to `on_chunk`.
413fn 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        // Re-store the callback so future partials can use it.
440        if let Ok(mut guard) = callback.lock() {
441            *guard = Some(cb);
442        }
443        // Notify the receiver that a partial arrived. Best-effort send: if the
444        // channel is full we drop the signal because the receiver only needs
445        // to know *that* activity happened, not how many chunks.
446        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}