Add native glob file discovery
This commit is contained in:
@@ -57,9 +57,9 @@ the same safe rebuild manually.
|
||||
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.
|
||||
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
|
||||
native filename glob tool, recursive filename lookup may require an approved
|
||||
shell command. A directory listing remains a non-recursive view of one folder.
|
||||
needs them. The native glob tool finds files recursively by basename or relative
|
||||
path pattern without a shell; content matching remains the search tool's job,
|
||||
and a directory listing remains a non-recursive view of one folder.
|
||||
|
||||
Tool calls are checked against typed schemas before execution. Unknown tools,
|
||||
missing or extra fields, malformed calls, and invalid values return structured
|
||||
|
||||
349
src/agent.rs
349
src/agent.rs
@@ -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(¤t)?;
|
||||
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(),
|
||||
|
||||
Reference in New Issue
Block a user