Skip to main content

oino_tools/bash/
tool.rs

1//! The `bash` and `powershell` tools.
2
3use futures::FutureExt;
4use futures::future::BoxFuture;
5use oino_agent::{
6    AgentTool, AgentToolResult, ToolDefinitionMeta, ToolError, ToolInvocation, UpdateSink,
7};
8use oino_ai::{JsonValue, UserContent};
9use serde_json::{Map, json};
10use tokio::time::Instant;
11use tokio_util::sync::CancellationToken;
12
13use super::accumulator::{OutputAccumulator, OutputSnapshot};
14use super::detach::DetachedProcess;
15use super::exec::{ExecEnd, ExecError, ExecRequest, OutputSink, ShellSpec, exec};
16use super::shell::{shell_env, tool_env};
17use super::temp_dir;
18use crate::config::ToolConfig;
19use crate::info::{ShellKind, shell_info};
20use crate::truncate::{DEFAULT_MAX_BYTES, TruncatedBy, format_size};
21use crate::{args, js_number};
22
23/// Update throttle.
24pub const BASH_UPDATE_THROTTLE: std::time::Duration = std::time::Duration::from_millis(100);
25/// Limit of `structuredContent.output`.
26const STRUCTURED_OUTPUT_MAX_BYTES: u64 = 1024 * 1024;
27/// Prepended to PowerShell commands.
28const POWERSHELL_UTF8_PREFIX: &str =
29    "try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}\n";
30
31/// A shell tool: `bash`, or `powershell` on Windows.
32#[derive(Debug)]
33pub struct ShellTool {
34    meta: ToolDefinitionMeta,
35    kind: ShellKind,
36    config: ToolConfig,
37}
38
39impl ShellTool {
40    /// The `bash` tool.
41    #[must_use]
42    pub fn bash(config: ToolConfig) -> Self {
43        Self::new(ShellKind::Bash, config)
44    }
45
46    /// The `powershell` tool (Windows; elsewhere every call fails with a fixed message).
47    #[must_use]
48    pub fn powershell(config: ToolConfig) -> Self {
49        Self::new(ShellKind::PowerShell, config)
50    }
51
52    fn new(kind: ShellKind, config: ToolConfig) -> Self {
53        Self {
54            meta: shell_info(kind, config.shell.expose_session_environment).definition_meta(),
55            kind,
56            config,
57        }
58    }
59}
60
61impl AgentTool for ShellTool {
62    fn meta(&self) -> &ToolDefinitionMeta {
63        &self.meta
64    }
65
66    fn execute(
67        &self,
68        call: ToolInvocation,
69        updates: UpdateSink,
70        cancel: CancellationToken,
71    ) -> BoxFuture<'static, Result<AgentToolResult, ToolError>> {
72        let config = self.config.clone();
73        let kind = self.kind;
74        async move { run(&config, kind, &call, updates, &cancel).await }.boxed()
75    }
76}
77
78/// Feeds output into the accumulator and emits throttled updates.
79struct Streaming {
80    output: OutputAccumulator,
81    updates: UpdateSink,
82    dirty: bool,
83    last_update: Option<Instant>,
84    timer: Option<Instant>,
85}
86
87impl Streaming {
88    fn emit(&mut self) {
89        if !self.dirty {
90            return;
91        }
92        self.dirty = false;
93        self.last_update = Some(Instant::now());
94        let snapshot = self.output.snapshot(true);
95        let mut details = Map::new();
96        if snapshot.truncation.truncated {
97            details.insert("truncation".to_owned(), json!(snapshot.truncation));
98        }
99        if let Some(path) = &snapshot.full_output_path {
100            details.insert("fullOutputPath".to_owned(), json!(path));
101        }
102        self.updates.send(AgentToolResult {
103            content: vec![UserContent::text(snapshot.content)],
104            details: Some(JsonValue::Object(details)),
105            ..AgentToolResult::default()
106        });
107    }
108
109    fn finish(&mut self) -> Result<OutputSnapshot, ToolError> {
110        self.output.finish();
111        self.timer = None;
112        self.emit();
113        let snapshot = self.output.snapshot(true);
114        self.output
115            .close_temp_file()
116            .map_err(|error| ToolError::new(error.to_string()))?;
117        Ok(snapshot)
118    }
119}
120
121impl OutputSink for Streaming {
122    fn data(&mut self, chunk: &[u8]) {
123        self.output.append(chunk);
124        self.dirty = true;
125        let due = self
126            .last_update
127            .map_or_else(Instant::now, |last| last + BASH_UPDATE_THROTTLE);
128        if due <= Instant::now() {
129            self.timer = None;
130            self.emit();
131        } else {
132            self.timer.get_or_insert(due);
133        }
134    }
135
136    fn next_flush(&self) -> Option<Instant> {
137        self.timer
138    }
139
140    fn flush(&mut self) {
141        self.timer = None;
142        self.emit();
143    }
144}
145
146/// The visible text with the truncation footer, and `details`.
147fn format_output(
148    snapshot: &OutputSnapshot,
149    last_line_bytes: u64,
150    empty_text: &str,
151) -> (String, Option<JsonValue>) {
152    let truncation = &snapshot.truncation;
153    let mut text = if snapshot.content.is_empty() {
154        empty_text.to_owned()
155    } else {
156        snapshot.content.clone()
157    };
158    if !truncation.truncated {
159        return (text, None);
160    }
161    let path = snapshot.full_output_path.clone().unwrap_or_default();
162    let mut details = Map::new();
163    details.insert("truncation".to_owned(), json!(truncation));
164    if let Some(path) = &snapshot.full_output_path {
165        details.insert("fullOutputPath".to_owned(), json!(path));
166    }
167    let start = truncation.total_lines - truncation.output_lines + 1;
168    let end = truncation.total_lines;
169    let footer = if truncation.last_line_partial {
170        format!(
171            "\n\n[Showing last {} of line {end} (line is {}). Full output: {path}]",
172            format_size(truncation.output_bytes),
173            format_size(last_line_bytes)
174        )
175    } else if truncation.truncated_by == Some(TruncatedBy::Lines) {
176        format!(
177            "\n\n[Showing lines {start}-{end} of {}. Full output: {path}]",
178            truncation.total_lines
179        )
180    } else {
181        format!(
182            "\n\n[Showing lines {start}-{end} of {} ({} limit). Full output: {path}]",
183            truncation.total_lines,
184            format_size(DEFAULT_MAX_BYTES)
185        )
186    };
187    text.push_str(&footer);
188    (text, Some(JsonValue::Object(details)))
189}
190
191/// A p p e n d   S t a t u s.
192fn append_status(text: &str, status: &str) -> String {
193    if text.is_empty() {
194        status.to_owned()
195    } else {
196        format!("{text}\n\n{status}")
197    }
198}
199
200async fn run(
201    config: &ToolConfig,
202    kind: ShellKind,
203    call: &ToolInvocation,
204    updates: UpdateSink,
205    cancel: &CancellationToken,
206) -> Result<AgentToolResult, ToolError> {
207    let command = args::required_str(&call.args, "command")?;
208    let timeout = args::optional_f64(&call.args, "timeout");
209    let command = match (kind, config.shell.command_prefix.as_deref()) {
210        (ShellKind::PowerShell, _) => format!("{POWERSHELL_UTF8_PREFIX}{command}"),
211        (ShellKind::Bash, Some(prefix)) if !prefix.is_empty() => format!("{prefix}\n{command}"),
212        (ShellKind::Bash, _) => command.to_owned(),
213    };
214    let session = config
215        .shell
216        .expose_session_environment
217        .then(|| config.context.session_env())
218        .flatten();
219    let vars = tool_env(
220        shell_env(config.process_env.as_deref(), &config.agent_dir),
221        session.as_ref(),
222    );
223    let mut streaming = Streaming {
224        output: OutputAccumulator::new(temp_dir(&config.agent_dir), kind.temp_file_prefix()),
225        updates: updates.clone(),
226        dirty: false,
227        last_update: None,
228        timer: None,
229    };
230    updates.send(AgentToolResult::default());
231    let (_registration, mut detach) = config.bash_detach.register(&call.tool_call_id);
232    let started = std::time::Instant::now();
233    let request = ExecRequest {
234        env: config.env.as_ref(),
235        shell: match kind {
236            ShellKind::Bash => ShellSpec::Bash(config.shell.shell_path.as_deref()),
237            ShellKind::PowerShell => ShellSpec::PowerShell,
238        },
239        command,
240        cwd: config.effective_cwd(),
241        vars,
242        timeout,
243    };
244    let outcome = exec(request, &mut streaming, cancel, Some(&mut detach)).await;
245    let exit_code = match outcome {
246        Err(error) => {
247            let snapshot = streaming.finish()?;
248            let (text, _) = format_output(&snapshot, streaming.output.last_line_bytes(), "");
249            return Err(ToolError::new(match error {
250                ExecError::Aborted => append_status(&text, "Command aborted"),
251                ExecError::Timeout(seconds) => {
252                    append_status(&text, &format!("Command timed out after {seconds} seconds"))
253                }
254                ExecError::Failed(message) => message,
255            }));
256        }
257        Ok(ExecEnd::Detached(request, process)) => {
258            let snapshot = streaming.finish()?;
259            let (text, details) =
260                format_output(&snapshot, streaming.output.last_line_bytes(), "(no output)");
261            let marker = format!(
262                "\n\n[Detached to background task {}: {}]",
263                request.task_id, request.reason
264            );
265            let reply = DetachedProcess {
266                process,
267                output_so_far: text.clone(),
268                full_output_path: snapshot.full_output_path,
269            };
270            // A dropped requester simply lets the process run unobserved.
271            let _ = request.reply.send(reply);
272            return Ok(AgentToolResult {
273                details,
274                ..AgentToolResult::text(format!("{text}{marker}"))
275            });
276        }
277        Ok(ExecEnd::Exited(code)) => code,
278    };
279    exited(&mut streaming, exit_code, started)
280}
281
282/// The result of a command that exited (the success / non-zero-exit branch).
283fn exited(
284    streaming: &mut Streaming,
285    exit_code: i32,
286    started: std::time::Instant,
287) -> Result<AgentToolResult, ToolError> {
288    let snapshot = streaming.finish()?;
289    let (text, details) =
290        format_output(&snapshot, streaming.output.last_line_bytes(), "(no output)");
291    let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
292    let wall_time_seconds = (elapsed_ms / 100.0).round() / 10.0;
293    let full = streaming
294        .output
295        .read_full_output(STRUCTURED_OUTPUT_MAX_BYTES)
296        .map_err(|error| ToolError::new(error.to_string()))?;
297    let mut structured = Map::new();
298    structured.insert("output".to_owned(), json!(full.content));
299    structured.insert("truncated".to_owned(), json!(full.truncated));
300    if full.truncated
301        && let Some(path) = &snapshot.full_output_path
302    {
303        structured.insert("full_output_path".to_owned(), json!(path));
304    }
305    structured.insert("exit_code".to_owned(), json!(exit_code));
306    structured.insert("wall_time_seconds".to_owned(), js_number(wall_time_seconds));
307    let is_error = exit_code != 0;
308    let text = if is_error {
309        append_status(&text, &format!("Command exited with code {exit_code}"))
310    } else {
311        text
312    };
313    Ok(AgentToolResult {
314        content: vec![UserContent::text(text)],
315        details,
316        structured_content: Some(JsonValue::Object(structured)),
317        is_error,
318        ..AgentToolResult::default()
319    })
320}