Add native glob file discovery

This commit is contained in:
Georg Bauer
2026-08-31 08:44:44 +02:00
parent 8adac261ae
commit 5c74739f65
2 changed files with 345 additions and 10 deletions

View File

@@ -338,6 +338,7 @@ enum ToolHandler {
Write,
Edit,
Search,
Glob,
List,
DevBrainInfo,
DevBrainSearch,
@@ -640,6 +641,30 @@ const TOOLS: &[ToolSpec] = &[
handler: ToolHandler::Search,
dev_brain: false,
},
ToolSpec {
name: "glob",
description: "Find regular files recursively by filename or relative path without a shell. Use this for filename discovery before Bash; use search for file contents and list for one directory. A pattern without / matches basenames; a pattern with / matches paths relative to path. * and ? use the search glob matcher, where * spans path separators and repeated * has no separate semantics.",
parameters: &[
ToolParameter {
name: "pattern",
kind: NON_EMPTY,
required: true,
},
ToolParameter {
name: "path",
kind: STRING,
required: false,
},
ToolParameter {
name: "max_results",
kind: ParameterKind::Integer { min: 1, max: 500 },
required: false,
},
],
rule: ToolRule::None,
handler: ToolHandler::Glob,
dev_brain: false,
},
ToolSpec {
name: "list",
description: "List one directory compactly. path is optional and defaults to the project root.",
@@ -1920,7 +1945,8 @@ impl Tools {
ToolHandler::More => self.more(call),
ToolHandler::Write => self.write(call),
ToolHandler::Edit => self.edit(call),
ToolHandler::Search => self.search(call),
ToolHandler::Search => self.search(call, cancel),
ToolHandler::Glob => self.glob(call, cancel),
ToolHandler::List => self.list(call),
ToolHandler::Bash => self.bash(call, cancel),
ToolHandler::BashStatus => self.bash_observe(call, false, cancel),
@@ -2391,6 +2417,30 @@ impl Tools {
self.inside_readable_root(path, value)
}
fn path_contains_readable_symlink(&self, path: &Path) -> Result<bool, std::io::Error> {
let mut current = PathBuf::new();
let mut inside = false;
for component in path.components() {
current.push(component.as_os_str());
let metadata = fs::symlink_metadata(&current)?;
let canonical = current.canonicalize()?;
let enters_readable_root = canonical.starts_with(&self.root)
|| self
.agent_skill_roots
.iter()
.any(|root| canonical.starts_with(root))
|| self
.dev_brain
.as_ref()
.is_some_and(|brain| canonical.starts_with(brain.folder()));
if metadata.file_type().is_symlink() && (inside || enters_readable_root) {
return Ok(true);
}
inside |= enters_readable_root;
}
Ok(false)
}
fn writable_path(&self, value: &str) -> Result<PathBuf, String> {
if Path::new(value)
.components()
@@ -2702,7 +2752,38 @@ impl Tools {
Ok(output)
}
fn search(&self, call: &ToolCall) -> Result<String, String> {
fn glob(&self, call: &ToolCall, cancel: &AtomicBool) -> Result<String, String> {
let pattern = required_string(call, "pattern")?;
let display = string(call, "path").unwrap_or(".");
let requested = if Path::new(display).is_absolute() {
PathBuf::from(display)
} else {
self.root.join(display)
};
if self
.path_contains_readable_symlink(&requested)
.map_err(|error| format!("open {display}: {error}"))?
{
return Err(format!("search root is a symlink: {display}"));
}
let path = self.existing_path(display)?;
if !path.is_dir() {
return Err(format!("not a directory: {display}"));
}
let skip_hidden = self
.dev_brain
.as_ref()
.is_some_and(|brain| path.starts_with(brain.folder()));
glob_path(
&path,
pattern,
integer(call, "max_results", 100, 1, 500),
skip_hidden,
cancel,
)
}
fn search(&self, call: &ToolCall, cancel: &AtomicBool) -> Result<String, String> {
let query = required_string(call, "query")?;
let display = string(call, "path").unwrap_or(".");
let path = self.existing_path(display)?;
@@ -2721,7 +2802,7 @@ impl Tools {
.as_ref()
.filter(|brain| path.starts_with(brain.folder()))
.map_or(self.root.as_path(), |brain| brain.folder());
search_path(root, &path, &options, root != self.root.as_path())
search_path(root, &path, &options, root != self.root.as_path(), cancel)
}
fn bash(&mut self, call: &ToolCall, cancel: &AtomicBool) -> Result<String, String> {
@@ -2989,9 +3070,17 @@ fn search_path(
path: &Path,
options: &SearchOptions<'_>,
skip_hidden: bool,
cancel: &AtomicBool,
) -> Result<String, String> {
let mut files = Vec::new();
collect_search_files(path, 0, skip_hidden, &mut files)?;
collect_files(
path,
0,
skip_hidden,
Some(MAX_FILE_BYTES),
cancel,
&mut files,
)?;
let mut matches = 0;
let mut body = String::new();
for file in files {
@@ -3029,12 +3118,62 @@ fn search_path(
}
}
fn collect_search_files(
fn glob_path(
root: &Path,
pattern: &str,
limit: usize,
skip_hidden: bool,
cancel: &AtomicBool,
) -> Result<String, String> {
let mut files = Vec::new();
collect_files(root, 0, skip_hidden, None, cancel, &mut files)?;
let basename_only = !pattern.contains('/');
let mut matches = files
.into_iter()
.filter_map(|file| {
let relative = file.strip_prefix(root).ok()?;
let relative = relative
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
let value = if basename_only {
file.file_name()?.to_string_lossy().into_owned()
} else {
relative.clone()
};
wildcard_match(pattern, &value).then_some(relative)
})
.collect::<Vec<_>>();
matches.sort();
if matches.is_empty() {
return Ok("No matches\n".into());
}
let total = matches.len();
let mut output = matches
.into_iter()
.take(limit)
.collect::<Vec<_>>()
.join("\n");
output.push('\n');
if total > limit {
output.push_str(&format!(
"... {} more matches omitted; narrow the pattern or raise max_results ...\n",
total - limit
));
}
Ok(output)
}
fn collect_files(
path: &Path,
depth: usize,
skip_hidden: bool,
max_file_bytes: Option<u64>,
cancel: &AtomicBool,
output: &mut Vec<PathBuf>,
) -> Result<(), String> {
if cancel.load(Ordering::Relaxed) {
return Err("interrupted".into());
}
if depth > 24 {
return Ok(());
}
@@ -3043,7 +3182,7 @@ fn collect_search_files(
return Ok(());
}
if metadata.is_file() {
if metadata.len() <= MAX_FILE_BYTES {
if max_file_bytes.is_none_or(|limit| metadata.len() <= limit) {
output.push(path.to_owned());
}
return Ok(());
@@ -3057,12 +3196,22 @@ fn collect_search_files(
.collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
if cancel.load(Ordering::Relaxed) {
return Err("interrupted".into());
}
if entry.file_name() == ".git"
|| skip_hidden && entry.file_name().to_string_lossy().starts_with('.')
{
continue;
}
collect_search_files(&entry.path(), depth + 1, skip_hidden, output)?;
collect_files(
&entry.path(),
depth + 1,
skip_hidden,
max_file_bytes,
cancel,
output,
)?;
}
Ok(())
}
@@ -3990,6 +4139,14 @@ mod tests {
)
.contains("skill reference")
);
let skill_glob = tools.execute(
&call(
"glob",
[("pattern", "*.md"), ("path", allium.to_str().unwrap())],
),
&cancel,
);
assert_eq!(skill_glob, "SKILL.md\nreference.md\n");
assert!(
tools
.execute(
@@ -4049,6 +4206,7 @@ mod tests {
"write",
"edit",
"search",
"glob",
"list",
] {
assert!(prompt.contains(&format!("\"name\":\"{name}\"")));
@@ -4316,6 +4474,37 @@ mod tests {
present_svg["function"]["parameters"]["required"],
serde_json::json!(["svg", "alt"])
);
let glob = schemas
.iter()
.find(|schema| schema["function"]["name"] == "glob")
.unwrap();
assert_eq!(
glob["function"]["parameters"]["required"],
serde_json::json!(["pattern"])
);
assert_eq!(
glob["function"]["parameters"]["properties"]["max_results"]["minimum"],
1
);
assert_eq!(
glob["function"]["parameters"]["properties"]["max_results"]["maximum"],
500
);
assert!(
glob["function"]["description"]
.as_str()
.unwrap()
.contains("before Bash")
);
for invalid in [
call("glob", []),
call("glob", [("pattern", "")]),
call("glob", [("pattern", "*.rs"), ("max_results", "0")]),
call("glob", [("pattern", "*.rs"), ("max_results", "501")]),
] {
assert!(validate_tool_call(&invalid).is_err());
}
}
#[test]
@@ -4905,11 +5094,21 @@ mod tests {
.is_err()
);
fs::write(vault.join("concepts/private.md"), "# Private\n").unwrap();
fs::write(vault.join(".obsidian/private.md"), "hidden\n").unwrap();
assert!(
tools
.writable_path(vault.join("concepts/private.md").to_str().unwrap())
.is_err()
);
let glob = tools.execute(
&call(
"glob",
[("pattern", "*.md"), ("path", vault.to_str().unwrap())],
),
&AtomicBool::new(false),
);
assert!(glob.contains("subsystems/inference/modes.md"));
assert!(!glob.contains(".obsidian"));
fs::remove_dir_all(directory).unwrap();
}
@@ -5318,6 +5517,142 @@ mod tests {
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn glob_finds_all_regular_file_names_deterministically_without_reading_them() {
let directory = std::env::temp_dir().join(format!(
"ds4-agent-glob-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(directory.join("src/a")).unwrap();
fs::create_dir_all(directory.join("src/z")).unwrap();
fs::create_dir_all(directory.join("skills/demo")).unwrap();
fs::create_dir_all(directory.join(".git")).unwrap();
fs::write(directory.join("Cargo.toml"), "").unwrap();
fs::write(directory.join(".hidden.rs"), "").unwrap();
fs::write(directory.join("src/a/agent1.rs"), "").unwrap();
fs::write(directory.join("src/z/agent test.rs"), "").unwrap();
fs::write(directory.join("src/z/café.rs"), "").unwrap();
fs::write(directory.join("skills/demo/SKILL.md"), "").unwrap();
fs::write(directory.join(".git/secret.rs"), "").unwrap();
fs::write(directory.join("binary.dat"), [0, 159, 146, 150]).unwrap();
File::create(directory.join("large.bin"))
.unwrap()
.set_len(MAX_FILE_BYTES + 1)
.unwrap();
let mut tools = Tools::new(&directory, 4096).unwrap();
let cancel = AtomicBool::new(false);
assert_eq!(
tools.execute(&call("glob", [("pattern", "Cargo.toml")]), &cancel),
"Cargo.toml\n"
);
assert_eq!(
tools.execute(&call("glob", [("pattern", "agent?.rs")]), &cancel),
"src/a/agent1.rs\n"
);
assert_eq!(
tools.execute(&call("glob", [("pattern", "src/*test.rs")]), &cancel,),
"src/z/agent test.rs\n"
);
assert_eq!(
tools.execute(&call("glob", [("pattern", "café.rs")]), &cancel),
"src/z/café.rs\n"
);
assert_eq!(
tools.execute(&call("glob", [("pattern", "agent test.rs")]), &cancel),
"src/z/agent test.rs\n"
);
assert_eq!(
tools.execute(&call("glob", [("pattern", "**/SKILL.md")]), &cancel),
"skills/demo/SKILL.md\n"
);
assert_eq!(
tools.execute(&call("glob", [("pattern", "*.bin")]), &cancel),
"large.bin\n"
);
assert_eq!(
tools.execute(&call("glob", [("pattern", "*.dat")]), &cancel),
"binary.dat\n"
);
assert_eq!(
tools.execute(&call("glob", [("pattern", "missing*")]), &cancel),
"No matches\n"
);
let truncated = tools.execute(
&call("glob", [("pattern", "*.rs"), ("max_results", "2")]),
&cancel,
);
assert_eq!(
truncated,
".hidden.rs\nsrc/a/agent1.rs\n... 2 more matches omitted; narrow the pattern or raise max_results ...\n"
);
assert!(!truncated.contains("secret.rs"));
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn glob_confinement_skips_symlinks_and_honors_cancellation() {
let directory = std::env::temp_dir().join(format!(
"ds4-agent-glob-boundary-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let project = directory.join("project");
let outside = directory.join("outside");
fs::create_dir_all(project.join("inside/deeper")).unwrap();
fs::create_dir_all(&outside).unwrap();
fs::write(project.join("inside/visible.rs"), "").unwrap();
fs::write(project.join("not-a-directory.rs"), "").unwrap();
fs::write(outside.join("secret.rs"), "").unwrap();
std::os::unix::fs::symlink(&outside, project.join("escape")).unwrap();
std::os::unix::fs::symlink(project.join("inside/visible.rs"), project.join("linked.rs"))
.unwrap();
std::os::unix::fs::symlink(project.join("inside"), project.join("inside-link")).unwrap();
let mut tools = Tools::new(&project, 4096).unwrap();
let active = AtomicBool::new(false);
assert_eq!(
tools.execute(&call("glob", [("pattern", "*.rs")]), &active),
"inside/visible.rs\nnot-a-directory.rs\n"
);
for invalid in [
call("glob", [("pattern", "*.rs"), ("path", "..")]),
call(
"glob",
[("pattern", "*.rs"), ("path", outside.to_str().unwrap())],
),
call("glob", [("pattern", "*.rs"), ("path", "escape")]),
call("glob", [("pattern", "*.rs"), ("path", "inside-link")]),
call(
"glob",
[("pattern", "*.rs"), ("path", "inside-link/deeper")],
),
call(
"glob",
[("pattern", "*.rs"), ("path", "not-a-directory.rs")],
),
] {
assert!(
tools
.execute(&invalid, &active)
.contains("code=execution_failed")
);
}
let cancelled = AtomicBool::new(true);
assert!(
tools
.execute(&call("glob", [("pattern", "*.rs")]), &cancelled)
.contains("code=interrupted")
);
fs::remove_dir_all(directory).unwrap();
}
fn call<const N: usize>(name: &str, arguments: [(&str, &str); N]) -> ToolCall {
ToolCall {
name: name.to_owned(),