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

@@ -57,9 +57,9 @@ the same safe rebuild manually.
The coding agent can use native tools to list a directory, read files, search The coding agent can use native tools to list a directory, read files, search
file contents, write or edit files, inspect Git state, and maintain Dev Brain. file contents, write or edit files, inspect Git state, and maintain Dev Brain.
It can also run shell commands and use web search or visit pages when the task It can also run shell commands and use web search or visit pages when the task
needs them. Content search is not filename discovery: until DS4Server ships a needs them. The native glob tool finds files recursively by basename or relative
native filename glob tool, recursive filename lookup may require an approved path pattern without a shell; content matching remains the search tool's job,
shell command. A directory listing remains a non-recursive view of one folder. and a directory listing remains a non-recursive view of one folder.
Tool calls are checked against typed schemas before execution. Unknown tools, Tool calls are checked against typed schemas before execution. Unknown tools,
missing or extra fields, malformed calls, and invalid values return structured missing or extra fields, malformed calls, and invalid values return structured

View File

@@ -338,6 +338,7 @@ enum ToolHandler {
Write, Write,
Edit, Edit,
Search, Search,
Glob,
List, List,
DevBrainInfo, DevBrainInfo,
DevBrainSearch, DevBrainSearch,
@@ -640,6 +641,30 @@ const TOOLS: &[ToolSpec] = &[
handler: ToolHandler::Search, handler: ToolHandler::Search,
dev_brain: false, 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 { ToolSpec {
name: "list", name: "list",
description: "List one directory compactly. path is optional and defaults to the project root.", 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::More => self.more(call),
ToolHandler::Write => self.write(call), ToolHandler::Write => self.write(call),
ToolHandler::Edit => self.edit(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::List => self.list(call),
ToolHandler::Bash => self.bash(call, cancel), ToolHandler::Bash => self.bash(call, cancel),
ToolHandler::BashStatus => self.bash_observe(call, false, cancel), ToolHandler::BashStatus => self.bash_observe(call, false, cancel),
@@ -2391,6 +2417,30 @@ impl Tools {
self.inside_readable_root(path, value) 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> { fn writable_path(&self, value: &str) -> Result<PathBuf, String> {
if Path::new(value) if Path::new(value)
.components() .components()
@@ -2702,7 +2752,38 @@ impl Tools {
Ok(output) 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 query = required_string(call, "query")?;
let display = string(call, "path").unwrap_or("."); let display = string(call, "path").unwrap_or(".");
let path = self.existing_path(display)?; let path = self.existing_path(display)?;
@@ -2721,7 +2802,7 @@ impl Tools {
.as_ref() .as_ref()
.filter(|brain| path.starts_with(brain.folder())) .filter(|brain| path.starts_with(brain.folder()))
.map_or(self.root.as_path(), |brain| 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> { fn bash(&mut self, call: &ToolCall, cancel: &AtomicBool) -> Result<String, String> {
@@ -2989,9 +3070,17 @@ fn search_path(
path: &Path, path: &Path,
options: &SearchOptions<'_>, options: &SearchOptions<'_>,
skip_hidden: bool, skip_hidden: bool,
cancel: &AtomicBool,
) -> Result<String, String> { ) -> Result<String, String> {
let mut files = Vec::new(); 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 matches = 0;
let mut body = String::new(); let mut body = String::new();
for file in files { 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, path: &Path,
depth: usize, depth: usize,
skip_hidden: bool, skip_hidden: bool,
max_file_bytes: Option<u64>,
cancel: &AtomicBool,
output: &mut Vec<PathBuf>, output: &mut Vec<PathBuf>,
) -> Result<(), String> { ) -> Result<(), String> {
if cancel.load(Ordering::Relaxed) {
return Err("interrupted".into());
}
if depth > 24 { if depth > 24 {
return Ok(()); return Ok(());
} }
@@ -3043,7 +3182,7 @@ fn collect_search_files(
return Ok(()); return Ok(());
} }
if metadata.is_file() { 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()); output.push(path.to_owned());
} }
return Ok(()); return Ok(());
@@ -3057,12 +3196,22 @@ fn collect_search_files(
.collect::<Vec<_>>(); .collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.file_name()); entries.sort_by_key(|entry| entry.file_name());
for entry in entries { for entry in entries {
if cancel.load(Ordering::Relaxed) {
return Err("interrupted".into());
}
if entry.file_name() == ".git" if entry.file_name() == ".git"
|| skip_hidden && entry.file_name().to_string_lossy().starts_with('.') || skip_hidden && entry.file_name().to_string_lossy().starts_with('.')
{ {
continue; 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(()) Ok(())
} }
@@ -3990,6 +4139,14 @@ mod tests {
) )
.contains("skill reference") .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!( assert!(
tools tools
.execute( .execute(
@@ -4049,6 +4206,7 @@ mod tests {
"write", "write",
"edit", "edit",
"search", "search",
"glob",
"list", "list",
] { ] {
assert!(prompt.contains(&format!("\"name\":\"{name}\""))); assert!(prompt.contains(&format!("\"name\":\"{name}\"")));
@@ -4316,6 +4474,37 @@ mod tests {
present_svg["function"]["parameters"]["required"], present_svg["function"]["parameters"]["required"],
serde_json::json!(["svg", "alt"]) 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] #[test]
@@ -4905,11 +5094,21 @@ mod tests {
.is_err() .is_err()
); );
fs::write(vault.join("concepts/private.md"), "# Private\n").unwrap(); fs::write(vault.join("concepts/private.md"), "# Private\n").unwrap();
fs::write(vault.join(".obsidian/private.md"), "hidden\n").unwrap();
assert!( assert!(
tools tools
.writable_path(vault.join("concepts/private.md").to_str().unwrap()) .writable_path(vault.join("concepts/private.md").to_str().unwrap())
.is_err() .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(); fs::remove_dir_all(directory).unwrap();
} }
@@ -5318,6 +5517,142 @@ mod tests {
fs::remove_dir_all(directory).unwrap(); 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 { fn call<const N: usize>(name: &str, arguments: [(&str, &str); N]) -> ToolCall {
ToolCall { ToolCall {
name: name.to_owned(), name: name.to_owned(),