Skip to main content

oino_core/session/
bash.rs

1//! `!` command execution (`executeBash`, `recordBashResult`, bash-executor.ts).
2
3use oino_ai::{AgentMessage, BashExecutionMessage};
4use serde::{Deserialize, Serialize};
5use serde_json::json;
6use tokio_util::sync::CancellationToken;
7
8use super::{AgentSession, SessionOpError, lock};
9use crate::events::AgentSessionEvent;
10
11/// The bash result record.
12#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub struct BashResult {
15    /// Combined output (possibly truncated).
16    pub output: String,
17    /// Exit code (`None` when killed).
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub exit_code: Option<i32>,
20    /// Cancelled by the user.
21    pub cancelled: bool,
22    /// Output was truncated.
23    pub truncated: bool,
24    /// Full output file when truncated.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub full_output_path: Option<String>,
27}
28
29impl From<oino_tools::BashOutcome> for BashResult {
30    fn from(outcome: oino_tools::BashOutcome) -> Self {
31        Self {
32            output: outcome.output,
33            exit_code: outcome.exit_code,
34            cancelled: outcome.cancelled,
35            truncated: outcome.truncated,
36            full_output_path: outcome.full_output_path,
37        }
38    }
39}
40
41impl AgentSession {
42    /// `user_bash` handlers first (`{result}` short-circuits), then the
43    /// configured shell with `shellCommandPrefix`; output streams as `bash_execution_update`
44    /// and the result is recorded as a `bashExecution` message (`!!` excludes it from context).
45    ///
46    /// # Errors
47    ///
48    /// A failing `user_bash` handler or a command that could not run.
49    pub async fn execute_bash(
50        &self,
51        command: String,
52        exclude_from_context: bool,
53        id: Option<String>,
54    ) -> Result<BashResult, SessionOpError> {
55        let runner = self.runner();
56        if runner.has_handlers("user_bash") {
57            let payload = json!({"type": "user_bash", "command": command, "excludeFromContext": exclude_from_context, "cwd": self.cwd().display().to_string()});
58            match runner.emit_user_bash(payload).await {
59                Ok(Some(result)) => {
60                    let result: BashResult = serde_json::from_value(result)
61                        .map_err(|e| SessionOpError::Failed(e.to_string()))?;
62                    self.record_bash_result(&command, &result, exclude_from_context);
63                    return Ok(result);
64                }
65                Ok(None) => {}
66                Err(error) => return Err(SessionOpError::Failed(error)),
67            }
68        }
69        let settings = lock(&self.inner.settings).snapshot();
70        let resolved = match settings.shell_command_prefix() {
71            Some(prefix) if !prefix.is_empty() => format!("{prefix}\n{command}"),
72            _ => command.clone(),
73        };
74        let options = oino_tools::ShellRunOptions {
75            shell_path: settings.shell_path().map(|p| p.display().to_string()),
76            agent_dir: self.agent_dir().display().to_string(),
77            process_env: None,
78        };
79        let cancel = CancellationToken::new();
80        let slot = {
81            let mut cancels = lock(&self.inner.cancels);
82            cancels.next_bash += 1;
83            let slot = cancels.next_bash;
84            cancels.bash.push((slot, cancel.clone()));
85            slot
86        };
87        let session = self.clone();
88        let mut on_chunk = move |delta: &str| {
89            session.emit(AgentSessionEvent::BashExecutionUpdate {
90                id: id.clone(),
91                delta: delta.to_owned(),
92            });
93        };
94        let env = oino_tools::LocalEnv::new();
95        let cwd = self.cwd().display().to_string();
96        let outcome =
97            oino_tools::run_shell_command(&env, &resolved, &cwd, &options, &mut on_chunk, &cancel)
98                .await;
99        lock(&self.inner.cancels).bash.retain(|(id, _)| *id != slot);
100        let result: BashResult = outcome.map_err(|e| SessionOpError::Failed(e.0))?.into();
101        self.record_bash_result(&command, &result, exclude_from_context);
102        Ok(result)
103    }
104
105    /// Records a `bashExecution` message (deferred while streaming).
106    pub fn record_bash_result(
107        &self,
108        command: &str,
109        result: &BashResult,
110        exclude_from_context: bool,
111    ) {
112        let message = BashExecutionMessage {
113            command: command.to_owned(),
114            output: result.output.clone(),
115            exit_code: result.exit_code,
116            cancelled: result.cancelled,
117            truncated: result.truncated,
118            full_output_path: result.full_output_path.clone(),
119            timestamp: oino_agent::now_ms(),
120            exclude_from_context: exclude_from_context.then_some(true),
121        };
122        if self.is_streaming() {
123            lock(&self.inner.book).pending_bash.push(message);
124            return;
125        }
126        if let Err(error) =
127            lock(&self.inner.manager).append_message(AgentMessage::BashExecution(message))
128        {
129            tracing::error!("persisting bash execution failed: {error}");
130        }
131        self.refresh_context();
132    }
133
134    /// F l u s h   P e n d i n g   B a s h   M e s s a g e s.
135    pub(crate) fn flush_pending_bash_messages(&self) {
136        let pending = std::mem::take(&mut lock(&self.inner.book).pending_bash);
137        if pending.is_empty() {
138            return;
139        }
140        {
141            let mut manager = lock(&self.inner.manager);
142            for message in pending {
143                if let Err(error) = manager.append_message(AgentMessage::BashExecution(message)) {
144                    tracing::error!("persisting bash execution failed: {error}");
145                }
146            }
147        }
148        self.refresh_context();
149    }
150}