//! 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;
mod scan;
use output::{Entry, EntryKind, 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 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::Graph => {
"Show the current project Artifact graph, including evals and input-to-consumer relations."
}
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 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. 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; ignores no files by git rules. Symlinks/special files are skipped, 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 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::Graph => (json!({}), 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(input) => reader.grep(reader.root_path(&input.path), &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),
}
}
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 scan = scan::Read::new(offset, limit);
let end = loop {
self.check_cancelled()?;
let bytes = reader.fill_buf().map_err(|error| error.to_string())?;
if bytes.is_empty() {
break scan::ReadEnd::Complete;
}
match scan.push(bytes)? {
scan::ReadStep::More(consumed) => reader.consume(consumed),
scan::ReadStep::Full(consumed) => {
reader.consume(consumed);
break if reader
.fill_buf()
.map_err(|error| error.to_string())?
.is_empty()
{
scan::ReadEnd::Complete
} else {
scan::ReadEnd::Bounded
};
}
scan::ReadStep::Bounded => break scan::ReadEnd::Bounded,
}
};
Ok(Output::Read(output::Read {
artifact_id: self.owner.clone(),
resolved_artifact_id: location.artifact_id,
path: path.to_owned(),
rows: scan.finish(end)?,
}))
}
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);
}
}
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)?;
scan::list(self.owner.clone(), path, entries, offset, limit).map(Output::List)
}
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()];
let mut files = BTreeSet::new();
let mut seen = 0;
let mut truncated = false;
while let Some(path) = pending.pop() {
self.check_cancelled()?;
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 => pending.push(entry.path),
EntryKind::Symlink | EntryKind::Other => {}
}
}
if truncated {
break;
}
}
Ok((files, truncated))
}
fn glob(&self, path: &str, matcher: &GlobMatcher) -> Result<Output, String> {
let location = self.location(path)?;
if !self
.open(path, &location)?
.metadata()
.map_err(|error| error.to_string())?
.is_dir()
{
return Err("Glob path must be a directory.".into());
}
let (files, truncated) = self.files(path)?;
let mut scan = scan::Glob::new(path, matcher, truncated);
for file in files {
self.check_cancelled()?;
match scan.push(&file) {
scan::Selection::Skip => continue,
scan::Selection::Full => break,
scan::Selection::Added => {}
}
// The selected file can have changed since directory enumeration.
if !self
.open(&file, &self.location(&file)?)?
.metadata()
.map_err(|error| error.to_string())?
.is_file()
{
return Err("Glob target is no longer a regular file.".into());
}
}
Ok(Output::Glob(scan.finish()))
}
fn grep(&self, path: &str, input: &input::Grep) -> Result<Output, String> {
let (files, truncated) = self.files(path)?;
let mut scan = scan::Grep::new(path, input, truncated);
'files: for file in files {
self.check_cancelled()?;
if !scan.allows(&file) {
continue;
}
let input = self.open(&file, &self.location(&file)?)?;
let metadata = input.metadata().map_err(|error| error.to_string())?;
if !metadata.is_file() {
return Err("Grep requires regular files.".into());
}
let limit = match scan.file(metadata.len()) {
scan::FileRead::Read(limit) => limit,
scan::FileRead::Skip => continue,
scan::FileRead::Full => break,
};
let mut contents = Vec::new();
input
.take(limit)
.read_to_end(&mut contents)
.map_err(|_| "Cannot read Artifact file.")?;
match scan.received(contents.len()) {
scan::Received::Skip => continue,
scan::Received::Full => break,
scan::Received::Text => {}
}
let Some(text) = scan::text(&contents) else {
continue;
};
for (index, line) in text.split_inclusive('\n').enumerate() {
self.check_cancelled()?;
if !scan.line(&file, index, line) {
break 'files;
}
}
}
Ok(Output::Grep(scan.finish()))
}
}
/// 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 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())
}