diff --git a/crates/bashkit/src/terminal/mod.rs b/crates/bashkit/src/terminal/mod.rs index bda585e72..a49005329 100644 --- a/crates/bashkit/src/terminal/mod.rs +++ b/crates/bashkit/src/terminal/mod.rs @@ -63,6 +63,7 @@ //! keystrokes (`vi`) read the device directly in raw mode. mod keys; +mod tool; mod tty; use std::collections::VecDeque; @@ -72,6 +73,7 @@ use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; pub(crate) use keys::{Key, ScreenGuard, read_key}; +pub use tool::{TERMINAL_TOOL_NAME, TerminalTool, TerminalToolError}; pub(crate) use tty::{Tty, TtyEvent}; use crate::fs::FileSystem; diff --git a/crates/bashkit/src/terminal/tool.rs b/crates/bashkit/src/terminal/tool.rs new file mode 100644 index 000000000..b6865ae7b --- /dev/null +++ b/crates/bashkit/src/terminal/tool.rs @@ -0,0 +1,418 @@ +//! LLM tool wrapper around a [`Terminal`] session. +//! +//! Decisions: +//! - Not an implementation of the crate's `Tool` trait. That contract is one +//! isolated shell per call (`{"commands": ...}` in, stdout/stderr out). A +//! terminal is a session: state, open programs and the screen carry over +//! between calls. Forcing it into `ToolRequest` would hide that, so this +//! type has its own schema and keeps the same metadata names +//! (`name`, `description`, `system_prompt`, `input_schema`, +//! `output_schema`, `tool_definition`). +//! - Keys use Vim notation inside one string (`ihello:wq`). LLMs +//! already know it, it keeps ordering between text and special keys, and it +//! avoids raw control characters in JSON. +//! - Each call waits for the session to go idle, bounded by `wait_ms` +//! (default 5 s, max 60 s). A command still running is reported, not +//! killed; the next call (even with no input) keeps waiting, and `` +//! interrupts it. + +use std::time::Duration; + +use serde_json::{Value, json}; + +use super::{CommandRecord, Terminal, TerminalActivity, TerminalSize, TerminalStatus}; +use crate::BashBuilder; + +/// Tool name used in schemas and definitions. +pub const TERMINAL_TOOL_NAME: &str = "terminal"; +const DEFAULT_WAIT_MS: u64 = 5_000; +const MAX_WAIT_MS: u64 = 60_000; +/// Longest `input` string accepted per call. +// THREAT[TM-DOS-119]: tool input feeds the bounded terminal queue; reject +// oversized calls up front instead of silently truncating. +const MAX_INPUT_BYTES: usize = 64 * 1024; + +const DESCRIPTION: &str = "Interactive bash terminal in a sandbox. Type keys, get the screen back. \ +Use it for full-screen programs such as vi and less, or a shell session that persists between calls."; + +/// Error from [`TerminalTool::call`]. The message is safe to show to the LLM. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{0}")] +pub struct TerminalToolError(String); + +/// A persistent terminal session exposed as an LLM tool. +/// +/// One instance is one session: keep it for the whole conversation. +/// +/// ```rust +/// use bashkit::Bash; +/// use bashkit::terminal::TerminalTool; +/// use serde_json::json; +/// +/// # #[tokio::main] +/// # async fn main() { +/// let mut tool = TerminalTool::new(Bash::builder()); +/// let out = tool +/// .call(json!({"input": "echo hi"})) +/// .await +/// .unwrap(); +/// assert_eq!(out["activity"], "prompt"); +/// assert_eq!(out["commands"][0]["output"], "hi\n"); +/// assert!(out["screen"].as_str().unwrap().contains("$ echo hi\nhi")); +/// # } +/// ``` +pub struct TerminalTool { + terminal: Terminal, +} + +impl TerminalTool { + /// Start a session on an 80x24 terminal. + pub fn new(builder: BashBuilder) -> Self { + Self::with_size(builder, TerminalSize::default()) + } + + /// Start a session on a terminal of the given size. + pub fn with_size(builder: BashBuilder, size: TerminalSize) -> Self { + Self { + terminal: Terminal::with_size(builder, size), + } + } + + /// The underlying terminal, for example to read files with + /// [`Terminal::fs`] or render [`Terminal::take_output`]. + pub fn terminal(&self) -> &Terminal { + &self.terminal + } + + /// Mutable access to the underlying terminal, to drive it directly + /// between tool calls. + pub fn terminal_mut(&mut self) -> &mut Terminal { + &mut self.terminal + } + + /// Tool name: `terminal`. + pub fn name(&self) -> &str { + TERMINAL_TOOL_NAME + } + + /// One-sentence description for tool listings. + pub fn description(&self) -> &str { + DESCRIPTION + } + + /// Terse usage guide for a system prompt. + pub fn system_prompt(&self) -> String { + "terminal: interactive sandboxed bash session that persists between calls. \ +Send keys in `input` using Vim notation: text is typed as-is, runs a line, \ +, , , ///, /, interrupts, \ + ends input, types a literal '<'. Each call returns the screen, the activity \ +(prompt, continuation, running, exited) and every command that finished with its exact \ +output and exit code. vi and less work; quit vi with :wq, less with q. \ +If activity is running, call again (input may be empty) to keep waiting, or send ." + .to_string() + } + + /// JSON Schema for [`call`](Self::call) arguments. + pub fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Keys to type, Vim notation: `ls -la`, `ihello:wq`, ``. May be empty to just wait and look." + }, + "wait_ms": { + "type": "integer", + "minimum": 0, + "maximum": MAX_WAIT_MS, + "description": "How long to wait for the session to need input (default 5000)." + } + } + }) + } + + /// JSON Schema for the value [`call`](Self::call) returns. + pub fn output_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["screen", "activity", "commands", "waiting_for_input"], + "properties": { + "screen": {"type": "string", "description": "Visible screen as plain text"}, + "activity": { + "type": "string", + "enum": ["prompt", "continuation", "running", "exited"], + "description": "What the session is doing" + }, + "running_command": {"type": "string", "description": "Command line still running (activity = running)"}, + "exit_code": {"type": "integer", "description": "Shell exit code (activity = exited)"}, + "waiting_for_input": {"type": "boolean", "description": "False when wait_ms ran out while a command was still working"}, + "full_screen": {"type": "boolean", "description": "A full-screen program such as vi or less is open"}, + "commands": { + "type": "array", + "description": "Commands that finished during this call", + "items": { + "type": "object", + "required": ["command", "output", "exit_code"], + "properties": { + "command": {"type": "string"}, + "output": {"type": "string"}, + "exit_code": {"type": "integer"}, + "output_truncated": {"type": "boolean"} + } + } + } + } + }) + } + + /// OpenAI-compatible function definition. + pub fn tool_definition(&self) -> Value { + json!({ + "type": "function", + "function": { + "name": self.name(), + "description": self.description(), + "parameters": self.input_schema(), + } + }) + } + + /// Send the keys in `args.input`, run until the session needs input (or + /// `wait_ms` passes), and report the screen and finished commands. + pub async fn call(&mut self, args: Value) -> Result { + let Some(args) = args.as_object() else { + return Err(TerminalToolError("arguments must be a JSON object".into())); + }; + let input = match args.get("input") { + None | Some(Value::Null) => "", + Some(Value::String(s)) => s.as_str(), + Some(_) => return Err(TerminalToolError("`input` must be a string".into())), + }; + if input.len() > MAX_INPUT_BYTES { + return Err(TerminalToolError(format!( + "`input` is longer than {MAX_INPUT_BYTES} bytes; send it in smaller parts" + ))); + } + let wait_ms = match args.get("wait_ms") { + None | Some(Value::Null) => DEFAULT_WAIT_MS, + Some(v) => v.as_u64().ok_or_else(|| { + TerminalToolError("`wait_ms` must be a non-negative integer".into()) + })?, + } + .min(MAX_WAIT_MS); + + let bytes = parse_keys(input); + if self.terminal.send(&bytes) < bytes.len() { + return Err(TerminalToolError( + "terminal input buffer is full; wait for the running command first".into(), + )); + } + let waiting = matches!( + crate::time_compat::timeout( + Duration::from_millis(wait_ms), + self.terminal.run_until_idle() + ) + .await, + Ok(TerminalStatus::Idle | TerminalStatus::Exited(_)) + ); + Ok(self.report(waiting)) + } + + fn report(&self, waiting: bool) -> Value { + let mut out = json!({ + "screen": self.terminal.screen_text(), + "waiting_for_input": waiting, + "full_screen": self.terminal.is_alternate_screen(), + "commands": self + .terminal + .take_transcript() + .into_iter() + .map(record_json) + .collect::>(), + }); + let (activity, extra) = match self.terminal.activity() { + TerminalActivity::Starting | TerminalActivity::Prompt => ("prompt", None), + TerminalActivity::ContinuationPrompt => ("continuation", None), + TerminalActivity::Running { command } => { + ("running", Some(("running_command", json!(command)))) + } + TerminalActivity::Exited(code) => ("exited", Some(("exit_code", json!(code)))), + }; + out["activity"] = json!(activity); + if let Some((key, value)) = extra { + out[key] = value; + } + out + } +} + +fn record_json(r: CommandRecord) -> Value { + let mut v = json!({ + "command": r.command, + "output": r.output, + "exit_code": r.exit_code, + }); + if r.output_truncated { + v["output_truncated"] = json!(true); + } + v +} + +/// Translate Vim key notation into terminal input bytes. Unknown `<...>` +/// tokens are typed literally. +pub(crate) fn parse_keys(input: &str) -> Vec { + let mut out = Vec::with_capacity(input.len()); + let mut rest = input; + while let Some(start) = rest.find('<') { + out.extend_from_slice(&rest.as_bytes()[..start]); + let after = &rest[start + 1..]; + let token = after + .find('>') + .map(|end| &after[..end]) + .filter(|t| !t.is_empty() && t.len() <= 10 && !t.contains('<')); + match token.and_then(key_bytes) { + Some(bytes) => { + out.extend_from_slice(&bytes); + rest = &after[token.map_or(0, str::len) + 1..]; + } + None => { + out.push(b'<'); + rest = after; + } + } + } + out.extend_from_slice(rest.as_bytes()); + out +} + +fn key_bytes(token: &str) -> Option> { + let lower = token.to_ascii_lowercase(); + let fixed: &[u8] = match lower.as_str() { + "enter" | "cr" | "return" => b"\r", + "esc" | "escape" => b"\x1b", + "tab" => b"\t", + "bs" | "backspace" => b"\x7f", + "del" | "delete" => b"\x1b[3~", + "space" => b" ", + "lt" => b"<", + "up" => b"\x1b[A", + "down" => b"\x1b[B", + "right" => b"\x1b[C", + "left" => b"\x1b[D", + "home" => b"\x1b[H", + "end" => b"\x1b[F", + "pageup" => b"\x1b[5~", + "pagedown" => b"\x1b[6~", + _ => { + let ctrl = lower.strip_prefix("c-")?; + let [c] = ctrl.as_bytes() else { + return None; + }; + return match c { + b'a'..=b'z' => Some(vec![c - b'a' + 1]), + b'[' => Some(vec![0x1b]), + _ => None, + }; + } + }; + Some(fixed.to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Bash; + + #[test] + fn vim_notation_maps_to_bytes() { + assert_eq!(parse_keys("ls"), b"ls\r"); + assert_eq!(parse_keys("ihi:wq"), b"ihi\x1b:wq\r"); + assert_eq!(parse_keys(""), b"\x03\x04"); + assert_eq!(parse_keys(""), b"\x1b[A\x1b[6~"); + assert_eq!(parse_keys("a b"), b"a < b"); + } + + #[test] + fn unknown_or_unclosed_tokens_are_literal() { + assert_eq!(parse_keys("echo "), b"echo "); + assert_eq!(parse_keys("a"), b"x << EOF\r"); + assert_eq!(parse_keys(""), b""); + assert_eq!(parse_keys("<>"), b"<>"); + } + + #[tokio::test] + async fn edits_a_file_in_vi_over_several_calls() { + let mut tool = TerminalTool::new(Bash::builder()); + let out = tool + .call(json!({"input": "vi /tmp/n.txt"})) + .await + .unwrap(); + assert_eq!(out["activity"], "running"); + assert_eq!(out["running_command"], "vi /tmp/n.txt"); + assert_eq!(out["full_screen"], true); + assert_eq!(out["waiting_for_input"], true); + + let out = tool + .call(json!({"input": "ihello:wq"})) + .await + .unwrap(); + assert_eq!(out["activity"], "prompt"); + assert_eq!(out["full_screen"], false); + assert_eq!(out["commands"][0]["command"], "vi /tmp/n.txt"); + assert_eq!(out["commands"][0]["exit_code"], 0); + let saved = tool + .terminal() + .fs() + .read_file("/tmp/n.txt".as_ref()) + .await + .unwrap(); + assert_eq!(saved, b"hello\n"); + } + + #[tokio::test] + async fn slow_command_reports_running_then_finishes() { + let mut tool = TerminalTool::new(Bash::builder()); + let out = tool + .call(json!({"input": "sleep 0.2; echo done", "wait_ms": 20})) + .await + .unwrap(); + assert_eq!(out["waiting_for_input"], false); + assert_eq!(out["activity"], "running"); + let out = tool.call(json!({"wait_ms": 2000})).await.unwrap(); + assert_eq!(out["waiting_for_input"], true); + assert_eq!(out["commands"][0]["output"], "done\n"); + } + + #[tokio::test] + async fn exit_is_reported() { + let mut tool = TerminalTool::new(Bash::builder()); + let out = tool.call(json!({"input": "exit 5"})).await.unwrap(); + assert_eq!(out["activity"], "exited"); + assert_eq!(out["exit_code"], 5); + } + + #[tokio::test] + async fn bad_arguments_are_user_facing_errors() { + let mut tool = TerminalTool::new(Bash::builder()); + assert!(tool.call(json!("ls")).await.is_err()); + assert!(tool.call(json!({"input": 3})).await.is_err()); + assert!( + tool.call(json!({"input": "", "wait_ms": -1})) + .await + .is_err() + ); + let big = "a".repeat(MAX_INPUT_BYTES + 1); + let err = tool.call(json!({"input": big})).await.unwrap_err(); + assert!(err.to_string().contains("smaller parts")); + } + + #[test] + fn definition_matches_schema() { + let tool = TerminalTool::new(Bash::builder()); + let def = tool.tool_definition(); + assert_eq!(def["function"]["name"], "terminal"); + assert_eq!(def["function"]["parameters"], tool.input_schema()); + assert!(tool.system_prompt().starts_with("terminal:")); + } +} diff --git a/docs/terminal.md b/docs/terminal.md index 344783f50..8df8acee0 100644 --- a/docs/terminal.md +++ b/docs/terminal.md @@ -148,6 +148,50 @@ default prompt is `$ `. `[ -t 0 ]` is true inside the session, and `COLUMNS`, `LINES` and `TERM` are set. `resize()` changes the size; a running `vi` redraws. +## As an LLM tool + +`TerminalTool` wraps one session as a tool an agent can call repeatedly. Keys +go in one string in Vim notation, and each call returns the screen, what the +session is doing, and the commands that finished during the call: + +```rust +use bashkit::Bash; +use bashkit::terminal::TerminalTool; +use serde_json::json; + +let mut tool = TerminalTool::new(Bash::builder()); +// Register tool.tool_definition() (OpenAI function format) and +// tool.system_prompt() with your model, then forward its calls: +let out = tool.call(json!({"input": "vi notes.txt"})).await?; +// out["activity"] == "running", out["full_screen"] == true +let out = tool.call(json!({"input": "ihello:wq"})).await?; +// out["activity"] == "prompt", out["commands"][0]["exit_code"] == 0 +``` + +| `input` token | Key | +|---------------|-----| +| plain text | typed as-is | +| `` `` `` `` `` `` | the named key | +| `` `` `` `` `` `` `` `` | cursor keys | +| ``, ``, any `` | Ctrl plus a letter | +| `` | a literal `<` | + +Anything else in angle brackets, such as `` or a heredoc's `<<`, is typed +literally. + +The result has `screen`, `activity` (`prompt`, `continuation`, `running` or +`exited`), `running_command` or `exit_code` when they apply, `full_screen` +(true while `vi` or `less` is open), `waiting_for_input`, and `commands` (the +transcript records finished during the call). Each call waits up to `wait_ms` +(default 5 s, at most 60 s) for the session to need input. A command still +running after that is reported with `waiting_for_input: false`, not killed: +call again, with empty input, to keep waiting, or send ``. One +`TerminalTool` is one session, so keep it for the whole conversation. +`call` takes at most 64 KiB of `input` per call. + +It is not a `BashTool`: that tool runs each call in a fresh shell, while a +terminal keeps the shell, open programs and the screen between calls. + ## vi `vi [FILE]` opens the editor on the alternate screen; quitting restores the diff --git a/knowledge/integrations/in-process-terminal.md b/knowledge/integrations/in-process-terminal.md index b74ed8a2f..9b17c52dd 100644 --- a/knowledge/integrations/in-process-terminal.md +++ b/knowledge/integrations/in-process-terminal.md @@ -83,6 +83,21 @@ programs write to the device, not the streaming callback, so they never land in the transcript. Transcript is drained (like `take_output`) and bounded (64 KiB output per record, 1 MiB total, oldest dropped). +## Decision: TerminalTool is its own tool, not a `Tool` impl + +`terminal::TerminalTool` (`terminal/tool.rs`) exposes one session to an LLM: +`call({"input", "wait_ms"})` returns screen, activity, `full_screen`, +`waiting_for_input` and the drained transcript. It does not implement the +`Tool` trait ([Tool Contract](tool-contract.md)): that contract is one isolated +shell per call with `{"commands"}` input, while a terminal session carries +state, open programs and the screen across calls. It mirrors the metadata +names (`name`, `description`, `system_prompt`, `input_schema`, +`output_schema`, `tool_definition`) so hosts register it the same way. +Keys use Vim notation (`ihi:wq`, ``, ``), unknown +`<...>` tokens are typed literally, so heredocs (`<` offset for every input line, so a bounded request allocated memory proportional to the whole input | `select_lines` scans for only the requested boundary, forward for `First`/`From` and backward for `Last`/`AllButLast`, and stores no offset collection; work is bounded by the distance to that boundary rather than by input size. Regression tests: `headtail_allocation_tests` counts allocations (13.6 MB peak before, 1.0 MB after, for `head -n 1` over 1 MB of newlines); `headtail::tests::tm_dos_117_newline_dense_selection_finds_boundaries` and `headtail_resource_tests` cover boundary correctness | **MITIGATED** | | TM-DOS-118 | Python read-only self-mount recursion | `bash.mount("/", bash.fs(), read_only=True)` wraps the live filesystem before the core pointer-identity guard, then filesystem reads recurse until stack exhaustion | `Bash.mount()` and `BashTool.mount()` compare the resolved source with the live filesystem before constructing `ReadOnlyFs`; regression tests cover both Python surfaces | **MITIGATED** | -| TM-DOS-119 | Interactive terminal resource exhaustion | A host or script floods the in-process `Terminal` (`terminal` feature): unbounded typed-ahead input, output nobody drains, or a `vi` buffer grown by pastes, `p` and `:s` until memory runs out | Unread input is capped at 1 MiB (`send` returns the accepted count), retained raw output at the newest 4 MiB, scrollback at 1000 rows, untaken transcript records at 1 MiB total (64 KiB of output per record, oldest records drop first), the cooked-mode line at 64 KiB, the `vi` buffer at 8 MiB (load, insert, put and substitute check it), and `vi` undo/redo history at 16 MiB total; `vi` patterns use `regex` with a 1 MiB compiled-size limit. `:w` goes through the session VFS, so VFS limits and read-only mounts apply. Regressions: `terminal::tests::input_backlog_is_capped`, `terminal::tests::transcript_output_is_capped_per_record`, `terminal::tests::transcript_total_size_is_bounded`, `terminal::tests::history_is_bounded`, `builtins::vi::tests::buffer_cap_stops_inserts`, `builtins::vi::tests::readonly_vfs_write_error_keeps_editor_open` | **MITIGATED** | +| TM-DOS-119 | Interactive terminal resource exhaustion | A host or script floods the in-process `Terminal` (`terminal` feature): unbounded typed-ahead input, output nobody drains, or a `vi` buffer grown by pastes, `p` and `:s` until memory runs out | Unread input is capped at 1 MiB (`send` returns the accepted count), retained raw output at the newest 4 MiB, scrollback at 1000 rows, untaken transcript records at 1 MiB total (64 KiB of output per record, oldest records drop first), the cooked-mode line and `TerminalTool` input per call at 64 KiB, the `vi` buffer at 8 MiB (load, insert, put and substitute check it), and `vi` undo/redo history at 16 MiB total; `vi` patterns use `regex` with a 1 MiB compiled-size limit. `:w` goes through the session VFS, so VFS limits and read-only mounts apply. Regressions: `terminal::tests::input_backlog_is_capped`, `terminal::tests::transcript_output_is_capped_per_record`, `terminal::tests::transcript_total_size_is_bounded`, `terminal::tests::history_is_bounded`, `terminal::tool::tests::bad_arguments_are_user_facing_errors`, `builtins::vi::tests::buffer_cap_stops_inserts`, `builtins::vi::tests::readonly_vfs_write_error_keeps_editor_open` | **MITIGATED** | | TM-DOS-120 | Interactive input wait vs. execution timeout | Excluding keyboard waits from `ExecutionLimits::timeout` (so a `vi` session is not killed while a person or agent thinks) must not let busy work escape the deadline | Only time blocked in the terminal device read is excluded (`InputWaitClock`); the timer re-arms with the remaining *active* budget, so CPU work, `sleep`, and pending callbacks still time out. Only a `Terminal` installs the clock; plain `exec()` is unchanged. Regressions: `terminal::tests::waiting_for_input_does_not_count_toward_timeout`, `terminal::tests::busy_work_still_times_out` | **MITIGATED** | | TM-DOS-101 | yq structured-data amplification | YAML aliases can expand exponentially during deserialization; deep YAML/JSON, document floods, jaq generators, and YAML re-serialization can consume stack, CPU, or memory beyond the source size | Reject YAML alias tokens with a bounded lexical pass before deserialization; VFS/stdin and aggregate input budgets; serde_yaml_ng recursion cap 128 plus Bashkit depth 100; 4096-document cap; shared jaq work/deadline/output limits; post-serialization stdout cap; YAML+JSON depth/document/input/output regressions plus `yq_fuzz` and arbitrary-input proptest | **MITIGATED** | | TM-DOS-107 | Static-analysis command validation amplification | A large comment before thousands of commands makes a whole-script ordered-subsequence scan per command | Build one source-character position index, then validate each analyzed command-name character with a logarithmic position lookup instead of rescanning source | **MITIGATED** |