1use 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
23pub const BASH_UPDATE_THROTTLE: std::time::Duration = std::time::Duration::from_millis(100);
25const STRUCTURED_OUTPUT_MAX_BYTES: u64 = 1024 * 1024;
27const POWERSHELL_UTF8_PREFIX: &str =
29 "try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}\n";
30
31#[derive(Debug)]
33pub struct ShellTool {
34 meta: ToolDefinitionMeta,
35 kind: ShellKind,
36 config: ToolConfig,
37}
38
39impl ShellTool {
40 #[must_use]
42 pub fn bash(config: ToolConfig) -> Self {
43 Self::new(ShellKind::Bash, config)
44 }
45
46 #[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
78struct 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
146fn 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
191fn 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 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
282fn 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}