1use async_trait::async_trait;
3use bamboo_agent_core::{Tool, ToolCtx, ToolError, ToolOutcome, ToolResult};
4use serde::{Deserialize, Serialize};
5use serde_json::json;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{Arc, OnceLock};
9use tokio::io::{AsyncRead, AsyncReadExt};
10use tokio::sync::{watch, Mutex, Notify, OwnedSemaphorePermit, Semaphore};
11use tokio::time::{timeout, Duration, Instant};
12
13use super::{bash_runtime, workspace_state};
14
15const MAX_RETAINED_SESSIONS: usize = 64;
16const MAX_CAPTURE_BYTES: usize = 256 * 1024;
17const MAX_OUTPUT_TOKENS: usize = 8192;
18const DEFAULT_OUTPUT_TOKENS: usize = 2000;
19const MAX_COMMAND_BYTES: usize = 64 * 1024;
20const QUIET_POLL_ADVISORY_AFTER: Duration = Duration::from_secs(90);
22const QUIET_POLL_ADVISORY_READOUTS: usize = 3;
23const QUIET_POLL_DIAGNOSIS_READOUTS: usize = 6;
24const QUIET_POLL_ADVISORY: &str = "No new output across repeated polls. Do other useful work or check a command-owned progress signal before polling again. Quiet output or an existing report does not prove completion. Continue waiting when appropriate, or report the concrete blocker; retain this handle for the final status.";
25
26#[derive(Debug, Clone)]
27struct CapturedOutput {
28 bytes: Vec<u8>,
29 omitted_bytes: usize,
30 completion: Option<(&'static str, Option<i32>)>,
31 error: Option<String>,
32 started_at: Instant,
33 last_output_at: Instant,
34 consecutive_empty_readouts: usize,
35}
36
37impl Default for CapturedOutput {
38 fn default() -> Self {
39 let now = Instant::now();
40 Self {
41 bytes: Vec::new(),
42 omitted_bytes: 0,
43 completion: None,
44 error: None,
45 started_at: now,
46 last_output_at: now,
47 consecutive_empty_readouts: 0,
48 }
49 }
50}
51
52#[derive(Debug)]
55pub(crate) struct PolledShellState {
56 output: Mutex<CapturedOutput>,
57 completed: Notify,
58 process_exited: AtomicBool,
61 background_monitor: AtomicBool,
62 #[cfg(test)]
63 fail_cleanup_after_reap: AtomicBool,
64 capture_stop: watch::Sender<bool>,
65 pub(crate) interaction: Mutex<()>,
66 _permit: OwnedSemaphorePermit,
67}
68
69impl PolledShellState {
70 fn new() -> Result<Arc<Self>, ToolError> {
71 static CAPACITY: OnceLock<Arc<Semaphore>> = OnceLock::new();
72 let permit = CAPACITY
73 .get_or_init(|| Arc::new(Semaphore::new(MAX_RETAINED_SESSIONS)))
74 .clone()
75 .try_acquire_owned()
76 .map_err(|_| ToolError::Execution("Exec session capacity exhausted; finish or stop an existing session before starting another command".into()))?;
77 Ok(Arc::new(Self {
78 output: Mutex::new(CapturedOutput::default()),
79 completed: Notify::new(),
80 process_exited: AtomicBool::new(false),
81 background_monitor: AtomicBool::new(false),
82 #[cfg(test)]
83 fail_cleanup_after_reap: AtomicBool::new(false),
84 capture_stop: watch::channel(false).0,
85 interaction: Mutex::new(()),
86 _permit: permit,
87 }))
88 }
89
90 async fn push(&self, bytes: &[u8]) {
91 if bytes.is_empty() {
92 return;
93 }
94 let mut output = self.output.lock().await;
95 output.last_output_at = Instant::now();
96 output.consecutive_empty_readouts = 0;
97 output.bytes.extend_from_slice(bytes);
98 let mut excess = output.bytes.len().saturating_sub(MAX_CAPTURE_BYTES);
99 if excess > 0 {
100 while excess < output.bytes.len() && output.bytes[excess] & 0b1100_0000 == 0b1000_0000 {
101 excess += 1;
102 }
103 output.bytes.drain(..excess);
104 output.omitted_bytes = output.omitted_bytes.saturating_add(excess);
105 }
106 }
107
108 pub(crate) async fn record_error(&self, error: String) {
109 self.output.lock().await.error.get_or_insert(error);
110 }
111
112 pub(crate) async fn record_cleanup_error(&self, error: String) {
113 self.output.lock().await.error = Some(error);
116 }
117
118 #[cfg(test)]
119 pub(super) fn test_cleanup_after_reap(&self) -> std::io::Result<()> {
120 if self.fail_cleanup_after_reap.swap(false, Ordering::AcqRel) {
121 Err(std::io::Error::other("controlled post-reap cleanup error"))
122 } else {
123 Ok(())
124 }
125 }
126
127 pub(crate) fn stop_capture(&self) {
128 self.capture_stop.send_replace(true);
129 }
130
131 pub(crate) async fn finish(&self, status: &'static str, exit_code: Option<i32>) {
132 let mut output = self.output.lock().await;
133 let status = if output.error.is_some() {
134 "error"
135 } else {
136 status
137 };
138 output.completion = Some((status, exit_code));
139 drop(output);
140 self.completed.notify_waiters();
141 }
142
143 pub(super) fn mark_process_exited(&self) {
144 self.process_exited.store(true, Ordering::Release);
145 }
146
147 pub(super) fn process_exited(&self) -> bool {
148 self.process_exited.load(Ordering::Acquire)
149 }
150
151 pub(super) async fn wait(&self, yield_time_ms: u64) {
152 let notified = self.completed.notified();
153 tokio::pin!(notified);
154 notified.as_mut().enable();
157 let running = self.output.lock().await.completion.is_none();
158 if running {
159 let _ = timeout(Duration::from_millis(yield_time_ms), notified).await;
160 }
161 }
162
163 pub(crate) async fn is_complete(&self) -> bool {
164 self.output.lock().await.completion.is_some()
165 }
166
167 pub(super) async fn reset_empty_readouts(&self) {
168 self.output.lock().await.consecutive_empty_readouts = 0;
169 }
170
171 async fn take(&self, shell_id: &str, max_output_tokens: Option<usize>) -> ExecSessionResult {
172 let mut output = self.output.lock().await;
173 Self::take_locked(
174 &mut output,
175 shell_id,
176 max_output_tokens,
177 self.process_exited(),
178 )
179 }
180
181 fn take_locked(
182 output: &mut CapturedOutput,
183 shell_id: &str,
184 max_output_tokens: Option<usize>,
185 exit_observed: bool,
186 ) -> ExecSessionResult {
187 let had_output = !output.bytes.is_empty() || output.omitted_bytes > 0;
190 let polling = output.completion.is_none().then(|| {
191 output.consecutive_empty_readouts = if had_output {
192 0
193 } else {
194 output.consecutive_empty_readouts.saturating_add(1)
195 };
196 let now = Instant::now();
197 let idle = now.duration_since(output.last_output_at);
198 ExecSessionProgress {
199 terminal_result_ttl_ms: bash_runtime::COMPLETED_SESSION_TTL_SECS * 1000,
200 state: if idle >= QUIET_POLL_ADVISORY_AFTER
201 && output.consecutive_empty_readouts >= QUIET_POLL_DIAGNOSIS_READOUTS
202 {
203 "needs_diagnosis"
204 } else {
205 "waiting"
206 },
207 elapsed_ms: duration_ms(now.duration_since(output.started_at)),
208 output_idle_ms: duration_ms(idle),
209 consecutive_empty_readouts: output.consecutive_empty_readouts,
210 exit_observed,
211 advisory: (idle >= QUIET_POLL_ADVISORY_AFTER
212 && output.consecutive_empty_readouts >= QUIET_POLL_ADVISORY_READOUTS)
213 .then_some(QUIET_POLL_ADVISORY),
214 }
215 });
216 let bytes = std::mem::take(&mut output.bytes);
217 let decoded = String::from_utf8_lossy(&bytes);
218 let budget = max_output_tokens
219 .unwrap_or(DEFAULT_OUTPUT_TOKENS)
220 .clamp(1, MAX_OUTPUT_TOKENS)
221 .saturating_mul(4);
222 let end = json_output_prefix_end(&decoded, budget);
223 let omitted = std::mem::take(&mut output.omitted_bytes)
224 .saturating_add(decoded.len().saturating_sub(end));
225 let (status, exit_code) = output.completion.unwrap_or(("running", None));
226 ExecSessionResult {
227 session_id: output.completion.is_none().then(|| shell_id.to_owned()),
228 status,
229 output: decoded[..end].to_owned(),
230 exit_code,
231 output_omitted_bytes: omitted,
232 error: output.error.clone(),
233 polling,
234 }
235 }
236}
237
238pub struct BackgroundExecJob {
242 shell: Arc<bash_runtime::ShellSession>,
243}
244
245impl BackgroundExecJob {
246 pub fn id(&self) -> &str {
247 &self.shell.id
248 }
249
250 pub fn session_id(&self) -> &str {
251 self.shell.session_id.as_deref().expect("validated owner")
252 }
253
254 pub async fn completion(&self) -> bamboo_agent_core::BashCompletionInfo {
257 let state = self.shell.polled.as_ref().expect("validated exec capture");
258 loop {
259 state.wait(30_000).await;
260 let output = state.output.lock().await;
261 if output.completion.is_none() {
262 continue;
263 }
264 let mut snapshot = output.clone();
265 drop(output);
266 let result = PolledShellState::take_locked(
267 &mut snapshot,
268 &self.shell.id,
269 Some(1024),
270 state.process_exited(),
271 );
272 return bamboo_agent_core::BashCompletionInfo {
273 session_id: self.session_id().to_owned(),
274 bash_id: self.id().to_owned(),
275 command: self.shell.command.clone(),
276 exit_code: result.exit_code,
277 status: result.status.to_owned(),
278 output_tail: serde_json::to_string(&result).expect("exec result is serializable"),
279 };
280 }
281 }
282}
283
284impl Drop for BackgroundExecJob {
285 fn drop(&mut self) {
286 self.shell
287 .polled
288 .as_ref()
289 .expect("validated exec capture")
290 .background_monitor
291 .store(false, Ordering::Release);
292 }
293}
294
295pub fn reserve_background_job(owner: &str, handle: &str) -> Option<BackgroundExecJob> {
298 let shell = bash_runtime::get_shell(handle)?;
299 if shell.session_id.as_deref() != Some(owner) {
300 return None;
301 }
302 shell
303 .polled
304 .as_ref()?
305 .background_monitor
306 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
307 .ok()?;
308 Some(BackgroundExecJob { shell })
309}
310
311fn json_output_prefix_end(output: &str, budget: usize) -> usize {
314 let mut encoded_bytes = 2; let mut end = 0;
316 for (offset, character) in output.char_indices() {
317 let character_bytes = match character {
319 '"' | '\\' | '\n' | '\r' | '\t' | '\u{8}' | '\u{c}' => 2,
320 '\u{0}'..='\u{1f}' => 6,
321 _ => character.len_utf8(),
322 };
323 if encoded_bytes + character_bytes > budget {
324 break;
325 }
326 encoded_bytes += character_bytes;
327 end = offset + character.len_utf8();
328 }
329 end
330}
331
332pub(crate) async fn pump_polled_stream(
335 mut reader: impl AsyncRead + Unpin,
336 state: Arc<PolledShellState>,
337) {
338 let mut buffer = [0; 8192];
339 let mut pending = Vec::new();
340 let mut stop = state.capture_stop.subscribe();
341 loop {
342 let stopped = *stop.borrow();
343 if stopped {
344 if !pending.is_empty() {
345 state
346 .push(String::from_utf8_lossy(&pending).as_bytes())
347 .await;
348 }
349 break;
350 }
351 let read = tokio::select! {
352 biased;
353 _ = stop.changed() => continue,
354 result = reader.read(&mut buffer) => result,
355 };
356 match read {
357 Ok(0) => {
358 if !pending.is_empty() {
359 state
360 .push(String::from_utf8_lossy(&pending).as_bytes())
361 .await;
362 }
363 break;
364 }
365 Ok(count) => {
366 pending.extend_from_slice(&buffer[..count]);
367 let mut consumed = 0;
368 let mut decoded = String::new();
369 while consumed < pending.len() {
370 match std::str::from_utf8(&pending[consumed..]) {
371 Ok(text) => {
372 decoded.push_str(text);
373 consumed = pending.len();
374 }
375 Err(error) => {
376 let valid_end = consumed + error.valid_up_to();
377 decoded.push_str(
378 std::str::from_utf8(&pending[consumed..valid_end]).unwrap(),
379 );
380 consumed = valid_end;
381 if let Some(invalid_len) = error.error_len() {
382 decoded.push('\u{fffd}');
383 consumed += invalid_len;
384 } else {
385 break;
386 }
387 }
388 }
389 }
390 pending.drain(..consumed);
391 state.push(decoded.as_bytes()).await;
392 }
393 Err(error) => {
394 state
395 .record_error(format!("Shell output read failed: {error}"))
396 .await;
397 break;
398 }
399 }
400 }
401}
402
403#[derive(Debug, Serialize)]
404struct ExecSessionResult {
405 #[serde(skip_serializing_if = "Option::is_none")]
406 session_id: Option<String>,
407 status: &'static str,
408 output: String,
409 #[serde(skip_serializing_if = "Option::is_none")]
410 exit_code: Option<i32>,
411 #[serde(skip_serializing_if = "is_zero")]
412 output_omitted_bytes: usize,
413 #[serde(skip_serializing_if = "Option::is_none")]
414 error: Option<String>,
415 #[serde(skip_serializing_if = "Option::is_none")]
416 polling: Option<ExecSessionProgress>,
417}
418
419#[derive(Debug, Serialize)]
421struct ExecSessionProgress {
422 terminal_result_ttl_ms: u64,
423 state: &'static str,
424 elapsed_ms: u64,
425 output_idle_ms: u64,
426 consecutive_empty_readouts: usize,
427 exit_observed: bool,
429 #[serde(skip_serializing_if = "Option::is_none")]
430 advisory: Option<&'static str>,
431}
432
433fn duration_ms(duration: Duration) -> u64 {
434 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
435}
436
437fn is_zero(value: &usize) -> bool {
438 *value == 0
439}
440
441pub(crate) fn yield_duration(requested: Option<u64>) -> u64 {
442 requested.unwrap_or(10_000).clamp(250, 30_000)
443}
444
445pub(crate) fn owner_session(ctx: &ToolCtx) -> Result<&str, ToolError> {
446 ctx.session_id().filter(|id| !id.is_empty()).ok_or_else(|| {
447 ToolError::InvalidArguments("Exec sessions require an owning session".into())
448 })
449}
450
451pub(crate) async fn poll_result(
452 shell: &bash_runtime::ShellSession,
453 yield_time_ms: u64,
454 max_output_tokens: Option<usize>,
455) -> ToolOutcome {
456 let state = shell.polled.as_ref().expect("validated exec session");
457 state.wait(yield_time_ms).await;
458 let result = state.take(&shell.id, max_output_tokens).await;
459 if result.session_id.is_none() {
460 bash_runtime::remove_shell(&shell.id);
461 }
462 result_outcome(&result)
463}
464
465fn result_outcome(result: &ExecSessionResult) -> ToolOutcome {
466 ToolOutcome::Completed(ToolResult {
467 success: true,
470 result: serde_json::to_string(result).expect("exec result is serializable"),
471 display_preference: Some("Collapsible".into()),
472 images: Vec::new(),
473 })
474}
475
476struct InitialDelivery<'a> {
479 shell: &'a bash_runtime::ShellSession,
480 delivered: bool,
481}
482
483impl Drop for InitialDelivery<'_> {
484 fn drop(&mut self) {
485 if !self.delivered {
486 self.shell.cancel_initial();
487 }
488 }
489}
490
491async fn poll_initial_result(
492 shell: &bash_runtime::ShellSession,
493 yield_time_ms: u64,
494 max_output_tokens: Option<usize>,
495) -> Result<ToolOutcome, ToolError> {
496 let mut delivery = InitialDelivery {
497 shell,
498 delivered: false,
499 };
500 let state = shell.polled.as_ref().expect("validated initial exec");
501 if let Some(owner) = &shell.initial_handoff {
502 tokio::select! {
503 _ = state.wait(yield_time_ms) => {},
504 _ = owner.capture_ready() => {},
505 }
506 } else {
507 state.wait(yield_time_ms).await;
508 }
509 let result = take_initial_result(shell, max_output_tokens, &mut delivery, || {}).await?;
510 if result.session_id.is_none() {
511 bash_runtime::remove_shell(&shell.id);
512 }
513 Ok(result_outcome(&result))
514}
515
516async fn take_initial_result(
521 shell: &bash_runtime::ShellSession,
522 max_output_tokens: Option<usize>,
523 delivery: &mut InitialDelivery<'_>,
524 before_commit: impl FnOnce(),
525) -> Result<ExecSessionResult, ToolError> {
526 let state = shell.polled.as_ref().expect("validated initial exec");
527 let mut output = state.output.lock().await;
528 before_commit();
529 let terminal = shell.deliver_initial().map_err(ToolError::Execution)?;
531 let mut result = PolledShellState::take_locked(
532 &mut output,
533 &shell.id,
534 max_output_tokens,
535 state.process_exited(),
536 );
537 if let Some(status) = terminal {
538 result.session_id = None;
539 result.polling = None;
540 result.exit_code = status.code();
541 result.status = if result.error.is_some() {
542 "error"
543 } else if status.code().is_none() {
544 "killed"
545 } else {
546 "completed"
547 };
548 } else if shell.initial_handoff.is_some() {
549 result.session_id = Some(shell.id.clone());
551 result.status = "running";
552 result.exit_code = None;
553 }
554 delivery.delivered = true;
555 drop(output);
556 Ok(result)
557}
558
559#[derive(Deserialize)]
560#[serde(deny_unknown_fields)]
561struct ExecCommandArgs {
562 cmd: String,
563 workdir: Option<String>,
564 yield_time_ms: Option<u64>,
565 max_output_tokens: Option<usize>,
566 #[serde(default)]
567 interactive: bool,
568}
569
570fn resolve_cwd(owner: &str, workdir: Option<&str>) -> Result<PathBuf, ToolError> {
571 let workspace = workspace_state::workspace_or_process_cwd(Some(owner));
572 let workspace = workspace.canonicalize().map_err(|error| {
573 ToolError::InvalidArguments(format!(
574 "Invalid session workspace '{}': {error}",
575 workspace.display()
576 ))
577 })?;
578 let requested = match workdir {
579 Some(value) if value.trim().is_empty() => {
580 return Err(ToolError::InvalidArguments(
581 "'workdir' cannot be empty".into(),
582 ));
583 }
584 Some(value) => Path::new(value.trim()).to_path_buf(),
585 None => workspace.clone(),
586 };
587 let resolved = if requested.is_absolute() {
588 requested
589 } else {
590 workspace.join(requested)
591 };
592 let canonical = resolved.canonicalize().map_err(|error| {
593 ToolError::InvalidArguments(format!("Invalid workdir '{}': {error}", resolved.display()))
594 })?;
595 if !canonical.is_dir() {
596 return Err(ToolError::InvalidArguments(
597 "workdir must be a directory".into(),
598 ));
599 }
600 if !canonical.starts_with(&workspace) {
601 return Err(ToolError::InvalidArguments(
602 "workdir must stay within the current session workspace".into(),
603 ));
604 }
605 Ok(canonical)
606}
607
608#[derive(Default)]
609pub struct ExecCommandTool;
610
611impl ExecCommandTool {
612 pub fn new() -> Self {
613 Self
614 }
615}
616
617#[async_trait]
618impl Tool for ExecCommandTool {
619 fn name(&self) -> &str {
620 "exec_command"
621 }
622
623 fn description(&self) -> &str {
624 "Execute a shell command and wait up to yield_time_ms. A running command returns a session_id and polling progress; continue other work, then call write_stdin with that ID and empty chars to retrieve incremental output and its final exit code. Follow a quiet polling advisory before repeating empty polls; silence does not prove completion. Completion is delivered through these tool results. interactive=true keeps a stdin pipe open for later input; this is a pipe, not a terminal/PTY. Output is bounded and reports omitted bytes."
625 }
626
627 fn parameters_schema(&self) -> serde_json::Value {
628 json!({"type":"object","properties":{
629 "cmd":{"type":"string","description":"Shell command to execute"},
630 "workdir":{"type":"string","description":"Working directory within the current session workspace, relative or absolute; paths and symlinks outside the workspace are rejected"},
631 "yield_time_ms":{"type":"integer","description":"Wait before yielding a running session; default 10000, clamped to 250..30000 ms"},
632 "max_output_tokens":{"type":"integer","description":"Output string budget, default 2000 and capped at 8192; estimated as four JSON-encoded bytes per token, including string quotes and escapes. Status/session/exit metadata is additional"},
633 "interactive":{"type":"boolean","description":"Keep a piped stdin open for write_stdin; default false sends immediate EOF"}
634 },"required":["cmd"],"additionalProperties":false})
635 }
636
637 async fn invoke(
638 &self,
639 args: serde_json::Value,
640 ctx: ToolCtx,
641 ) -> Result<ToolOutcome, ToolError> {
642 let parsed: ExecCommandArgs = serde_json::from_value(args).map_err(|error| {
643 ToolError::InvalidArguments(format!("Invalid exec_command args: {error}"))
644 })?;
645 let owner = owner_session(&ctx)?;
646 if parsed.cmd.trim().is_empty() || parsed.cmd.len() > MAX_COMMAND_BYTES {
647 return Err(ToolError::InvalidArguments(
648 "cmd must be nonempty and at most 65536 bytes".into(),
649 ));
650 }
651 let cwd = resolve_cwd(owner, parsed.workdir.as_deref())?;
652 let state = PolledShellState::new()?;
653 let shell = bash_runtime::spawn_polled(
654 &parsed.cmd,
655 &cwd,
656 None,
657 owner.to_owned(),
658 parsed.interactive,
659 state,
660 )
661 .await
662 .map_err(ToolError::Execution)?;
663 poll_initial_result(
664 &shell,
665 yield_duration(parsed.yield_time_ms),
666 parsed.max_output_tokens,
667 )
668 .await
669 }
670}
671
672#[cfg(test)]
673#[path = "exec_sessions_tests.rs"]
674mod tests;
675
676#[cfg(all(test, unix))]
677#[path = "initial_exec_cleanup_tests.rs"]
678mod initial_cleanup_tests;
679
680#[cfg(test)]
681#[path = "background_exec_tests.rs"]
682mod background_tests;