Skip to main content

bamboo_tools/tools/
write_stdin.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolCtx, ToolError, ToolOutcome};
3use serde::Deserialize;
4use serde_json::json;
5
6use super::{
7    bash_runtime,
8    exec_command::{owner_session, poll_result, yield_duration},
9};
10
11const MAX_INPUT_BYTES: usize = 64 * 1024;
12
13#[derive(Deserialize)]
14#[serde(deny_unknown_fields)]
15struct WriteStdinArgs {
16    session_id: String,
17    #[serde(default)]
18    chars: String,
19    yield_time_ms: Option<u64>,
20    max_output_tokens: Option<usize>,
21    #[serde(default)]
22    eof: bool,
23}
24
25#[derive(Default)]
26pub struct WriteStdinTool;
27
28impl WriteStdinTool {
29    pub fn new() -> Self {
30        Self
31    }
32}
33
34#[async_trait]
35impl Tool for WriteStdinTool {
36    fn name(&self) -> &str {
37        "write_stdin"
38    }
39
40    fn description(&self) -> &str {
41        "Continue an exec_command session owned by this conversation. Empty chars waits/polls for incremental output and final exit status. Running results include polling elapsed/idle times, empty-readout count and exit_observed (false does not prove liveness). Follow a polling advisory by checking progress or doing useful work before polling again; silence does not prove completion. Nonempty chars writes exact bytes to an interactive stdin pipe; use eof=true to close it. A lone Ctrl-C (\\u0003) stops the process. Finished results omit session_id; nonzero exit codes are command outcomes."
42    }
43
44    fn parameters_schema(&self) -> serde_json::Value {
45        json!({"type":"object","properties":{
46            "session_id":{"type":"string","description":"Running handle returned by exec_command"},
47            "chars":{"type":"string","description":"Exact stdin bytes; empty or omitted waits/polls. A lone Ctrl-C stops the process"},
48            "yield_time_ms":{"type":"integer","description":"Wait before yielding again; default 10000, clamped to 250..30000 ms"},
49            "max_output_tokens":{"type":"integer","description":"Output string budget, default 2000, capped at 8192; four JSON-encoded bytes per token including quotes and escapes. Status/session/exit metadata is additional"},
50            "eof":{"type":"boolean","description":"Close the stdin pipe after sending chars"}
51        },"required":["session_id"],"additionalProperties":false})
52    }
53
54    async fn invoke(
55        &self,
56        args: serde_json::Value,
57        ctx: ToolCtx,
58    ) -> Result<ToolOutcome, ToolError> {
59        let parsed: WriteStdinArgs = serde_json::from_value(args).map_err(|error| {
60            ToolError::InvalidArguments(format!("Invalid write_stdin args: {error}"))
61        })?;
62        let owner = owner_session(&ctx)?;
63        let shell = bash_runtime::get_shell(&parsed.session_id)
64            .filter(|shell| shell.polled.is_some() && shell.session_id.as_deref() == Some(owner))
65            .ok_or_else(|| {
66                ToolError::Execution("Exec session not found or not owned by this session (unread completed results expire after five minutes; handles do not survive Host restart)".into())
67            })?;
68        if parsed.chars.len() > MAX_INPUT_BYTES {
69            return Err(ToolError::InvalidArguments(
70                "chars exceeds 65536 bytes".into(),
71            ));
72        }
73        let state = shell.polled.as_ref().expect("validated exec session");
74        // Serialize writes and the implicit output cursor, including callers
75        // that already obtained an Arc while another poll was completing.
76        let _interaction = state.interaction.lock().await;
77        let mut wait_ms = yield_duration(parsed.yield_time_ms);
78        if parsed.chars == "\u{3}" {
79            shell.kill().await.map_err(ToolError::Execution)?;
80        } else if !parsed.chars.is_empty() {
81            if let Err(error) = shell.write_stdin(&parsed.chars, false).await {
82                // A failed write may race process exit or output-pump drain.
83                // Wait once without consuming output, then keep polling an
84                // observed exit even if its captured result is not final yet.
85                state.wait(wait_ms).await;
86                if !state.is_complete().await && !state.process_exited() {
87                    return Err(ToolError::Execution(error));
88                }
89                wait_ms = 0;
90            } else {
91                state.reset_empty_readouts().await;
92            }
93        }
94        if parsed.eof {
95            shell.close_stdin().await;
96            state.reset_empty_readouts().await;
97        }
98        Ok(poll_result(&shell, wait_ms, parsed.max_output_tokens).await)
99    }
100}