use std::path::{Path, PathBuf};
use std::time::Duration;
use async_trait::async_trait;
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::{Error, Result};
use crate::tools::{
image_mime_for, is_image_path, network_checked_redirect_policy, Tool, ToolContext,
MULTIMODAL_IMAGE_MARKER, NOTEBOOK_EXTENSION,
};
pub(crate) const MAX_READ_BYTES: usize = 400_000;
const DEFAULT_BASH_TIMEOUT_MS: u64 = 120_000;
fn parse_args<T: DeserializeOwned>(tool: &str, args: Value) -> Result<T> {
serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
tool: tool.to_string(),
message: e.to_string(),
})
}
fn rel(ctx: &ToolContext, p: &Path) -> String {
p.strip_prefix(&ctx.cwd)
.unwrap_or(p)
.to_string_lossy()
.into_owned()
}
fn base64_encode(bytes: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b0 = chunk[0];
let b1 = chunk.get(1).copied();
let b2 = chunk.get(2).copied();
out.push(ALPHABET[(b0 >> 2) as usize] as char);
out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1.unwrap_or(0) >> 4)) as usize] as char);
match b1 {
Some(b1) => {
out.push(ALPHABET[(((b1 & 0x0f) << 2) | (b2.unwrap_or(0) >> 6)) as usize] as char)
}
None => out.push('='),
}
match b2 {
Some(b2) => out.push(ALPHABET[(b2 & 0x3f) as usize] as char),
None => out.push('='),
}
}
out
}
fn image_tool_result(path: &Path, bytes: &[u8]) -> String {
let mime = image_mime_for(path);
let b64 = base64_encode(bytes);
format!("{MULTIMODAL_IMAGE_MARKER}data:{mime};base64,{b64}")
}
fn nested_instructions_notice(ctx: &ToolContext, touched: &Path) -> Option<String> {
if !ctx.nested_instructions {
return None;
}
let dir = if touched.is_dir() {
touched.to_path_buf()
} else {
touched.parent()?.to_path_buf()
};
if !crate::agent::import_target_is_contained(&dir, &ctx.cwd) {
return None;
}
let root = std::fs::canonicalize(&ctx.cwd).unwrap_or_else(|_| ctx.cwd.clone());
let real_dir = std::fs::canonicalize(&dir).ok()?;
if real_dir == root {
return None;
}
let mut found: Option<(PathBuf, String)> = None;
for name in ["CLAUDE.md", "AGENTS.md"] {
let candidate = dir.join(name);
if let Ok(content) = std::fs::read_to_string(&candidate) {
found = Some((candidate, content));
break;
}
}
let (candidate, content) = found?;
{
let mut seen = ctx.injected_instruction_dirs.lock().ok()?;
if !seen.insert(real_dir) {
return None;
}
}
let shown = rel(ctx, &candidate);
Some(format!(
"\n\n[nested instructions from {shown}]\n{}",
content.trim()
))
}
pub struct ReadFileTool;
#[derive(Deserialize)]
struct ReadArgs {
path: String,
#[serde(default)]
offset: Option<usize>,
#[serde(default)]
limit: Option<usize>,
}
#[async_trait]
impl Tool for ReadFileTool {
fn name(&self) -> &str {
"read_file"
}
fn description(&self) -> &str {
"Read the contents of a UTF-8 text file. Large files are returned truncated from the start (with a notice stating the true size); pass `offset` (1-based start line) and/or `limit` (number of lines) to read further slices."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path, absolute or relative to the working directory."},
"offset": {"type": "integer", "description": "1-based line to start at."},
"limit": {"type": "integer", "description": "Maximum number of lines to return."}
},
"required": ["path"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
let a: ReadArgs = parse_args(self.name(), args)?;
let path = ctx.resolve(&a.path);
let bytes = tokio::fs::read(&path)
.await
.map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
if ctx.multimodal_read && is_image_path(&path) {
ctx.mark_read(&path);
return Ok(image_tool_result(&path, &bytes));
}
let text = String::from_utf8_lossy(&bytes);
let result = if a.offset.is_none() && a.limit.is_none() {
if bytes.len() > MAX_READ_BYTES {
let total = bytes.len();
let mut end = MAX_READ_BYTES.min(text.len());
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
if let Some(nl) = text[..end].rfind('\n') {
end = nl + 1;
}
let shown = end;
let lines = text[..end].matches('\n').count();
let notice = format!(
"[read_file: file is {total} bytes; showing first {shown} bytes ({lines} lines). Pass offset/limit to read more.]\n"
);
notice + &text[..end]
} else {
text.into_owned()
}
} else {
let start = a.offset.unwrap_or(1).saturating_sub(1);
let limit = a.limit.unwrap_or(usize::MAX);
let sliced: Vec<&str> = text.lines().skip(start).take(limit).collect();
sliced.join("\n")
};
ctx.mark_read(&path);
let mut result = result;
if let Some(notice) = nested_instructions_notice(ctx, &path) {
result.push_str(¬ice);
}
Ok(result)
}
}
pub struct ViewImageTool;
#[derive(Deserialize)]
struct ViewImageArgs {
path: String,
}
#[async_trait]
impl Tool for ViewImageTool {
fn name(&self) -> &str {
"view_image"
}
fn description(&self) -> &str {
"Read a local image file and return it as a model-visible image content block."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Image file path, absolute or relative to the working directory."}
},
"required": ["path"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
let a: ViewImageArgs = parse_args(self.name(), args)?;
let path = ctx.resolve(&a.path);
if !is_image_path(&path) {
return Err(Error::tool(
self.name(),
format!(
"{} is not a recognized image file (expected one of: png, jpg, jpeg, gif, webp, bmp)",
path.display()
),
));
}
let bytes = tokio::fs::read(&path)
.await
.map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
ctx.mark_read(&path);
Ok(image_tool_result(&path, &bytes))
}
}
pub struct WriteFileTool;
#[derive(Deserialize)]
struct WriteArgs {
path: String,
content: String,
}
#[async_trait]
impl Tool for WriteFileTool {
fn name(&self) -> &str {
"write_file"
}
fn description(&self) -> &str {
"Create or overwrite a file with the given contents. Parent directories are created as needed."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to write."},
"content": {"type": "string", "description": "Full file contents."}
},
"required": ["path", "content"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
let a: WriteArgs = parse_args(self.name(), args)?;
let path = ctx.resolve(&a.path);
ctx.check_write(&path)?;
if let Some(obs) = &ctx.write_observer {
obs.before_write(&path).await;
}
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
tokio::fs::write(&path, a.content.as_bytes())
.await
.map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
let mut annotation = String::new();
if let Some(obs) = &ctx.write_observer {
if let Some(note) = obs.after_write(&path).await {
annotation = format!("\n\n{note}");
}
}
Ok(format!(
"Wrote {} bytes to {}{}",
a.content.len(),
rel(ctx, &path),
annotation
))
}
}
pub struct EditFileTool;
#[derive(Deserialize)]
struct EditArgs {
path: String,
#[serde(default)]
old_string: String,
#[serde(default)]
new_string: String,
#[serde(default)]
replace_all: bool,
#[serde(default)]
cell_index: Option<usize>,
#[serde(default)]
cell_op: Option<String>,
#[serde(default)]
cell_source: Option<String>,
#[serde(default)]
cell_type: Option<String>,
}
#[async_trait]
impl Tool for EditFileTool {
fn name(&self) -> &str {
"edit_file"
}
fn description(&self) -> &str {
"Replace an exact substring in a file. By default `old_string` must occur exactly once; set `replace_all` to replace every occurrence. When notebook-aware editing is enabled, pass `cell_index`/`cell_op` (`replace`|`insert`|`delete`) instead to edit a Jupyter `.ipynb` cell."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"old_string": {"type": "string", "description": "Exact text to replace."},
"new_string": {"type": "string", "description": "Replacement text."},
"replace_all": {"type": "boolean", "description": "Replace every occurrence instead of requiring uniqueness."},
"cell_index": {"type": "integer", "description": "0-based Jupyter cell index (notebook-aware mode only)."},
"cell_op": {"type": "string", "enum": ["replace", "insert", "delete"], "description": "Notebook cell operation (notebook-aware mode only)."},
"cell_source": {"type": "string", "description": "New cell source text (notebook-aware `replace`/`insert`)."},
"cell_type": {"type": "string", "enum": ["code", "markdown"], "description": "Cell type for notebook-aware `insert` (default `code`)."}
},
"required": ["path"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
let a: EditArgs = parse_args(self.name(), args)?;
let path = ctx.resolve(&a.path);
ctx.check_write(&path)?;
if let Some(obs) = &ctx.write_observer {
obs.before_write(&path).await;
}
if ctx.require_read_before_edit && !ctx.was_read(&path) {
return Err(Error::tool(
self.name(),
format!(
"{} must be read with `read_file` before it can be edited this conversation",
path.display()
),
));
}
if ctx.notebook_aware
&& a.cell_op.is_some()
&& path.extension().and_then(|e| e.to_str()) == Some(NOTEBOOK_EXTENSION)
{
let result = edit_notebook_cell(self.name(), ctx, &path, &a).await?;
let mut annotation = String::new();
if let Some(obs) = &ctx.write_observer {
if let Some(note) = obs.after_write(&path).await {
annotation = format!("\n\n{note}");
}
}
return Ok(format!("{result}{annotation}"));
}
if a.old_string.is_empty() {
return Err(Error::tool(self.name(), "old_string must not be empty"));
}
let original = tokio::fs::read_to_string(&path)
.await
.map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
let count = original.matches(&a.old_string).count();
if count == 0 {
return Err(Error::tool(self.name(), "old_string not found in file"));
}
if count > 1 && !a.replace_all {
return Err(Error::tool(
self.name(),
format!("old_string occurs {count} times; pass replace_all or add more context"),
));
}
let updated = if a.replace_all {
original.replace(&a.old_string, &a.new_string)
} else {
original.replacen(&a.old_string, &a.new_string, 1)
};
tokio::fs::write(&path, updated.as_bytes())
.await
.map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
let mut result = format!(
"Replaced {} occurrence(s) in {}",
if a.replace_all { count } else { 1 },
rel(ctx, &path)
);
if let Some(obs) = &ctx.write_observer {
if let Some(note) = obs.after_write(&path).await {
result.push_str("\n\n");
result.push_str(¬e);
}
}
if let Some(notice) = nested_instructions_notice(ctx, &path) {
result.push_str(¬ice);
}
Ok(result)
}
}
async fn edit_notebook_cell(
tool_name: &str,
ctx: &ToolContext,
path: &Path,
a: &EditArgs,
) -> Result<String> {
let text = tokio::fs::read_to_string(path)
.await
.map_err(|e| Error::tool(tool_name, format!("{}: {e}", path.display())))?;
let mut doc: Value = serde_json::from_str(&text).map_err(|e| {
Error::tool(
tool_name,
format!("{}: not valid notebook JSON: {e}", path.display()),
)
})?;
let cells = doc
.get_mut("cells")
.and_then(|c| c.as_array_mut())
.ok_or_else(|| Error::tool(tool_name, format!("{}: no `cells` array", path.display())))?;
let index = a
.cell_index
.ok_or_else(|| Error::tool(tool_name, "cell_index is required for notebook cell edits"))?;
let op = a.cell_op.as_deref().unwrap_or("replace");
let summary = match op {
"delete" => {
if index >= cells.len() {
return Err(Error::tool(
tool_name,
format!("cell_index {index} out of range (0..{})", cells.len()),
));
}
cells.remove(index);
format!("Deleted cell {index}")
}
"insert" => {
let source = a
.cell_source
.clone()
.ok_or_else(|| Error::tool(tool_name, "cell_source is required for insert"))?;
let cell_type = a.cell_type.as_deref().unwrap_or("code");
let new_cell = json!({
"cell_type": cell_type,
"metadata": {},
"source": [source],
"outputs": if cell_type == "code" { json!([]) } else { json!(null) },
"execution_count": json!(null),
});
if index > cells.len() {
return Err(Error::tool(
tool_name,
format!("cell_index {index} out of range (0..={})", cells.len()),
));
}
cells.insert(index, new_cell);
format!("Inserted a {cell_type} cell at {index}")
}
"replace" => {
let source = a
.cell_source
.clone()
.ok_or_else(|| Error::tool(tool_name, "cell_source is required for replace"))?;
let len = cells.len();
let cell = cells.get_mut(index).ok_or_else(|| {
Error::tool(
tool_name,
format!("cell_index {index} out of range (0..{len})"),
)
})?;
cell["source"] = json!([source]);
format!("Replaced source of cell {index}")
}
other => {
return Err(Error::tool(
tool_name,
format!("unknown cell_op `{other}` (expected replace|insert|delete)"),
))
}
};
let rendered =
serde_json::to_string_pretty(&doc).map_err(|e| Error::tool(tool_name, e.to_string()))?;
tokio::fs::write(path, rendered.as_bytes())
.await
.map_err(|e| Error::tool(tool_name, format!("{}: {e}", path.display())))?;
Ok(format!("{summary} in {}", rel(ctx, path)))
}
pub struct ListDirTool;
#[derive(Deserialize)]
struct ListArgs {
#[serde(default)]
path: Option<String>,
}
#[async_trait]
impl Tool for ListDirTool {
fn name(&self) -> &str {
"list_dir"
}
fn description(&self) -> &str {
"List the entries of a directory (defaults to the working directory). Directories are suffixed with `/`."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Directory to list. Defaults to the working directory."}
},
"additionalProperties": false
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
let a: ListArgs = parse_args(self.name(), args)?;
let dir = match a.path {
Some(p) => ctx.resolve(&p),
None => ctx.cwd.clone(),
};
let mut rd = tokio::fs::read_dir(&dir)
.await
.map_err(|e| Error::tool(self.name(), format!("{}: {e}", dir.display())))?;
let mut entries = Vec::new();
while let Some(e) = rd
.next_entry()
.await
.map_err(|e| Error::tool(self.name(), e.to_string()))?
{
let name = e.file_name().to_string_lossy().into_owned();
let is_dir = e.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
entries.push(if is_dir { format!("{name}/") } else { name });
}
entries.sort();
if entries.is_empty() {
Ok("(empty directory)".to_string())
} else {
Ok(entries.join("\n"))
}
}
}
pub struct GlobTool;
#[derive(Deserialize)]
struct GlobArgs {
pattern: String,
}
#[async_trait]
impl Tool for GlobTool {
fn name(&self) -> &str {
"glob"
}
fn description(&self) -> &str {
"Find files matching a glob pattern (e.g. `src/**/*.rs`), relative to the working directory."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Glob pattern, e.g. **/*.rs"}
},
"required": ["pattern"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
let a: GlobArgs = parse_args(self.name(), args)?;
let cwd = ctx.cwd.clone();
let full = if PathBuf::from(&a.pattern).is_absolute() {
a.pattern.clone()
} else {
cwd.join(&a.pattern).to_string_lossy().into_owned()
};
let cwd2 = cwd.clone();
let matches = tokio::task::spawn_blocking(move || {
let mut out = Vec::new();
if let Ok(paths) = glob::glob(&full) {
for p in paths.flatten() {
let display = p
.strip_prefix(&cwd2)
.unwrap_or(&p)
.to_string_lossy()
.into_owned();
out.push(display);
}
}
out
})
.await
.map_err(|e| Error::tool("glob", e.to_string()))?;
if matches.is_empty() {
Ok("(no matches)".to_string())
} else {
Ok(matches.join("\n"))
}
}
}
pub struct SearchTool;
#[derive(Deserialize)]
struct SearchArgs {
pattern: String,
#[serde(default)]
path: Option<String>,
#[serde(default)]
max_results: Option<usize>,
}
#[async_trait]
impl Tool for SearchTool {
fn name(&self) -> &str {
"search"
}
fn description(&self) -> &str {
"Search file contents with a regular expression, respecting .gitignore. Returns `path:line: text` matches."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Regular expression to search for."},
"path": {"type": "string", "description": "Directory or file to search. Defaults to the working directory."},
"max_results": {"type": "integer", "description": "Cap on the number of matches (default 200)."}
},
"required": ["pattern"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
let a: SearchArgs = parse_args(self.name(), args)?;
let re = regex::Regex::new(&a.pattern)
.map_err(|e| Error::tool(self.name(), format!("invalid regex: {e}")))?;
let root = match a.path {
Some(p) => ctx.resolve(&p),
None => ctx.cwd.clone(),
};
let cwd = ctx.cwd.clone();
let cap = a.max_results.unwrap_or(200);
let results = tokio::task::spawn_blocking(move || {
let mut out: Vec<String> = Vec::new();
let walker = ignore::WalkBuilder::new(&root).build();
'outer: for entry in walker.flatten() {
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let path = entry.path();
let Ok(content) = std::fs::read_to_string(path) else {
continue; };
for (i, line) in content.lines().enumerate() {
if re.is_match(line) {
let rel = path.strip_prefix(&cwd).unwrap_or(path);
out.push(format!("{}:{}: {}", rel.display(), i + 1, line.trim_end()));
if out.len() >= cap {
break 'outer;
}
}
}
}
out
})
.await
.map_err(|e| Error::tool("search", e.to_string()))?;
if results.is_empty() {
Ok("(no matches)".to_string())
} else {
Ok(results.join("\n"))
}
}
}
pub struct BashTool {
default_timeout_ms: u64,
}
#[cfg(unix)]
struct BashProcessTreeGuard(Option<u32>);
#[cfg(windows)]
struct BashProcessTreeGuard(Option<usize>);
#[cfg(not(any(unix, windows)))]
struct BashProcessTreeGuard;
impl BashProcessTreeGuard {
#[cfg(unix)]
fn prepare() -> std::io::Result<Self> {
Ok(Self(None))
}
#[cfg(windows)]
fn prepare() -> std::io::Result<Self> {
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::JobObjects::{
CreateJobObjectW, JobObjectExtendedLimitInformation, SetInformationJobObject,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if job.is_null() {
return Err(std::io::Error::last_os_error());
}
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let configured = unsafe {
SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
std::ptr::addr_of!(limits).cast(),
std::mem::size_of_val(&limits) as u32,
)
};
if configured == 0 {
let error = std::io::Error::last_os_error();
unsafe {
CloseHandle(job);
}
return Err(error);
}
Ok(Self(Some(job as usize)))
}
#[cfg(not(any(unix, windows)))]
fn prepare() -> std::io::Result<Self> {
Ok(Self)
}
fn configure_command(&self, command: &mut tokio::process::Command) {
#[cfg(unix)]
command.process_group(0);
#[cfg(windows)]
command.creation_flags(windows_sys::Win32::System::Threading::CREATE_SUSPENDED);
}
#[cfg(unix)]
fn attach_and_start(&mut self, child: &tokio::process::Child) -> std::io::Result<()> {
self.0 = child.id();
Ok(())
}
#[cfg(windows)]
fn attach_and_start(&mut self, child: &tokio::process::Child) -> std::io::Result<()> {
use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
let job = self.0.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"command Job Object is closed",
)
})? as windows_sys::Win32::Foundation::HANDLE;
let process = child.raw_handle().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"suspended command has no process handle",
)
})?;
if unsafe { AssignProcessToJobObject(job, process.cast()) } == 0 {
return Err(std::io::Error::last_os_error());
}
Self::resume_primary_thread(child.id().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"suspended command has no process id",
)
})?)
}
#[cfg(windows)]
fn resume_primary_thread(process_id: u32) -> std::io::Result<()> {
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32,
};
use windows_sys::Win32::System::Threading::{
OpenThread, ResumeThread, THREAD_SUSPEND_RESUME,
};
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
if snapshot == INVALID_HANDLE_VALUE {
return Err(std::io::Error::last_os_error());
}
let result = (|| {
let mut entry: THREADENTRY32 = unsafe { std::mem::zeroed() };
entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
let mut has_entry = unsafe { Thread32First(snapshot, &mut entry) } != 0;
while has_entry {
if entry.th32OwnerProcessID == process_id {
let thread =
unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
if thread.is_null() {
return Err(std::io::Error::last_os_error());
}
let resumed = unsafe { ResumeThread(thread) };
unsafe {
CloseHandle(thread);
}
if resumed == u32::MAX {
return Err(std::io::Error::last_os_error());
}
return Ok(());
}
has_entry = unsafe { Thread32Next(snapshot, &mut entry) } != 0;
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"suspended command's primary thread was not found",
))
})();
unsafe {
CloseHandle(snapshot);
}
result
}
#[cfg(not(any(unix, windows)))]
fn attach_and_start(&mut self, _child: &tokio::process::Child) -> std::io::Result<()> {
Ok(())
}
fn kill(&mut self) {
#[cfg(unix)]
if let Some(pid) = self.0.take() {
crate::lsp::kill_process_group(pid);
}
#[cfg(windows)]
if let Some(job) = self.0.take() {
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::JobObjects::TerminateJobObject;
let job = job as windows_sys::Win32::Foundation::HANDLE;
unsafe {
TerminateJobObject(job, 1);
CloseHandle(job);
}
}
}
}
impl Drop for BashProcessTreeGuard {
fn drop(&mut self) {
self.kill();
}
}
impl Default for BashTool {
fn default() -> Self {
BashTool {
default_timeout_ms: DEFAULT_BASH_TIMEOUT_MS,
}
}
}
#[derive(Deserialize)]
struct BashArgs {
command: String,
#[serde(default)]
timeout_ms: Option<u64>,
}
#[async_trait]
impl Tool for BashTool {
fn name(&self) -> &str {
"bash"
}
fn description(&self) -> &str {
"Execute a shell command via `sh -c` in the working directory and return its combined stdout/stderr and exit code."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"command": {"type": "string", "description": "Shell command to run."},
"timeout_ms": {"type": "integer", "description": "Timeout in milliseconds (default 120000)."}
},
"required": ["command"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
let a: BashArgs = parse_args(self.name(), args)?;
let effective_default_ms = ctx
.bash_timeout_secs
.map(|s| s.saturating_mul(1000))
.unwrap_or(self.default_timeout_ms);
let timeout = Duration::from_millis(a.timeout_ms.unwrap_or(effective_default_ms));
let deadline = tokio::time::Instant::now() + timeout;
let mut cmd = build_sandboxed_sh(&a.command, ctx)?;
cmd.current_dir(&ctx.cwd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let mut process_tree = BashProcessTreeGuard::prepare()
.map_err(|error| Error::tool(self.name(), error.to_string()))?;
process_tree.configure_command(&mut cmd);
let mut child = cmd
.spawn()
.map_err(|error| Error::tool(self.name(), error.to_string()))?;
process_tree
.attach_and_start(&child)
.map_err(|error| Error::tool(self.name(), error.to_string()))?;
let mut stdout = child
.stdout
.take()
.ok_or_else(|| Error::tool(self.name(), "spawned command has no stdout"))?;
let mut stderr = child
.stderr
.take()
.ok_or_else(|| Error::tool(self.name(), "spawned command has no stderr"))?;
let mut stdout_task = tokio::spawn(async move {
let mut bytes = Vec::new();
let result = stdout.read_to_end(&mut bytes).await;
(result, bytes)
});
let mut stderr_task = tokio::spawn(async move {
let mut bytes = Vec::new();
let result = stderr.read_to_end(&mut bytes).await;
(result, bytes)
});
let status = match tokio::time::timeout_at(deadline, child.wait()).await {
Ok(Ok(status)) => status,
Ok(Err(error)) => {
process_tree.kill();
let _ = child.start_kill();
let _ = tokio::time::timeout(Duration::from_secs(1), child.wait()).await;
stdout_task.abort();
stderr_task.abort();
return Err(Error::tool(self.name(), error.to_string()));
}
Err(_) => {
process_tree.kill();
let _ = child.start_kill();
let _ = tokio::time::timeout(Duration::from_secs(1), child.wait()).await;
stdout_task.abort();
stderr_task.abort();
return Err(Error::tool(
self.name(),
format!("command timed out after {timeout:?}"),
));
}
};
process_tree.kill();
let pipe_output = tokio::time::timeout_at(deadline, async {
let (stdout_result, stdout) = (&mut stdout_task)
.await
.map_err(|error| Error::tool(self.name(), error.to_string()))?;
stdout_result.map_err(|error| Error::tool(self.name(), error.to_string()))?;
let (stderr_result, stderr) = (&mut stderr_task)
.await
.map_err(|error| Error::tool(self.name(), error.to_string()))?;
stderr_result.map_err(|error| Error::tool(self.name(), error.to_string()))?;
Ok::<_, Error>((stdout, stderr))
})
.await;
let (stdout, stderr) = match pipe_output {
Ok(result) => result?,
Err(_) => {
stdout_task.abort();
stderr_task.abort();
return Err(Error::tool(
self.name(),
format!("command timed out after {timeout:?}"),
));
}
};
let mut buf = String::new();
let stdout = String::from_utf8_lossy(&stdout);
let stderr = String::from_utf8_lossy(&stderr);
if !stdout.is_empty() {
buf.push_str(&stdout);
}
if !stderr.is_empty() {
if !buf.is_empty() && !buf.ends_with('\n') {
buf.push('\n');
}
buf.push_str(&stderr);
}
let code = status.code().unwrap_or(-1);
if buf.is_empty() {
buf.push_str("(no output)");
}
Ok(format!("exit code: {code}\n{buf}"))
}
}
struct SandboxPlan {
confine_fs: bool,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fs_allow_writes: bool,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
confine_net: bool,
}
fn resolve_sandbox_plan(ctx: &ToolContext, subject: &str) -> Result<SandboxPlan> {
use crate::sandbox::{decide_fs, decide_net, warn_once, FsDecision, NetDecision};
let fs_available = cfg!(target_os = "macos") || crate::sandbox::landlock_available();
let approval = ctx.sandbox_approval_handler.as_deref();
let fs_decision = decide_fs(
ctx.sandbox,
ctx.sandbox_os_enabled,
fs_available,
ctx.sandbox_escalation,
approval,
subject,
);
let confine_fs = match fs_decision {
FsDecision::NotRequested => false,
FsDecision::Confine => true,
FsDecision::RunUnconfinedWithWarning { reason } => {
warn_once(&reason);
false
}
FsDecision::Refuse { reason } => return Err(Error::tool("sandbox", reason)),
};
let network_enabled = ctx
.network_policy
.as_ref()
.map(|p| p.enabled)
.unwrap_or(false);
let has_domain_rules = ctx
.network_policy
.as_ref()
.map(|p| !p.allow_domains.is_empty() || !p.deny_domains.is_empty())
.unwrap_or(false);
let net_available = cfg!(target_os = "linux") && crate::sandbox::netns_available();
let net_decision = decide_net(network_enabled, has_domain_rules, net_available);
let confine_net = match net_decision {
NetDecision::NotRequested => false,
NetDecision::Confine => true,
NetDecision::GapWarn { reason } => {
warn_once(&reason);
false
}
};
Ok(SandboxPlan {
confine_fs,
fs_allow_writes: ctx.sandbox == crate::tools::SandboxPolicy::WorkspaceWrite,
confine_net,
})
}
#[cfg(target_os = "linux")]
fn apply_linux_plan(cmd: &mut tokio::process::Command, ctx: &ToolContext, plan: &SandboxPlan) {
if !plan.confine_fs && !plan.confine_net {
return;
}
let cwd = crate::safe_path::resolve_real(&ctx.cwd).unwrap_or_else(|| ctx.cwd.clone());
let tmp_dir = std::env::temp_dir();
let tmp = crate::safe_path::resolve_real(&tmp_dir).unwrap_or(tmp_dir);
crate::sandbox::apply_linux_confinement(
cmd,
plan.confine_fs,
plan.fs_allow_writes,
cwd,
vec![tmp],
plan.confine_net,
);
}
#[cfg(not(target_os = "linux"))]
fn apply_linux_plan(_cmd: &mut tokio::process::Command, _ctx: &ToolContext, _plan: &SandboxPlan) {}
fn apply_sandbox_env_policy(cmd: &mut tokio::process::Command, ctx: &ToolContext) {
if ctx.sandbox_env_policy == crate::sandbox::SandboxEnvPolicy::Inherit {
if let Some(snapshot) = &ctx.shell_env {
cmd.envs(snapshot.iter().map(|(k, v)| (k.as_str(), v.as_str())));
}
return;
}
let mut base: Vec<(String, String)> = std::env::vars().collect();
if let Some(snapshot) = &ctx.shell_env {
for (k, v) in snapshot.iter() {
match base.iter_mut().find(|(bk, _)| bk == k) {
Some(entry) => entry.1 = v.clone(),
None => base.push((k.clone(), v.clone())),
}
}
}
let filtered = crate::sandbox::apply_env_policy(ctx.sandbox_env_policy, base);
cmd.env_clear();
cmd.envs(filtered);
}
pub(crate) fn build_sandboxed_sh(
command: &str,
ctx: &ToolContext,
) -> Result<tokio::process::Command> {
let plan = resolve_sandbox_plan(ctx, command)?;
#[cfg(target_os = "macos")]
{
if plan.confine_fs {
if let Some(profile) = seatbelt_profile(ctx) {
let mut cmd = tokio::process::Command::new("sandbox-exec");
cmd.arg("-p").arg(profile).arg("sh").arg("-c").arg(command);
apply_sandbox_env_policy(&mut cmd, ctx);
return Ok(cmd);
}
}
}
let mut cmd = tokio::process::Command::new("sh");
cmd.arg("-c").arg(command);
apply_linux_plan(&mut cmd, ctx, &plan);
apply_sandbox_env_policy(&mut cmd, ctx);
Ok(cmd)
}
fn build_sandboxed_interactive_sh(ctx: &ToolContext) -> Result<tokio::process::Command> {
let plan = resolve_sandbox_plan(ctx, "<persistent shell>")?;
#[cfg(target_os = "macos")]
{
if plan.confine_fs {
if let Some(profile) = seatbelt_profile(ctx) {
let mut cmd = tokio::process::Command::new("sandbox-exec");
cmd.arg("-p").arg(profile).arg("sh");
apply_sandbox_env_policy(&mut cmd, ctx);
return Ok(cmd);
}
}
}
let mut cmd = tokio::process::Command::new("sh");
apply_linux_plan(&mut cmd, ctx, &plan);
apply_sandbox_env_policy(&mut cmd, ctx);
Ok(cmd)
}
#[cfg(target_os = "macos")]
fn seatbelt_profile(ctx: &ToolContext) -> Option<String> {
use crate::tools::SandboxPolicy;
match ctx.sandbox {
SandboxPolicy::DangerFullAccess => None,
SandboxPolicy::ReadOnly => Some("(version 1)(allow default)(deny file-write*)".to_string()),
SandboxPolicy::WorkspaceWrite => {
let real = crate::safe_path::resolve_real(&ctx.cwd).unwrap_or_else(|| ctx.cwd.clone());
let dir = real.to_string_lossy().replace('"', "");
Some(format!(
"(version 1)(allow default)(deny file-write*)\
(allow file-write* (subpath \"{dir}\"))\
(allow file-write* (literal \"/dev/null\") (literal \"/dev/dtracehelper\") (literal \"/dev/tty\"))"
))
}
}
}
pub struct ApplyPatchTool;
#[derive(Deserialize)]
struct ApplyPatchArgs {
patch: String,
}
enum PatchOp {
Add {
path: String,
body: String,
},
Delete {
path: String,
},
Update {
path: String,
move_to: Option<String>,
hunks: Vec<Hunk>,
},
}
#[derive(Default)]
struct Hunk {
old: Vec<String>,
new: Vec<String>,
anchor: Option<String>,
}
fn parse_patch(patch: &str) -> Result<Vec<PatchOp>> {
let err = |m: &str| Error::tool("apply_patch", m.to_string());
let lines: Vec<&str> = patch.lines().collect();
let mut i = 0;
while i < lines.len() && lines[i].trim() != "*** Begin Patch" {
i += 1;
}
if i == lines.len() {
return Err(err("missing '*** Begin Patch'"));
}
i += 1;
let mut ops = Vec::new();
while i < lines.len() {
let line = lines[i];
let t = line.trim_end();
if t == "*** End Patch" {
return Ok(ops);
} else if let Some(p) = t.strip_prefix("*** Add File: ") {
i += 1;
let mut body = Vec::new();
while i < lines.len() && lines[i].starts_with('+') {
body.push(&lines[i][1..]);
i += 1;
}
ops.push(PatchOp::Add {
path: p.to_string(),
body: body.join("\n"),
});
} else if let Some(p) = t.strip_prefix("*** Delete File: ") {
ops.push(PatchOp::Delete {
path: p.to_string(),
});
i += 1;
} else if let Some(p) = t.strip_prefix("*** Update File: ") {
i += 1;
let mut move_to = None;
if i < lines.len() {
if let Some(m) = lines[i].trim_end().strip_prefix("*** Move to: ") {
move_to = Some(m.to_string());
i += 1;
}
}
let mut hunks = Vec::new();
let mut cur = Hunk::default();
let mut started = false;
while i < lines.len() {
let l = lines[i];
let lt = l.trim_end();
if lt.starts_with("*** ") {
break; }
if let Some(anchor) = lt.strip_prefix("@@") {
if started && (!cur.old.is_empty() || !cur.new.is_empty()) {
hunks.push(std::mem::take(&mut cur));
}
let anchor = anchor.trim();
cur.anchor = (!anchor.is_empty()).then(|| anchor.to_string());
started = true;
i += 1;
continue;
}
started = true;
if let Some(rest) = l.strip_prefix('+') {
cur.new.push(rest.to_string());
} else if let Some(rest) = l.strip_prefix('-') {
cur.old.push(rest.to_string());
} else {
let ctx = l.strip_prefix(' ').unwrap_or(l).to_string();
cur.old.push(ctx.clone());
cur.new.push(ctx);
}
i += 1;
}
if !cur.old.is_empty() || !cur.new.is_empty() {
hunks.push(cur);
}
ops.push(PatchOp::Update {
path: p.to_string(),
move_to,
hunks,
});
} else {
i += 1;
}
}
Err(err("missing '*** End Patch'"))
}
pub(crate) fn patch_target_paths(patch: &str) -> Result<Vec<String>> {
let ops = parse_patch(patch)?;
let mut paths = Vec::with_capacity(ops.len());
for op in ops {
match op {
PatchOp::Add { path, .. } | PatchOp::Delete { path } => paths.push(path),
PatchOp::Update { path, move_to, .. } => {
paths.push(path);
if let Some(m) = move_to {
paths.push(m);
}
}
}
}
Ok(paths)
}
fn apply_update(original: &str, hunks: &[Hunk], tool: &str) -> Result<String> {
let mut text = original.to_string();
for h in hunks {
let from = match &h.anchor {
Some(a) => {
let Some(pos) = text.find(a.as_str()) else {
return Err(Error::tool(tool, format!("@@ anchor not found: {a}")));
};
text[pos..]
.find('\n')
.map(|nl| pos + nl + 1)
.unwrap_or(text.len())
}
None => 0,
};
let new_block = h.new.join("\n");
if h.old.is_empty() {
if h.anchor.is_some() {
let needs_lead_nl = from > 0 && text.as_bytes()[from - 1] != b'\n';
let payload = if needs_lead_nl {
format!("\n{new_block}\n")
} else {
format!("{new_block}\n")
};
text.insert_str(from, &payload);
} else {
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&new_block);
}
continue;
}
let old_block = h.old.join("\n");
let region = &text[from..];
let count = region.matches(&old_block).count();
match count {
0 => {
return Err(Error::tool(
tool,
format!("hunk did not match file contents:\n{old_block}"),
))
}
1 => {
let rel = region.find(&old_block).unwrap();
let start = from + rel;
text.replace_range(start..start + old_block.len(), &new_block);
}
_ => {
return Err(Error::tool(
tool,
format!(
"hunk matches file contents {count} times; add more context lines or a more specific @@ anchor to disambiguate:\n{old_block}"
),
))
}
}
}
Ok(text)
}
#[async_trait]
impl Tool for ApplyPatchTool {
fn name(&self) -> &str {
"apply_patch"
}
fn description(&self) -> &str {
"Apply a patch in the apply_patch envelope format (*** Begin Patch / *** End Patch) with Add File, Delete File, and Update File operations. Update hunks use leading '+'/'-'/' ' on each line and may include '@@' context headers and an optional '*** Move to:' rename."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"patch": {"type": "string", "description": "The full *** Begin Patch … *** End Patch text."}
},
"required": ["patch"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
let a: ApplyPatchArgs = parse_args(self.name(), args)?;
let ops = parse_patch(&a.patch)?;
let mut summary = Vec::new();
let mut annotations: Vec<String> = Vec::new();
for op in ops {
match op {
PatchOp::Add { path, body } => {
let full = ctx.resolve(&path);
ctx.check_write(&full)?;
if let Some(obs) = &ctx.write_observer {
obs.before_write(&full).await;
}
if let Some(parent) = full.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
tokio::fs::write(&full, body.as_bytes())
.await
.map_err(|e| {
Error::tool(self.name(), format!("{}: {e}", full.display()))
})?;
if let Some(obs) = &ctx.write_observer {
if let Some(note) = obs.after_write(&full).await {
annotations.push(note);
}
}
summary.push(format!("A {}", rel(ctx, &full)));
}
PatchOp::Delete { path } => {
let full = ctx.resolve(&path);
ctx.check_write(&full)?;
if let Some(obs) = &ctx.write_observer {
obs.before_write(&full).await;
}
tokio::fs::remove_file(&full).await.map_err(|e| {
Error::tool(self.name(), format!("{}: {e}", full.display()))
})?;
if let Some(obs) = &ctx.write_observer {
if let Some(note) = obs.after_write(&full).await {
annotations.push(note);
}
}
summary.push(format!("D {}", rel(ctx, &full)));
}
PatchOp::Update {
path,
move_to,
hunks,
} => {
let full = ctx.resolve(&path);
let dest_for_check = move_to
.as_ref()
.map(|m| ctx.resolve(m))
.unwrap_or_else(|| full.clone());
ctx.check_write(&dest_for_check)?;
if let Some(obs) = &ctx.write_observer {
obs.before_write(&full).await;
if dest_for_check != full {
obs.before_write(&dest_for_check).await;
}
}
let original = tokio::fs::read_to_string(&full).await.map_err(|e| {
Error::tool(self.name(), format!("{}: {e}", full.display()))
})?;
let updated = apply_update(&original, &hunks, self.name())?;
let dest = match &move_to {
Some(m) => ctx.resolve(m),
None => full.clone(),
};
if let Some(parent) = dest.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
tokio::fs::write(&dest, updated.as_bytes())
.await
.map_err(|e| {
Error::tool(self.name(), format!("{}: {e}", dest.display()))
})?;
if move_to.is_some() && dest != full {
tokio::fs::remove_file(&full).await.ok();
if let Some(obs) = &ctx.write_observer {
if let Some(note) = obs.after_write(&dest).await {
annotations.push(note);
}
}
summary.push(format!("M {} -> {}", rel(ctx, &full), rel(ctx, &dest)));
} else {
if let Some(obs) = &ctx.write_observer {
if let Some(note) = obs.after_write(&full).await {
annotations.push(note);
}
}
summary.push(format!("U {}", rel(ctx, &full)));
}
}
}
}
let annotation = if annotations.is_empty() {
String::new()
} else {
format!("\n\n{}", annotations.join("\n\n"))
};
if summary.is_empty() {
Ok("(empty patch)".to_string())
} else {
Ok(format!(
"Applied patch:\n{}{annotation}",
summary.join("\n")
))
}
}
}
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::Mutex as AsyncMutex;
pub struct PersistentShellTool {
state: AsyncMutex<Option<ShellState>>,
default_timeout_ms: u64,
}
impl Default for PersistentShellTool {
fn default() -> Self {
PersistentShellTool {
state: AsyncMutex::new(None),
default_timeout_ms: DEFAULT_BASH_TIMEOUT_MS,
}
}
}
struct ShellState {
#[allow(dead_code)]
child: tokio::process::Child,
stdin: tokio::process::ChildStdin,
stdout: tokio::io::BufReader<tokio::process::ChildStdout>,
}
const SHELL_SENTINEL: &str = "__SC_SHELL_DONE__";
use std::sync::atomic::{AtomicU64, Ordering};
static SHELL_SENTINEL_SEQ: AtomicU64 = AtomicU64::new(0);
fn shell_sentinel() -> String {
use std::hash::BuildHasher;
let seq = SHELL_SENTINEL_SEQ.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let hash =
std::collections::hash_map::RandomState::new().hash_one((std::process::id(), seq, nanos));
format!("{SHELL_SENTINEL}_{hash:016x}{seq:04x}")
}
#[derive(Deserialize)]
struct ShellArgs {
#[serde(default)]
command: Option<String>,
#[serde(default)]
write_stdin: Option<String>,
#[serde(default)]
timeout_ms: Option<u64>,
}
impl PersistentShellTool {
async fn ensure_started(
&self,
state: &mut Option<ShellState>,
ctx: &ToolContext,
) -> Result<()> {
if state.is_some() {
return Ok(());
}
let mut child = build_sandboxed_interactive_sh(ctx)?
.current_dir(&ctx.cwd)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| Error::tool("shell", format!("spawn sh: {e}")))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| Error::tool("shell", "no stdin"))?;
let stdout = tokio::io::BufReader::new(
child
.stdout
.take()
.ok_or_else(|| Error::tool("shell", "no stdout"))?,
);
*state = Some(ShellState {
child,
stdin,
stdout,
});
Ok(())
}
}
#[async_trait]
impl Tool for PersistentShellTool {
fn name(&self) -> &str {
"shell"
}
fn description(&self) -> &str {
"Run a command in a PERSISTENT shell whose working directory, environment, and shell functions survive across calls (unlike one-shot bash). Pass `command` to run to completion (returns exit code), or `write_stdin` to feed raw input to the shell (for interactive programs)."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"command": {"type": "string", "description": "Command to run in the persistent shell."},
"write_stdin": {"type": "string", "description": "Raw text to write to the shell's stdin instead of running a command."},
"timeout_ms": {"type": "integer", "description": "Timeout in milliseconds (default 120000)."}
},
"additionalProperties": false
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
let a: ShellArgs = parse_args(self.name(), args)?;
let timeout = Duration::from_millis(a.timeout_ms.unwrap_or(self.default_timeout_ms));
let mut guard = self.state.lock().await;
self.ensure_started(&mut guard, ctx).await?;
let st = guard.as_mut().expect("started");
if let Some(input) = a.write_stdin {
st.stdin
.write_all(input.as_bytes())
.await
.map_err(|e| Error::tool(self.name(), format!("write_stdin: {e}")))?;
st.stdin.flush().await.ok();
let out = read_available(&mut st.stdout, Duration::from_millis(800)).await;
return Ok(if out.is_empty() {
"(no output)".into()
} else {
out
});
}
let command = a
.command
.ok_or_else(|| Error::tool(self.name(), "provide `command` or `write_stdin`"))?;
let sentinel = shell_sentinel();
let wrapped = format!("{{ {command}\n}} 2>&1\nprintf '%s %d\\n' '{sentinel}' \"$?\"\n");
st.stdin
.write_all(wrapped.as_bytes())
.await
.map_err(|e| Error::tool(self.name(), format!("write: {e}")))?;
st.stdin.flush().await.ok();
let mut acc = String::new();
let mut code = -1;
let read_fut = async {
let mut chunk = [0u8; 4096];
loop {
let n = st.stdout.read(&mut chunk).await.unwrap_or(0);
if n == 0 {
break; }
acc.push_str(&String::from_utf8_lossy(&chunk[..n]));
if let Some(pos) = acc.find(&sentinel) {
let after = &acc[pos + sentinel.len()..];
if let Some(nl) = after.find('\n') {
code = after[..nl].trim().parse().unwrap_or(-1);
acc.truncate(pos);
break;
}
}
}
};
if tokio::time::timeout(timeout, read_fut).await.is_err() {
return Err(Error::tool(
self.name(),
format!("command timed out after {timeout:?}"),
));
}
let output = if acc.trim().is_empty() {
"(no output)".to_string()
} else {
acc.trim_end().to_string()
};
Ok(format!("exit code: {code}\n{output}"))
}
}
async fn read_available<R: AsyncReadExt + Unpin>(reader: &mut R, window: Duration) -> String {
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
loop {
match tokio::time::timeout(window, reader.read(&mut chunk)).await {
Ok(Ok(0)) => break, Ok(Ok(n)) => buf.extend_from_slice(&chunk[..n]),
Ok(Err(_)) => break,
Err(_) => break, }
}
String::from_utf8_lossy(&buf).into_owned()
}
pub struct UpdatePlanTool {
plan: std::sync::Mutex<Vec<PlanStep>>,
}
impl Default for UpdatePlanTool {
fn default() -> Self {
UpdatePlanTool {
plan: std::sync::Mutex::new(Vec::new()),
}
}
}
#[derive(Deserialize, Clone)]
struct PlanStep {
step: String,
#[serde(default = "default_status")]
status: String,
}
fn default_status() -> String {
"pending".to_string()
}
#[derive(Deserialize)]
struct PlanArgs {
plan: Vec<PlanStep>,
}
impl UpdatePlanTool {
pub fn current(&self) -> Vec<(String, String)> {
self.plan
.lock()
.unwrap()
.iter()
.map(|s| (s.step.clone(), s.status.clone()))
.collect()
}
}
#[async_trait]
impl Tool for UpdatePlanTool {
fn name(&self) -> &str {
"update_plan"
}
fn description(&self) -> &str {
"Record or update the task plan: a checklist of steps with statuses (pending/in_progress/completed). Replaces the current plan. Use it to track multi-step work."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"plan": {
"type": "array",
"items": {
"type": "object",
"properties": {
"step": {"type": "string"},
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}
},
"required": ["step"]
}
}
},
"required": ["plan"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
let a: PlanArgs = parse_args(self.name(), args)?;
*self.plan.lock().unwrap() = a.plan.clone();
let rendered = a
.plan
.iter()
.map(|s| {
let mark = match s.status.as_str() {
"completed" => "[x]",
"in_progress" => "[~]",
_ => "[ ]",
};
format!("{mark} {}", s.step)
})
.collect::<Vec<_>>()
.join("\n");
Ok(if rendered.is_empty() {
"(empty plan)".into()
} else {
format!("Plan updated:\n{rendered}")
})
}
}
fn describe_reqwest_error(e: &reqwest::Error) -> String {
let mut out = e.to_string();
let mut source = std::error::Error::source(e);
while let Some(s) = source {
out.push_str(": ");
out.push_str(&s.to_string());
source = s.source();
}
out
}
pub struct WebFetchTool;
const MAX_FETCH_BYTES: usize = 200_000;
#[derive(Deserialize)]
struct WebFetchArgs {
url: String,
}
#[async_trait]
impl Tool for WebFetchTool {
fn name(&self) -> &str {
"web_fetch"
}
fn description(&self) -> &str {
"Fetch a URL over HTTP(S) and return its response body as text (truncated if large)."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"url": {"type": "string", "description": "The http:// or https:// URL to fetch."}
},
"required": ["url"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
let a: WebFetchArgs = parse_args(self.name(), args)?;
ctx.check_network(&a.url)?;
if !a.url.starts_with("http://") && !a.url.starts_with("https://") {
return Err(Error::tool(self.name(), "url must be http:// or https://"));
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.redirect(network_checked_redirect_policy(ctx.network_policy.clone()))
.build()
.map_err(|e| Error::tool(self.name(), e.to_string()))?;
let resp = client.get(&a.url).send().await.map_err(|e| {
Error::tool(
self.name(),
format!("fetch failed: {}", describe_reqwest_error(&e)),
)
})?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| Error::tool(self.name(), format!("failed to read response body: {e}")))?;
let mut end = MAX_FETCH_BYTES.min(body.len());
while end > 0 && !body.is_char_boundary(end) {
end -= 1;
}
let truncated = body.len() > MAX_FETCH_BYTES;
let shown = &body[..end];
Ok(if truncated {
format!(
"[web_fetch: HTTP {status}; body is {} bytes, showing first {end}]\n{shown}",
body.len()
)
} else {
format!("[web_fetch: HTTP {status}]\n{shown}")
})
}
}
pub struct WebSearchTool;
pub const WEB_SEARCH_URL_ENV: &str = "SUPERCODE_WEB_SEARCH_URL";
#[derive(Deserialize)]
struct WebSearchArgs {
query: String,
}
#[async_trait]
impl Tool for WebSearchTool {
fn name(&self) -> &str {
"web_search"
}
fn description(&self) -> &str {
"Search the web and return matching results as text."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query."}
},
"required": ["query"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
let a: WebSearchArgs = parse_args(self.name(), args)?;
let endpoint = std::env::var(WEB_SEARCH_URL_ENV).map_err(|_| {
Error::tool(
self.name(),
format!(
"web_search requires a configured search endpoint; set {WEB_SEARCH_URL_ENV}"
),
)
})?;
ctx.check_network(&endpoint)?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.redirect(network_checked_redirect_policy(ctx.network_policy.clone()))
.build()
.map_err(|e| Error::tool(self.name(), e.to_string()))?;
let resp = client
.get(&endpoint)
.query(&[("q", &a.query)])
.send()
.await
.map_err(|e| {
Error::tool(
self.name(),
format!("search failed: {}", describe_reqwest_error(&e)),
)
})?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| Error::tool(self.name(), format!("failed to read response body: {e}")))?;
let mut end = MAX_FETCH_BYTES.min(body.len());
while end > 0 && !body.is_char_boundary(end) {
end -= 1;
}
Ok(format!("[web_search: HTTP {status}]\n{}", &body[..end]))
}
}