use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use locode_host::{ExecRequest, Host, ShellSpec};
use locode_tools::{Tool, ToolCtx, ToolError, ToolKind, ToolOutput};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
const DEFAULT_TIMEOUT_MS: u64 = 10_000;
const OUTPUT_TOKEN_BUDGET: usize = 10_000;
const APPROX_BYTES_PER_TOKEN: usize = 4;
pub(crate) struct CodexShellCommand {
host: Arc<Host>,
}
impl CodexShellCommand {
pub(crate) fn new(host: Arc<Host>) -> Self {
Self { host }
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct ShellCommandArgs {
#[schemars(description = "Shell script to run in the user's default shell.")]
command: String,
#[schemars(description = "Working directory for the command. Defaults to the turn cwd.")]
#[serde(default)]
workdir: Option<String>,
#[schemars(description = "Maximum command runtime. Defaults to 10000 ms.")]
#[serde(default)]
timeout_ms: Option<u64>,
#[schemars(
description = "True runs with login shell semantics; false disables them. Defaults to true."
)]
#[serde(default)]
login: Option<bool>,
}
#[derive(Debug, Serialize)]
pub(crate) struct ShellCommandOutput {
exit_code: i64,
timed_out: bool,
truncated: bool,
#[serde(skip)]
body: String,
}
impl ToolOutput for ShellCommandOutput {
fn to_prompt_text(&self) -> String {
self.body.clone()
}
}
fn truncate_middle(content: &str) -> (String, bool) {
let budget = OUTPUT_TOKEN_BUDGET * APPROX_BYTES_PER_TOKEN;
if content.len() <= budget {
return (content.to_string(), false);
}
let removed_tokens = content
.len()
.saturating_sub(budget)
.div_ceil(APPROX_BYTES_PER_TOKEN);
let marker = format!("…{removed_tokens} tokens truncated…");
let keep = budget.saturating_sub(marker.len());
let head_target = keep / 2;
let tail_target = keep - head_target;
let head_end = (0..=head_target.min(content.len()))
.rev()
.find(|&i| content.is_char_boundary(i))
.unwrap_or(0);
let tail_from = content.len().saturating_sub(tail_target);
let tail_start = (tail_from..=content.len())
.find(|&i| content.is_char_boundary(i))
.unwrap_or(content.len());
(
format!("{}{marker}{}", &content[..head_end], &content[tail_start..]),
true,
)
}
#[async_trait]
impl Tool for CodexShellCommand {
type Args = ShellCommandArgs;
type Output = ShellCommandOutput;
fn kind(&self) -> ToolKind {
ToolKind::Shell
}
#[allow(clippy::unnecessary_literal_bound)] fn description(&self) -> &str {
include_str!("descriptions/shell_command.md")
}
async fn run(&self, ctx: &ToolCtx, args: ShellCommandArgs) -> Result<Self::Output, ToolError> {
let cwd = match args.workdir.as_deref().filter(|w| !w.is_empty()) {
Some(w) => self
.host
.resolve_in_jail(&ctx.cwd, std::path::Path::new(w))
.await
.map_err(|e| ToolError::Respond(e.to_string()))?,
None => ctx.cwd.clone(),
};
let timeout_ms = args
.timeout_ms
.filter(|&t| t > 0)
.unwrap_or(DEFAULT_TIMEOUT_MS);
let request = ExecRequest {
command: args.command,
cwd,
timeout: Some(Duration::from_millis(timeout_ms)),
env: Vec::new(),
shell: Some(ShellSpec {
detect_program: true,
login_arg: args.login.unwrap_or(true),
login_path_probe: false,
}),
front_back: None,
};
let out = self
.host
.exec(request, &ctx.cancel)
.await
.map_err(|e| ToolError::Respond(e.to_string()))?;
let timed_out = out.timed_out;
let content = if timed_out {
format!(
"command timed out after {} milliseconds\n{}",
timeout_ms, out.combined
)
} else {
out.combined
};
let total_lines = content.lines().count();
let (formatted, truncated) = truncate_middle(&content);
let exit_code: i64 = if timed_out {
124
} else {
out.exit_code.map_or(-1, i64::from)
};
#[allow(clippy::cast_possible_truncation)] let duration_seconds = ((out.duration.as_secs_f32()) * 10.0).round() / 10.0;
let mut sections = vec![
format!("Exit code: {exit_code}"),
format!("Wall time: {duration_seconds} seconds"),
];
if truncated {
sections.push(format!("Total output lines: {total_lines}"));
}
sections.push("Output:".to_string());
sections.push(formatted);
Ok(ShellCommandOutput {
exit_code,
timed_out,
truncated,
body: sections.join("\n"),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn description_is_the_pinned_shell_command_md() {
let desc = include_str!("descriptions/shell_command.md");
assert_eq!(desc.len(), 161, "shell_command.md byte length changed");
assert!(desc.starts_with("Runs a shell command and returns its output."));
assert!(
desc.trim_end()
.ends_with("Do not use `cd` unless absolutely necessary.")
);
}
#[test]
fn truncate_middle_keeps_head_and_tail() {
let big = "x".repeat(OUTPUT_TOKEN_BUDGET * APPROX_BYTES_PER_TOKEN + 5_000);
let (out, truncated) = truncate_middle(&big);
assert!(truncated);
assert!(out.contains("tokens truncated…"));
assert!(out.len() < big.len());
let (small, t2) = truncate_middle("short");
assert!(!t2);
assert_eq!(small, "short");
}
}