//! Scoped read-only Agent tools.
mod fixed;
mod input;
pub use fixed::{
Context, call_fixed, fixed_description, fixed_schema, is_url, validate_args, validate_target,
};
pub use input::Input;
mod output;
use output::{Entry, EntryKind, GrepMatch, Line, Output};
/// Bound the compiled regex automaton to limit search memory.
const REGEX_BYTES: usize = 2 * 1024 * 1024;
use std::{
collections::{BTreeMap, BTreeSet},
fs::File,
io::{BufRead, BufReader, Read},
path::Path,
};
use globset::{GlobBuilder, GlobMatcher};
use regex::RegexBuilder;
use serde_json::{Value, json};
use tokio_util::sync::CancellationToken;
use crate::{
Builtin,
files::{self as platform, FileKind},
scope::{self, ArtifactId, OpenError, Scope, ScopedPath},
};
use crate::{Content, ToolResult, image};
/// Bound one read's contribution to the Agent context while preserving complete lines.
pub const READ_BYTES: usize = 64 * 1024;
/// Cap serialized tool output even when match text or logical paths are very long.
pub const RESULT_BYTES: usize = 512 * 1024;
/// Reserve 1 KiB for serialized list/glob/grep keys, brackets and page metadata before
/// accumulating entries; the final serialized-result check still enforces RESULT_BYTES.
const RESULT_ENVELOPE_BYTES: usize = 1024;
/// Keep list/search pages small enough for a review; pagination exposes later entries.
const MAX_RESULTS: usize = 200;
/// Bound directory fan-out and traversal work regardless of the requested result count.
const MAX_ENTRIES: usize = 10_000;
/// Skip individual oversized files so one input cannot monopolize a search.
pub const SEARCH_FILE_BYTES: usize = 8 * 1024 * 1024;
/// Bound total search I/O across files; exhausting the budget reports truncation.
const SEARCH_BYTES: usize = 64 * 1024 * 1024;
/// Patterns are user input rather than paths, but share their bounded schema size.
const MAX_PATTERN_UNITS: usize = scope::MAX_PATH_UNITS;
/// An ordinary read starts with eighty lines; owners can page further or request
/// up to five hundred without scanning an unbounded number of lines in one call.
const DEFAULT_READ_LINES: usize = 80;
const MAX_READ_LINES: usize = 500;
pub fn description(builtin: Builtin) -> &'static str {
match builtin {
Builtin::Section => "Read a Markdown section in {artifactName} by its exact heading.",
Builtin::Help => "Read a program's --help output.",
Builtin::Open => "Open a scoped file or URL in its default application.",
Builtin::Read => {
"Read UTF-8 complete lines in {artifactName}. path is a relative logical file path, including child/mount paths. Paths are relative to {artifactName} itself: use \"notes.md\", not \"{artifactName}/notes.md\". offset is 1-based (default 1); limit defaults to 80, maximum 500. Returns numbered lines preserving LF/CRLF/BOM, up to 64 KiB, startLine/endLine/lineCount, totalLines when known, truncated and nextOffset. No symlinks or binary text."
}
Builtin::List => {
"List {artifactName} at a relative logical path (default root). Paths are relative to {artifactName} itself: use \"notes.md\", not \"{artifactName}/notes.md\". Sorted entries include name, path and kind; mounts are included. offset is 0-based; limit defaults to and cannot exceed 200. Returns totalEntries, truncated and nextOffset. Symlinks and special files are listed but never followed. Directories are limited to 10,000 entries."
}
Builtin::Glob => {
"Find files in {artifactName} with a relative glob pattern (* within a path component, ** across directories). path is a relative logical directory, default root; patterns are relative to it. Paths are relative to {artifactName} itself: use \"notes.md\", not \"{artifactName}/notes.md\". Returns up to 200 sorted logical paths and truncated. Includes hidden files and mounts; ignores no files by git rules. Symlinks/special files are skipped, mount cycles are not repeated, and traversal is limited to 10,000 entries."
}
Builtin::Grep => {
"Search UTF-8 files in {artifactName} with a Rust regex, one match per matching line. path is a relative logical file or directory (default root); glob filters paths relative to it. Paths are relative to {artifactName} itself: use \"notes.md\", not \"{artifactName}/notes.md\". caseInsensitive defaults to false; maxResults defaults to and cannot exceed 200. Returns matches with path, line and text, plus truncated. Skips binary/invalid UTF-8 and symlinks; includes hidden files. Caps: 10,000 traversed entries, 8 MiB per file, 64 MiB searched, 512 KiB result. Skipped oversized files or bounded results set truncated."
}
Builtin::ViewImage => {
"View one image in {artifactName} at a relative logical file path, including child/mount paths. Paths are relative to {artifactName} itself: use \"image.png\", not \"{artifactName}/image.png\". Detects PNG, JPEG or WebP by bytes, not extension; returns an embedded image block. Requires a nonempty regular file up to 4 MiB; no symlinks, GIF, BMP or animated PNG."
}
}
}
pub fn input_schema(builtin: Builtin) -> Value {
let path = json!({
"type":"string",
"maxLength":scope::MAX_PATH_UNITS,
"description":"Logical path relative to the tool's Artifact, without the Artifact's name; no absolute paths, dot components, backslashes or symlinks. Empty means the root.",
});
let pattern = json!({"type":"string","minLength":1,"maxLength":MAX_PATTERN_UNITS});
let integer =
|min, max, default| json!({"type":"integer","minimum":min,"maximum":max,"default":default});
let (properties, required) = match builtin {
Builtin::Read => (
json!({
"path":path,
"offset":integer(1, crate::MAX_SAFE_JSON_INTEGER, 1),
"limit":integer(1, MAX_READ_LINES as u64, DEFAULT_READ_LINES as u64),
}),
vec!["path"],
),
Builtin::List => (
json!({
"path":path,
"offset":integer(0, crate::MAX_SAFE_JSON_INTEGER, 0),
"limit":integer(1, MAX_RESULTS as u64, MAX_RESULTS as u64),
}),
vec![],
),
Builtin::Glob => (json!({"pattern":pattern,"path":path}), vec!["pattern"]),
Builtin::Grep => (
json!({
"pattern":pattern,
"path":path,
"glob":pattern,
"caseInsensitive":{"type":"boolean","default":false},
"maxResults":integer(1, MAX_RESULTS as u64, MAX_RESULTS as u64),
}),
vec!["pattern"],
),
Builtin::ViewImage => (json!({"path":path}), vec!["path"]),
Builtin::Section => (json!({"heading":pattern}), vec!["heading"]),
Builtin::Help | Builtin::Open => (json!({}), vec![]),
};
json!({
"type":"object",
"properties":properties,
"required":required,
"additionalProperties":false,
})
}
pub fn call(
input: Input,
root: &Path,
scope: &Scope,
owner: &ArtifactId,
cancellation: &CancellationToken,
) -> ToolResult {
let reader = Reader {
root,
scope,
owner,
cancellation,
};
let result = (|| {
reader.check_cancelled()?;
let data = match input {
Input::Read(input) => reader.read(&input.path, input.offset, input.limit),
Input::List(input) => {
reader.list(reader.root_path(&input.path), input.offset, input.limit)
}
Input::Glob(input) => reader.glob(reader.root_path(&input.path), &input.pattern),
Input::Grep(mut input) => {
input.path = reader.root_path(&input.path).to_owned();
reader.grep(&input)
}
Input::ViewImage(input) => return reader.view_image(&input.path),
}?;
if serde_json::to_vec(&data).unwrap().len() > RESULT_BYTES {
return Err("Built-in result exceeds 512 KiB; narrow the path or range.".into());
}
Ok(Content::Json {
data: serde_json::to_value(data).expect("builtin output is JSON"),
})
})();
match result {
Ok(content) => ToolResult::success(content),
Err(message) => ToolResult::error(message),
}
}
/// EOF is distinct from a budget stop: only EOF makes totalLines available.
#[derive(Clone, Copy, PartialEq, Eq)]
enum ReadEnd {
Bounded,
Complete,
}
struct Reader<'a> {
root: &'a Path,
scope: &'a Scope,
owner: &'a ArtifactId,
cancellation: &'a CancellationToken,
}
impl Reader<'_> {
fn root_path<'a>(&self, path: &'a str) -> &'a str {
if path == "." && self.scope.artifacts[self.owner].file_name().is_some() {
""
} else {
path
}
}
fn check_cancelled(&self) -> Result<(), String> {
if self.cancellation.is_cancelled() {
Err("Agent tool call was cancelled.".into())
} else {
Ok(())
}
}
fn location(&self, path: &str) -> Result<ScopedPath, String> {
self.scope
.resolve_path(self.owner, path)
.map_err(|e| e.to_string())
}
/// Open the resolved `location` of the logical `path`, which a missing entry names.
fn open(&self, path: &str, location: &ScopedPath) -> Result<File, String> {
let artifact = &self.scope.artifacts[&location.artifact_id];
scope::open_input(self.root, artifact, &location.path).map_err(|error| match error {
OpenError::NotFound => missing(self.owner, path),
error => error.to_string(),
})
}
fn view_image(&self, path: &str) -> Result<Content, String> {
let location = self.location(path)?;
let bytes = image::read(self.open(path, &location)?)?;
self.check_cancelled()?;
image::normalize(&bytes, None)
}
fn read(&self, path: &str, offset: usize, limit: usize) -> Result<Output, String> {
let location = self.location(path)?;
let file = self.open(path, &location)?;
if !file.metadata().map_err(|e| e.to_string())?.is_file() {
return Err("Reading requires a regular file; list the directory first.".into());
}
let mut reader = BufReader::new(file);
let mut lines = Vec::new();
let mut line = Vec::new();
let mut current = 1;
let mut returned_bytes = 0;
let mut read_end = ReadEnd::Bounded;
let mut partial_line = false;
loop {
self.check_cancelled()?;
let bytes = reader.fill_buf().map_err(|e| e.to_string())?;
if bytes.is_empty() {
if !line.is_empty() {
lines.push(text_line(current, &line)?);
}
read_end = ReadEnd::Complete;
break;
}
let end = bytes
.iter()
.position(|byte| *byte == b'\n')
.map(|index| index + 1);
let fragment = &bytes[..end.unwrap_or(bytes.len())];
if current >= offset {
if returned_bytes + line.len() + fragment.len() > READ_BYTES {
if lines.is_empty() {
return Err(format!(
"Artifact line {current} exceeds the {READ_BYTES}-byte read limit."
));
}
break;
}
if fragment.contains(&0) {
return Err("Binary text (NUL) cannot be read.".into());
}
line.extend_from_slice(fragment);
}
let consumed = fragment.len();
partial_line = end.is_none();
reader.consume(consumed);
if end.is_some() {
if current >= offset {
lines.push(text_line(current, &line)?);
returned_bytes += line.len();
line.clear();
}
current += 1;
if lines.len() == limit || returned_bytes == READ_BYTES {
if reader.fill_buf().map_err(|e| e.to_string())?.is_empty() {
read_end = ReadEnd::Complete;
}
break;
}
}
}
let count = lines.len();
Ok(Output::Read(output::Read {
artifact_id: self.owner.clone(),
resolved_artifact_id: location.artifact_id,
path: path.to_owned(),
lines,
start_line: offset,
end_line: (count != 0).then(|| offset + count - 1),
line_count: count,
truncated: read_end == ReadEnd::Bounded,
next_offset: (read_end == ReadEnd::Bounded).then_some(offset + count),
total_lines: (read_end == ReadEnd::Complete)
.then(|| current - 1 + usize::from(partial_line)),
}))
}
fn entries(&self, path: &str) -> Result<Vec<Entry>, String> {
self.check_cancelled()?;
let location = self.location(path)?;
let file = self.open(path, &location)?;
if !file.metadata().map_err(|e| e.to_string())?.is_dir() {
return Err("Listing requires a directory.".into());
}
let owner = &self.scope.artifacts[&location.artifact_id];
let mut entries = BTreeMap::new();
if let Some(name) = owner.file_name() {
let target = scope::open_child(&file, std::ffi::OsStr::new(name))
.map_err(|error| error.to_string())?;
if !target.metadata().map_err(|e| e.to_string())?.is_file() {
return Err("File Artifact target must remain a regular file.".into());
}
entries.insert(name.to_owned(), EntryKind::File);
} else {
// Enumerate the pinned directory, not a path that could have been replaced by a link.
let directory =
platform::read_dir(&file).map_err(|_| "Cannot list Artifact directory.")?;
for entry in directory {
self.check_cancelled()?;
if entries.len() >= MAX_ENTRIES {
return Err(
"Directory exceeds the 10,000-entry listing limit; choose a narrower path."
.into(),
);
}
let entry = entry.map_err(|_| "Cannot list Artifact entry.")?;
let name = entry
.file_name()
.into_string()
.map_err(|_| "Artifact paths must be UTF-8.")?;
let kind = entry
.file_type()
.map_err(|_| "Cannot inspect Artifact entry.")?;
let kind = match kind {
FileKind::File => EntryKind::File,
FileKind::Directory => EntryKind::Directory,
FileKind::Symlink => EntryKind::Symlink,
FileKind::Other => EntryKind::Other,
};
entries.insert(name, kind);
}
}
if location.path.is_empty() {
for (alias, id) in &owner.mounts {
self.check_cancelled()?;
if entries.len() == MAX_ENTRIES {
return Err(
"Directory exceeds the 10,000-entry listing limit; choose a narrower path."
.into(),
);
}
if !self.scope.artifacts.contains_key(id) {
return Err("Mount is outside this eval's scope.".into());
}
if entries
.insert(
alias.to_string(),
EntryKind::Mount {
artifact_id: id.clone(),
},
)
.is_some()
{
return Err("Logical mount conflicts with a physical entry.".into());
}
}
}
Ok(entries
.into_iter()
.map(|(name, kind)| Entry {
kind,
path: join(path, &name),
name,
})
.collect())
}
fn list(&self, path: &str, offset: usize, limit: usize) -> Result<Output, String> {
let entries = self.entries(path)?;
let total = entries.len();
let mut bytes = RESULT_ENVELOPE_BYTES + serde_json::to_vec(path).unwrap().len();
let entries: Vec<_> = entries
.into_iter()
.skip(offset)
.take(limit)
.take_while(|entry| {
bytes += serde_json::to_vec(entry).unwrap().len() + 1;
bytes <= RESULT_BYTES
})
.collect();
if entries.is_empty() && offset < total {
return Err("Listing entry exceeds 512 KiB; narrow the path.".into());
}
let next = offset + entries.len();
Ok(Output::List(output::List {
artifact_id: self.owner.clone(),
path: path.to_owned(),
entries,
total_entries: total,
truncated: next < total,
next_offset: (next < total).then_some(next),
}))
}
fn files(&self, path: &str) -> Result<(BTreeSet<String>, bool), String> {
let location = self.location(path)?;
if self
.open(path, &location)?
.metadata()
.map_err(|e| e.to_string())?
.is_file()
{
return Ok((BTreeSet::from([path.into()]), false));
}
let mut pending = vec![(path.to_owned(), Vec::new())];
let mut files = BTreeSet::new();
let mut seen = 0;
let mut truncated = false;
while let Some((path, mut ancestors)) = pending.pop() {
self.check_cancelled()?;
let location = self.location(&path)?;
let key = (location.artifact_id, location.path);
if ancestors.contains(&key) {
continue;
}
ancestors.push(key);
for entry in self.entries(&path)? {
if seen == MAX_ENTRIES {
truncated = true;
break;
}
seen += 1;
match entry.kind {
EntryKind::File => {
files.insert(entry.path);
}
EntryKind::Directory | EntryKind::Mount { .. } => {
pending.push((entry.path, ancestors.clone()));
}
EntryKind::Symlink | EntryKind::Other => {}
}
}
if truncated {
break;
}
}
Ok((files, truncated))
}
fn glob(&self, path: &str, pattern: &str) -> Result<Output, String> {
let matcher = glob(pattern)?;
let location = self.location(path)?;
if !self
.open(path, &location)?
.metadata()
.map_err(|e| e.to_string())?
.is_dir()
{
return Err("Glob path must be a directory.".into());
}
let (files, mut truncated) = self.files(path)?;
let mut matches = Vec::new();
let mut bytes = RESULT_ENVELOPE_BYTES + serde_json::to_vec(path).unwrap().len();
for file in files {
self.check_cancelled()?;
if !matcher.is_match(relative(path, &file)) {
continue;
}
bytes += serde_json::to_vec(&file).unwrap().len() + 1;
if matches.len() == MAX_RESULTS || bytes > RESULT_BYTES {
truncated = true;
break;
}
// Recheck entries that may have changed since enumeration.
if !self
.open(&file, &self.location(&file)?)?
.metadata()
.map_err(|e| e.to_string())?
.is_file()
{
return Err("Glob target is no longer a regular file.".into());
}
matches.push(file);
}
Ok(Output::Glob(output::Glob {
path: path.to_owned(),
files: matches,
truncated,
}))
}
fn grep(&self, input: &input::Grep) -> Result<Output, String> {
let path = input.path.as_str();
let regex = RegexBuilder::new(&input.pattern)
.case_insensitive(input.case_insensitive)
.size_limit(REGEX_BYTES)
.build()
.map_err(|_| "Invalid or oversized regex pattern.")?;
let filter = input.glob.as_deref().map(glob).transpose()?;
let (files, mut truncated) = self.files(path)?;
let mut matches = Vec::new();
let mut searched = 0;
let mut bytes = RESULT_ENVELOPE_BYTES + serde_json::to_vec(path).unwrap().len();
let limit = input.max_results;
'files: for file in files {
self.check_cancelled()?;
if filter
.as_ref()
.is_some_and(|filter| !filter.is_match(relative(path, &file)))
{
continue;
}
let input = self.open(&file, &self.location(&file)?)?;
let metadata = input.metadata().map_err(|e| e.to_string())?;
if !metadata.is_file() {
return Err("Grep requires regular files.".into());
}
if metadata.len() > SEARCH_FILE_BYTES as u64 {
truncated = true;
continue;
}
if searched + metadata.len() as usize > SEARCH_BYTES {
truncated = true;
break;
}
let mut contents = Vec::new();
input
.take((SEARCH_FILE_BYTES.min(SEARCH_BYTES - searched) + 1) as u64)
.read_to_end(&mut contents)
.map_err(|_| "Cannot read Artifact file.")?;
searched += contents.len();
if searched > SEARCH_BYTES {
truncated = true;
break;
}
if contents.len() > SEARCH_FILE_BYTES {
truncated = true;
continue;
}
if contents.contains(&0) {
continue;
}
let Ok(contents) = std::str::from_utf8(&contents) else {
continue;
};
for (index, line) in contents.split_inclusive('\n').enumerate() {
self.check_cancelled()?;
let line = line.strip_suffix('\n').unwrap_or(line);
let line = line.strip_suffix('\r').unwrap_or(line);
if !regex.is_match(line) {
continue;
}
let entry = GrepMatch {
path: file.clone(),
line: index + 1,
text: line.to_owned(),
};
bytes += serde_json::to_vec(&entry).unwrap().len() + 1;
if matches.len() == limit || bytes > RESULT_BYTES {
truncated = true;
break 'files;
}
matches.push(entry);
}
}
Ok(Output::Grep(output::Grep {
path: path.to_owned(),
matches,
truncated,
}))
}
}
/// A missing path, with a hint when a model prefixed it with the Artifact's own name.
fn missing(owner: &ArtifactId, path: &str) -> String {
match path.strip_prefix(owner.as_str()) {
Some("") => format!(
"No {path:?} in Artifact {owner}; paths are relative to the Artifact, without its name (its root is \"\")."
),
Some(rest) if rest.starts_with('/') => format!(
"No {path:?} in Artifact {owner}; paths are relative to the Artifact (for example {:?}), without its name.",
&rest[1..]
),
_ => format!("No {path:?} in Artifact {owner}."),
}
}
fn text_line(number: usize, line: &[u8]) -> Result<Line, String> {
let text = std::str::from_utf8(line)
.map_err(|_| "Artifact contains invalid UTF-8 in the requested lines.")?;
Ok(Line {
number,
text: text.to_owned(),
})
}
fn join(base: &str, name: &str) -> String {
if base.is_empty() {
name.into()
} else {
format!("{base}/{name}")
}
}
fn relative<'a>(base: &str, file: &'a str) -> &'a str {
if file == base {
file.rsplit('/').next().unwrap()
} else if base.is_empty() {
file
} else {
file.strip_prefix(base).unwrap().strip_prefix('/').unwrap()
}
}
fn glob(pattern: &str) -> Result<GlobMatcher, String> {
if pattern.starts_with('/')
|| pattern.contains(['\\', '\0', ':'])
|| pattern
.split('/')
.any(|part| part == "." || part == ".." || part.is_empty())
{
return Err("Glob pattern must be relative without traversal components.".into());
}
GlobBuilder::new(pattern)
.literal_separator(true)
.build()
.map(|glob| glob.compile_matcher())
.map_err(|_| "Invalid glob pattern.".into())
}