use super::*;
impl BuiltinTools {
pub async fn execute(&self, name: &str, args: Value) -> String {
let canonical = canonical_tool_name(name);
if !self.available(canonical) {
return format!("[error] tool '{}' is not available on this platform", name);
}
match canonical {
"read_file" => self.read_file(&args).await,
"read_files" => self.read_files(&args).await,
"write_file" => self.write_file(&args).await,
"edit_file" => self.edit_file(&args).await,
"list_dir" => self.list_dir(&args).await,
"shell" => self.shell(&args).await,
n if n.starts_with("context_") => {
"[error] context tools must be handled by the runtime".to_string()
}
SUBMIT_OUTPUT_TOOL => {
"[error] submit_output must be handled by the runtime".to_string()
}
_ => format!("[error] Unknown built-in tool: {}", name),
}
}
pub(crate) fn ensure_workspace(&self) -> Result<(), String> {
if std::fs::metadata(&self.ctx.workdir).is_ok_and(|m| m.is_dir()) {
return Ok(());
}
Err(format!(
"[error] workspace '{}' is no longer accessible",
self.ctx.workdir.display()
))
}
pub(crate) fn resolve(&self, requested: &str) -> anyhow::Result<PathBuf> {
Self::resolve_within(requested, &self.ctx.workdir, resolves_within)
}
pub(crate) fn resolve_read(&self, requested: &str) -> anyhow::Result<PathBuf> {
match Self::resolve_within(requested, &self.ctx.workdir, resolves_within) {
Ok(path) => Ok(path),
Err(workdir_err) => {
if !self.ctx.read_paths.is_active() {
return Err(workdir_err);
}
Self::resolve_outside(
requested,
&self.ctx.workdir,
&self.ctx.read_paths,
leviath_core::canonicalize_for_match,
)
}
}
}
pub(crate) fn resolve_outside(
requested: &str,
workdir: &Path,
policy: &leviath_core::ReadPathPolicy,
canon: fn(&Path) -> Option<PathBuf>,
) -> anyhow::Result<PathBuf> {
let raw = if Path::new(requested).is_absolute() {
PathBuf::from(requested)
} else {
workdir.join(requested)
};
let mut normalized = PathBuf::new();
for component in raw.components() {
match component {
Component::ParentDir => {
if !normalized.pop() {
anyhow::bail!("path '{requested}' cannot be resolved");
}
}
other => normalized.push(other),
}
}
let Some(canonical) = canon(&normalized) else {
anyhow::bail!("path '{requested}' cannot be verified against [read_paths]");
};
match policy.decide(&canonical) {
leviath_core::ReadPathDecision::Allowed => Ok(canonical),
leviath_core::ReadPathDecision::NotDeclared => anyhow::bail!(
"path '{requested}' is outside the working directory and not in this \
agent's [read_paths]"
),
leviath_core::ReadPathDecision::NotGranted => anyhow::bail!(
"path '{requested}' matches this agent's [read_paths], but your config \
does not grant it; add it under [agent_read_paths.{agent}] (or set \
allow_blueprint_read_paths = true under [security]) in your config.toml",
agent = policy.agent
),
}
}
pub(crate) fn resolve_within(
requested: &str,
workdir: &Path,
within: fn(&Path, &Path) -> bool,
) -> anyhow::Result<PathBuf> {
if is_null_device(requested) {
return Ok(PathBuf::from(requested));
}
let raw = if Path::new(requested).is_absolute() {
PathBuf::from(requested)
} else {
workdir.join(requested)
};
let mut normalized = PathBuf::new();
for component in raw.components() {
match component {
Component::ParentDir => {
if !normalized.pop() {
anyhow::bail!("path '{}' escapes the working directory", requested);
}
}
c => normalized.push(c),
}
}
if !normalized.starts_with(workdir) {
anyhow::bail!(
"path '{}' would escape the working directory ({}). Use a path \
inside the workspace instead - a relative path resolves \
against it.",
requested,
workdir.display()
);
}
if !within(&normalized, workdir) {
anyhow::bail!(
"path '{requested}' resolves outside the working directory through a symlink"
);
}
Ok(normalized)
}
pub(crate) async fn read_file(&self, args: &Value) -> String {
let path_str = match args.get("path").and_then(|v| v.as_str()) {
Some(p) => p,
None => return "[error] missing 'path' argument".to_string(),
};
let path = match self.resolve_read(path_str) {
Ok(p) => p,
Err(e) => return format!("[error] {}", e),
};
match std::fs::read_to_string(&path) {
Ok(content) => cap_file_content(&content, MAX_READ_FILE_BYTES),
Err(e) => format!("[error] Failed to read '{}': {}", path_str, e),
}
}
pub(crate) async fn read_files(&self, args: &Value) -> String {
let paths = match args.get("paths").and_then(|v| v.as_array()) {
Some(arr) => arr,
None => return "[error] missing 'paths' argument (expected array)".to_string(),
};
if paths.is_empty() {
return "[error] 'paths' array is empty".to_string();
}
let mut results = Vec::with_capacity(paths.len());
for path_val in paths {
let path_str = match path_val.as_str() {
Some(p) => p,
None => {
results.push("[error] non-string path in array".to_string());
continue;
}
};
let path = match self.resolve_read(path_str) {
Ok(p) => p,
Err(e) => {
results.push(format!("### [{}]\n[error] {}", path_str, e));
continue;
}
};
match std::fs::read_to_string(&path) {
Ok(content) => {
results.push(format!("### [{}]\n{}", path_str, content));
}
Err(e) => {
results.push(format!("### [{}]\n[error] Failed to read: {}", path_str, e));
}
}
}
results.join("\n\n")
}
pub(crate) async fn write_file(&self, args: &Value) -> String {
let path_str = match args.get("path").and_then(|v| v.as_str()) {
Some(p) => p,
None => return "[error] missing 'path' argument".to_string(),
};
let content = match args.get("content").and_then(|v| v.as_str()) {
Some(c) => c,
None => return "[error] missing 'content' argument".to_string(),
};
if let Err(e) = self.ensure_workspace() {
return e;
}
let path = match self.resolve(path_str) {
Ok(p) => p,
Err(e) => return format!("[error] {}", e),
};
let lock = self.ctx.lock_for(&path);
let _guard = lock.lock().await;
let parent = {
let mut p = path.clone();
p.pop();
p
};
if let Err(e) = std::fs::create_dir_all(&parent) {
return format!(
"[error] Failed to create directories for '{}': {}",
path_str, e
);
}
match std::fs::write(&path, content) {
Ok(()) => format!(
"Successfully wrote {} bytes to '{}'",
content.len(),
path_str
),
Err(e) => format!("[error] Failed to write '{}': {}", path_str, e),
}
}
pub(crate) async fn edit_file(&self, args: &Value) -> String {
let path_str = match args.get("path").and_then(|v| v.as_str()) {
Some(p) => p,
None => return "[error] missing 'path' argument".to_string(),
};
let old_str = match args.get("old_str").and_then(|v| v.as_str()) {
Some(s) => s,
None => return "[error] missing 'old_str' argument".to_string(),
};
let new_str = match args.get("new_str").and_then(|v| v.as_str()) {
Some(s) => s,
None => return "[error] missing 'new_str' argument".to_string(),
};
if let Err(e) = self.ensure_workspace() {
return e;
}
let path = match self.resolve(path_str) {
Ok(p) => p,
Err(e) => return format!("[error] {}", e),
};
let lock = self.ctx.lock_for(&path);
let _guard = lock.lock().await;
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => return format!("[error] Failed to read '{}': {}", path_str, e),
};
let count = content.matches(old_str).count();
match count {
0 => format!(
"[error] String not found in '{}'. Ensure old_str matches the file exactly.",
path_str
),
1 => {
let new_content = content.replacen(old_str, new_str, 1);
match std::fs::write(&path, &new_content) {
Ok(()) => format!("Successfully edited '{}'", path_str),
Err(e) => format!("[error] Failed to write '{}': {}", path_str, e),
}
}
n => format!(
"[error] Found {} occurrences of the string in '{}'. old_str must be unique.",
n, path_str
),
}
}
pub(crate) async fn list_dir(&self, args: &Value) -> String {
let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let path = match self.resolve_read(path_str) {
Ok(p) => p,
Err(e) => return format!("[error] {}", e),
};
let entries = match std::fs::read_dir(&path) {
Ok(e) => e,
Err(e) => return format!("[error] Failed to read directory '{}': {}", path_str, e),
};
let mut items: Vec<_> = entries.filter_map(|e| e.ok()).collect();
items.sort_by_key(|e| e.file_name());
let mut lines = Vec::new();
for entry in items {
let name = entry.file_name().to_string_lossy().to_string();
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
if is_dir {
lines.push(format!("{}/", name));
} else {
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
lines.push(format!("{} ({}B)", name, size));
}
}
if lines.is_empty() {
format!("(empty directory: {})", path_str)
} else {
lines.join("\n")
}
}
pub(crate) fn detect_shell() -> (&'static str, &'static str) {
Self::detect_shell_for(
std::env::consts::OS,
std::env::var("SHELL").ok(),
&Self::shell_path_exists,
)
}
pub(crate) fn shell_path_exists(path: &str) -> bool {
std::path::Path::new(path).exists()
}
pub(crate) fn detect_shell_for(
os: &str,
env_shell: Option<String>,
shell_exists: &dyn Fn(&str) -> bool,
) -> (&'static str, &'static str) {
if os == "windows" {
return ("cmd.exe", "/C");
}
if let Some(shell) = env_shell
&& (shell.ends_with("/zsh") || shell.ends_with("/bash") || shell.ends_with("/sh"))
&& shell_exists(&shell)
{
let shell: &'static str = Box::leak(shell.into_boxed_str());
return (shell, "-c");
}
for &shell in &[
"/bin/bash",
"/usr/bin/bash",
"/bin/zsh",
"/usr/bin/zsh",
"/bin/sh",
] {
if shell_exists(shell) {
return (shell, "-c");
}
}
("sh", "-c")
}
pub(crate) async fn shell(&self, args: &Value) -> String {
self.shell_with_timeout(args, Duration::from_secs(60)).await
}
pub(crate) async fn shell_with_timeout(
&self,
args: &Value,
timeout_duration: Duration,
) -> String {
self.shell_with_limits(args, timeout_duration, MAX_CAPTURE_BYTES)
.await
}
pub(crate) async fn shell_with_limits(
&self,
args: &Value,
timeout_duration: Duration,
cap: usize,
) -> String {
let command = match args.get("command").and_then(|v| v.as_str()) {
Some(c) => c,
None => return "[error] missing 'command' argument".to_string(),
};
let workdir = self.ctx.workdir.clone();
let (shell, flag) = Self::detect_shell();
let mut cmd = match &self.shell_executor {
Some(executor) => executor.build_command(shell, flag, command, &workdir),
None => {
let mut c = crate::platform::child_command(shell);
c.arg(flag).arg(command).current_dir(&workdir);
c
}
};
cmd.kill_on_drop(true);
own_process_group(&mut cmd);
self.ctx.shell_env.apply(&mut cmd);
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let run = async {
let mut child = cmd.spawn()?;
let _reaper = child.id().map(ProcessGroupReaper);
let mut out = child.stdout.take().expect("stdout was piped");
let mut err = child.stderr.take().expect("stderr was piped");
let (stdout, stderr, status) = tokio::join!(
capture_capped(&mut out, cap),
capture_capped(&mut err, cap),
child.wait(),
);
status.map(|status| (stdout, stderr, status))
};
match timeout(timeout_duration, run).await {
Err(_) => format!("[timed out] Command exceeded 60s: {}", command),
Ok(Err(e)) => format!("[error] Failed to spawn shell '{}': {}", shell, e),
Ok(Ok((stdout, stderr, status))) => {
let body = Self::format_command_output(
&stdout.kept,
&stderr.kept,
status.success(),
status.code().unwrap_or(-1),
);
match capture_note(&stdout, &stderr, cap) {
Some(note) => format!("{body}\n\n{note}"),
None => body,
}
}
}
}
pub(crate) fn format_command_output(
stdout: &[u8],
stderr: &[u8],
success: bool,
exit_code: i32,
) -> String {
let stdout = String::from_utf8_lossy(stdout);
let stderr = String::from_utf8_lossy(stderr);
if success {
if stdout.trim().is_empty() {
"(command succeeded with no output)".to_string()
} else {
stdout.to_string()
}
} else {
let mut result = format!("[exit code {}]\n", exit_code);
if !stdout.trim().is_empty() {
result.push_str(&format!("stdout:\n{}\n", stdout));
}
if !stderr.trim().is_empty() {
result.push_str(&format!("stderr:\n{}", stderr));
}
result
}
}
}
pub(crate) const MAX_CAPTURE_BYTES: usize = 1024 * 1024;
pub fn is_null_device(path: &str) -> bool {
path == "/dev/null" || path.eq_ignore_ascii_case("nul")
}
pub(crate) const MAX_READ_FILE_BYTES: usize = 256 * 1024;
pub(crate) fn cap_file_content(content: &str, cap: usize) -> String {
if content.len() <= cap {
return content.to_string();
}
let kept = leviath_core::text::substring(content, 0, cap);
format!(
"{kept}\n[truncated] The file is {} bytes; the first {} are shown. Read a range, or \
narrow with a search, rather than re-reading the whole file.",
content.len(),
kept.len(),
)
}
#[derive(Debug)]
pub(crate) struct Captured {
pub(crate) kept: Vec<u8>,
pub(crate) total: u64,
}
pub(crate) async fn capture_capped(
stream: &mut (dyn tokio::io::AsyncRead + Unpin + Send),
cap: usize,
) -> Captured {
use tokio::io::AsyncReadExt;
let mut kept: Vec<u8> = Vec::new();
let mut total: u64 = 0;
let mut buf = [0u8; 8192];
loop {
let n = match stream.read(&mut buf).await {
Ok(0) | Err(_) => return Captured { kept, total },
Ok(n) => n,
};
total += n as u64;
if kept.len() < cap {
let room = cap - kept.len();
kept.extend_from_slice(&buf[..n.min(room)]);
}
}
}
pub(crate) fn capture_note(stdout: &Captured, stderr: &Captured, cap: usize) -> Option<String> {
let lost = |c: &Captured| c.total > c.kept.len() as u64;
let which = match (lost(stdout), lost(stderr)) {
(false, false) => return None,
(true, false) => "stdout",
(false, true) => "stderr",
(true, true) => "stdout and stderr",
};
let total = stdout.total + stderr.total;
Some(format!(
"[truncated] The command wrote {total} bytes; {which} exceeded the {cap}-byte capture \
limit and only the beginning is shown. Narrow the command (a filter, a line count, a \
smaller range) rather than re-running it."
))
}