use anyhow::{anyhow, Result};
use serde_json::Value;
use std::path::PathBuf;
use crate::tools::ToolRuntime;
use crate::types::{FunctionDef, ToolDefinition};
pub fn exec_command_definition() -> ToolDefinition {
ToolDefinition {
def_type: "function".to_string(),
function: FunctionDef {
name: "exec_command".to_string(),
description: "Execute a shell command.\n\n\
Use tty=false (default) as the bash tool for one-shot shell commands like `cargo build`, `npm test`, `git status`, `ls`, etc. \
The command runs to completion, returns output + exit code, and treats yield_time_ms as a timeout.\n\n\
Use tty=true for:\n\
- Interactive REPLs (python, node, etc.)\n\
- Long-running multi-step workflows where you need shell state to persist across calls \
(cd into a directory, set env vars, then run commands)\n\
- When you need more than one command in the same shell session\n\n\
When tty=true, yield_time_ms only controls how long to wait for output before returning; it does not kill the session.\n\n\
When tty=true, you get a session_name back. Use write_stdin to continue interacting with that session."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"cmd": { "type": "string", "description": "Shell command to execute" },
"workdir": { "type": "string", "description": "Working directory for the command (default: project root)" },
"tty": { "type": "boolean", "description": "Use false as the bash tool for one-shot shell commands; use true for an interactive/persistent PTY session (default: false)" },
"yield_time_ms": { "type": "number", "description": "For tty=false, command timeout in milliseconds (default: 30000, max: 30000). For tty=true, maximum time to wait for terminal output without killing the session (default: 500, max: 30000)." },
"max_output_chars": { "type": "number", "description": "Maximum characters of output to return (default: 8000, head-tail truncated if exceeded)" }
},
"required": ["cmd"]
}),
},
}
}
pub async fn execute_exec_command(args: &Value, runtime: &ToolRuntime) -> Result<String> {
let manager = &runtime.terminal_manager;
let cmd = require_str(args, "cmd")?;
let tty = args.get("tty").and_then(|v| v.as_bool()).unwrap_or(false);
let default_yield_ms = if tty { 500 } else { 30_000 };
let yield_ms = clamp_yield(
args.get("yield_time_ms")
.and_then(|v| v.as_u64())
.unwrap_or(default_yield_ms),
);
let max_output = args
.get("max_output_chars")
.and_then(|v| v.as_u64())
.unwrap_or(8000) as usize;
let cwd = args
.get("workdir")
.and_then(|v| v.as_str())
.map(PathBuf::from);
if !tty {
let output = manager
.exec_one_shot(
&cmd,
cwd,
120,
40,
yield_ms,
max_output,
runtime.sandbox.as_ref(),
)
.await?;
return Ok(serde_json::to_string_pretty(&output)?);
}
let session_name = make_session_name();
let _info = manager
.create(session_name.clone(), cwd, 120, 40, runtime.sandbox.as_ref())
.await?;
if cmd.trim().is_empty() {
let output = manager
.write_stdin(&session_name, "", yield_ms, max_output)
.await?;
return Ok(serde_json::to_string_pretty(&output)?);
}
let output = manager
.write_stdin(&session_name, &format!("{}\r", cmd), yield_ms, max_output)
.await?;
Ok(serde_json::to_string_pretty(&output)?)
}
pub fn write_stdin_definition() -> ToolDefinition {
ToolDefinition {
def_type: "function".to_string(),
function: FunctionDef {
name: "write_stdin".to_string(),
description: "Send input to a persistent terminal session created by exec_command with tty=true. \
Also use this to poll for output by sending empty input.\n\n\
Supports key notation: <RET> (Enter), <C-c> (Ctrl+C), <C-d> (Ctrl+D), <TAB>, <BSPC> (Backspace), \
<UP>/<DOWN>/<LEFT>/<RIGHT>."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"session_id": { "type": "string", "description": "Session ID returned by exec_command" },
"chars": { "type": "string", "description": "Input to send to the terminal. Supports key notation: <RET>, <C-c>, <C-d>, <TAB>, <BSPC>, <UP>/<DOWN>/<LEFT>/<RIGHT>. Leave empty to just poll for output." },
"yield_time_ms": { "type": "number", "description": "Maximum time to wait for output in milliseconds (default: 500)" },
"max_output_chars": { "type": "number", "description": "Maximum characters of output to return (default: 8000)" }
},
"required": ["session_id"]
}),
},
}
}
pub async fn execute_write_stdin(args: &Value, runtime: &ToolRuntime) -> Result<String> {
let manager = &runtime.terminal_manager;
let session_id = require_str(args, "session_id")?;
let chars = args.get("chars").and_then(|v| v.as_str()).unwrap_or("");
let yield_ms = clamp_yield(
args.get("yield_time_ms")
.and_then(|v| v.as_u64())
.unwrap_or(500),
);
let max_output = args
.get("max_output_chars")
.and_then(|v| v.as_u64())
.unwrap_or(8000) as usize;
if !manager.contains(&session_id).await {
return Err(anyhow!(
"terminal session '{}' not found - it may have been closed or expired",
session_id
));
}
let output = manager
.write_stdin(&session_id, chars, yield_ms, max_output)
.await?;
Ok(serde_json::to_string_pretty(&output)?)
}
fn require_str(args: &Value, key: &str) -> Result<String> {
args.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("missing required argument '{}'", key))
}
fn clamp_yield(ms: u64) -> u64 {
if ms > 30_000 {
30_000
} else {
ms
}
}
fn make_session_name() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(1);
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
format!("shell-{}", n)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::EventSink;
use serde_json::json;
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::Mutex;
fn test_runtime() -> ToolRuntime {
ToolRuntime {
store_path: PathBuf::new(),
session_id: None,
worker_executable: None,
active_threads: Arc::new(Mutex::new(HashSet::new())),
event_sink: EventSink::none(),
sandbox: None,
mcp: None,
skills: None,
activated_skills: Arc::new(Mutex::new(HashSet::new())),
terminal_manager: crate::terminal::TerminalManager::new(),
thread_timeout_secs: crate::tools::thread::DEFAULT_THREAD_TIMEOUT_SECS,
}
}
#[tokio::test]
async fn exec_command_definition_shape() {
let def = exec_command_definition();
assert_eq!(def.function.name, "exec_command");
assert!(def.function.description.contains("tty=true"));
assert!(def
.function
.parameters
.get("required")
.and_then(|v| v.as_array())
.is_some());
}
#[tokio::test]
async fn exec_command_missing_cmd_fails() {
let result = execute_exec_command(&json!({}), &test_runtime()).await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("missing required argument"));
}
#[tokio::test]
async fn exec_command_one_shot_echo() {
let result = execute_exec_command(
&json!({ "cmd": "echo hello-world", "tty": false, "yield_time_ms": 2000 }),
&test_runtime(),
)
.await;
assert!(result.is_ok(), "error: {:?}", result.err());
let output = result.unwrap();
assert!(output.contains("hello-world"), "got: {}", output);
let parsed: Value = serde_json::from_str(&output).unwrap();
assert!(parsed["session_name"].is_null());
assert_eq!(parsed["exit_code"].as_i64(), Some(0));
}
#[tokio::test]
async fn exec_command_one_shot_multiline() {
let result = execute_exec_command(
&json!({ "cmd": "echo line1 && echo line2", "tty": false, "yield_time_ms": 2000 }),
&test_runtime(),
)
.await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(
output.contains("line1") && output.contains("line2"),
"got: {}",
output
);
}
#[tokio::test]
async fn exec_command_one_shot_nonzero_exit_code() {
let output = execute_exec_command(
&json!({ "cmd": "echo failure >&2; exit 7", "tty": false }),
&test_runtime(),
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&output).unwrap();
assert!(parsed["output"].as_str().unwrap().contains("failure"));
assert_eq!(parsed["exit_code"].as_i64(), Some(7));
assert!(parsed["session_name"].is_null());
}
#[tokio::test]
async fn exec_command_one_shot_returns_on_early_exit() {
let start = std::time::Instant::now();
let output = execute_exec_command(
&json!({ "cmd": "sleep 0.05; echo done", "tty": false, "yield_time_ms": 30000 }),
&test_runtime(),
)
.await
.unwrap();
assert!(
start.elapsed() < std::time::Duration::from_secs(2),
"one-shot command waited for yield_time_ms"
);
let parsed: Value = serde_json::from_str(&output).unwrap();
assert_eq!(parsed["exit_code"].as_i64(), Some(0));
assert!(parsed["output"].as_str().unwrap().contains("done"));
}
#[tokio::test]
async fn exec_command_one_shot_yield_time_is_timeout() {
let start = std::time::Instant::now();
let output = execute_exec_command(
&json!({ "cmd": "echo before; sleep 5; echo SHOULD_NOT_PRINT", "tty": false, "yield_time_ms": 100 }),
&test_runtime(),
)
.await
.unwrap();
assert!(
start.elapsed() < std::time::Duration::from_secs(2),
"one-shot command did not time out promptly"
);
let parsed: Value = serde_json::from_str(&output).unwrap();
let text = parsed["output"].as_str().unwrap();
assert!(text.contains("timed out after 100ms"), "got: {}", text);
assert!(text.contains("before"), "got: {}", text);
assert!(!text.contains("SHOULD_NOT_PRINT"), "got: {}", text);
assert!(parsed["exit_code"].is_null(), "got: {}", output);
assert!(parsed["session_name"].is_null());
}
#[tokio::test]
async fn exec_command_one_shot_timeout_kills_child_processes() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time went backwards")
.as_nanos();
let marker = std::env::temp_dir().join(format!("sac_exec_timeout_leak_{}", unique));
let cmd = format!("(sleep 1; touch {}) & wait", marker.display());
let output = execute_exec_command(
&json!({ "cmd": cmd, "tty": false, "yield_time_ms": 100 }),
&test_runtime(),
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&output).unwrap();
assert!(parsed["exit_code"].is_null(), "got: {}", output);
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
assert!(
!marker.exists(),
"timed-out command left a child process running"
);
}
#[tokio::test]
async fn exec_command_one_shot_large_output_keeps_tail() {
let output = execute_exec_command(
&json!({
"cmd": "for i in $(seq 1 120); do echo line$i; done",
"tty": false,
"max_output_chars": 200
}),
&test_runtime(),
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&output).unwrap();
let text = parsed["output"].as_str().unwrap();
assert!(text.contains("line1"), "got: {}", text);
assert!(text.contains("line120"), "got: {}", text);
assert_eq!(parsed["output_truncated"].as_bool(), Some(true));
}
#[tokio::test]
async fn exec_command_persistent_creates_session() {
let result = execute_exec_command(
&json!({ "cmd": "echo persistent-test", "tty": true, "yield_time_ms": 2000 }),
&test_runtime(),
)
.await;
assert!(result.is_ok(), "error: {:?}", result.err());
let output = result.unwrap();
let parsed: Value = serde_json::from_str(&output).unwrap();
let session_name = parsed["session_name"].as_str().unwrap();
assert!(session_name.starts_with("shell-"), "got: {}", session_name);
assert!(output.contains("persistent-test"), "got: {}", output);
}
#[tokio::test]
async fn exec_command_persistent_empty_cmd() {
let result = execute_exec_command(
&json!({ "cmd": "", "tty": true, "yield_time_ms": 2000 }),
&test_runtime(),
)
.await;
assert!(result.is_ok(), "error: {:?}", result.err());
let parsed: Value = serde_json::from_str(&result.unwrap()).unwrap();
assert!(parsed["session_name"].is_string());
}
#[tokio::test]
async fn exec_command_persistent_empty_cmd_respects_yield_time() {
let started = std::time::Instant::now();
let result = execute_exec_command(
&json!({ "cmd": "", "tty": true, "yield_time_ms": 900 }),
&test_runtime(),
)
.await;
assert!(result.is_ok(), "error: {:?}", result.err());
assert!(started.elapsed() >= std::time::Duration::from_millis(800));
}
#[tokio::test]
async fn write_stdin_definition_shape() {
let def = write_stdin_definition();
assert_eq!(def.function.name, "write_stdin");
let required = def
.function
.parameters
.get("required")
.and_then(|v| v.as_array())
.unwrap();
assert!(required.iter().any(|v| v.as_str() == Some("session_id")));
}
#[tokio::test]
async fn write_stdin_missing_session_id_fails() {
assert!(execute_write_stdin(&json!({}), &test_runtime())
.await
.is_err());
}
#[tokio::test]
async fn write_stdin_session_not_found() {
let result = execute_write_stdin(
&json!({ "session_id": "nonexistent", "chars": "echo hi<RET>" }),
&test_runtime(),
)
.await;
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[tokio::test]
async fn write_stdin_poll_output() {
let runtime = test_runtime();
runtime
.terminal_manager
.create("test-poll".to_string(), None, 120, 40, None)
.await
.unwrap();
runtime
.terminal_manager
.write_stdin("test-poll", "echo poll-me\n", 2000, 8000)
.await
.unwrap();
let result = execute_write_stdin(
&json!({ "session_id": "test-poll", "chars": "", "yield_time_ms": 500 }),
&runtime,
)
.await;
assert!(result.is_ok(), "error: {:?}", result.err());
runtime.terminal_manager.remove("test-poll").await.ok();
}
#[tokio::test]
async fn write_stdin_send_input() {
let runtime = test_runtime();
runtime
.terminal_manager
.create("test-input".to_string(), None, 120, 40, None)
.await
.unwrap();
let result = execute_write_stdin(
&json!({ "session_id": "test-input", "chars": "echo from-stdin<RET>", "yield_time_ms": 2000 }),
&runtime,
).await;
assert!(result.is_ok(), "error: {:?}", result.err());
assert!(result.unwrap().contains("from-stdin"));
runtime.terminal_manager.remove("test-input").await.ok();
}
#[tokio::test]
async fn write_stdin_allows_raw_text_without_terminator() {
let runtime = test_runtime();
runtime
.terminal_manager
.create("test-raw".to_string(), None, 120, 40, None)
.await
.unwrap();
let result = execute_write_stdin(
&json!({ "session_id": "test-raw", "chars": "echo buffered", "yield_time_ms": 100 }),
&runtime,
)
.await;
assert!(result.is_ok(), "raw text was rejected: {:?}", result.err());
runtime.terminal_manager.remove("test-raw").await.ok();
}
#[tokio::test]
async fn write_stdin_allows_pure_control_key_c_c() {
let runtime = test_runtime();
runtime
.terminal_manager
.create("test-ctrl".to_string(), None, 120, 40, None)
.await
.unwrap();
let result = execute_write_stdin(
&json!({ "session_id": "test-ctrl", "chars": "<C-c>", "yield_time_ms": 1000 }),
&runtime,
)
.await;
assert!(result.is_ok(), "validation error: {:?}", result.err());
runtime.terminal_manager.remove("test-ctrl").await.ok();
}
#[tokio::test]
async fn write_stdin_allows_pure_control_key_c_z() {
let runtime = test_runtime();
runtime
.terminal_manager
.create("test-ctrz".to_string(), None, 120, 40, None)
.await
.unwrap();
let result = execute_write_stdin(
&json!({ "session_id": "test-ctrz", "chars": "<C-z>", "yield_time_ms": 1000 }),
&runtime,
)
.await;
assert!(result.is_ok(), "validation error: {:?}", result.err());
runtime.terminal_manager.remove("test-ctrz").await.ok();
}
#[tokio::test]
async fn write_stdin_allows_arrow_key_without_terminator() {
let runtime = test_runtime();
runtime
.terminal_manager
.create("test-arrow".to_string(), None, 120, 40, None)
.await
.unwrap();
let result = execute_write_stdin(
&json!({ "session_id": "test-arrow", "chars": "<UP>", "yield_time_ms": 1000 }),
&runtime,
)
.await;
assert!(result.is_ok(), "validation error: {:?}", result.err());
runtime.terminal_manager.remove("test-arrow").await.ok();
}
#[tokio::test]
async fn write_stdin_allows_tab_without_terminator() {
let runtime = test_runtime();
runtime
.terminal_manager
.create("test-tab".to_string(), None, 120, 40, None)
.await
.unwrap();
let result = execute_write_stdin(
&json!({ "session_id": "test-tab", "chars": "<TAB>", "yield_time_ms": 1000 }),
&runtime,
)
.await;
assert!(result.is_ok(), "validation error: {:?}", result.err());
runtime.terminal_manager.remove("test-tab").await.ok();
}
#[tokio::test]
async fn write_stdin_returns_exit_metadata_and_clears_session() {
let runtime = test_runtime();
runtime
.terminal_manager
.create("test-exit".to_string(), None, 120, 40, None)
.await
.unwrap();
let result = execute_write_stdin(
&json!({ "session_id": "test-exit", "chars": "exit<RET>", "yield_time_ms": 2000 }),
&runtime,
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
let mut final_parsed = parsed.clone();
for _ in 0..10 {
if final_parsed["session_name"].is_null()
&& final_parsed["exit_code"].as_i64() == Some(0)
{
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let poll = execute_write_stdin(
&json!({ "session_id": "test-exit", "chars": "", "yield_time_ms": 200 }),
&runtime,
)
.await;
match poll {
Ok(output) => {
final_parsed = serde_json::from_str(&output).unwrap();
}
Err(error) => {
assert!(
error.to_string().contains("not found"),
"unexpected error while polling exit: {}",
error
);
break;
}
}
}
assert!(
final_parsed["session_name"].is_null(),
"got: {}",
final_parsed
);
assert_eq!(final_parsed["exit_code"].as_i64(), Some(0));
assert!(runtime.terminal_manager.get("test-exit").await.is_none());
}
#[tokio::test]
async fn clamp_yield_edge() {
assert_eq!(clamp_yield(0), 0);
assert_eq!(clamp_yield(15_000), 15_000);
assert_eq!(clamp_yield(60_000), 30_000);
}
#[tokio::test]
async fn make_session_name_increments() {
let a = make_session_name();
let b = make_session_name();
assert_ne!(a, b);
assert!(a.starts_with("shell-"));
assert!(b.starts_with("shell-"));
}
}