- drop agent_run_groups table and group_id/scope_kind/scope_id columns (schema v8) - remove group_id from AgentExecutionContext and recovery group counters - flatten TasksPage background tab into a per-run list - add WebUI Agents page with definition CRUD and inline provider/model - bump version to 1.11.0
450 lines
13 KiB
Rust
450 lines
13 KiB
Rust
use std::path::Path;
|
|
use std::process::Stdio;
|
|
|
|
use async_trait::async_trait;
|
|
use serde_json::json;
|
|
use tokio::process::Command;
|
|
use tokio::time::timeout;
|
|
|
|
use crate::tools::traits::{Tool, ToolResult};
|
|
|
|
const MAX_RESULTS: usize = 200;
|
|
const MAX_OUTPUT_CHARS: usize = 50_000;
|
|
const TIMEOUT_SECS: u64 = 60;
|
|
|
|
pub struct FileSearchTool;
|
|
|
|
impl FileSearchTool {
|
|
pub fn new() -> Self {
|
|
Self
|
|
}
|
|
|
|
fn resolve_dir(&self, dir: Option<&str>) -> String {
|
|
match dir {
|
|
Some(d) if !d.is_empty() => d.to_string(),
|
|
_ => ".".to_string(),
|
|
}
|
|
}
|
|
|
|
fn truncate_output(&self, lines: &[String]) -> String {
|
|
let mut output = String::new();
|
|
for (i, line) in lines.iter().enumerate() {
|
|
if output.len() + line.len() + 1 > MAX_OUTPUT_CHARS {
|
|
let omitted = lines.len() - i;
|
|
output.push_str(&format!("\n... ({} files omitted) ...", omitted));
|
|
break;
|
|
}
|
|
if !output.is_empty() {
|
|
output.push('\n');
|
|
}
|
|
output.push_str(line);
|
|
}
|
|
output
|
|
}
|
|
}
|
|
|
|
impl Default for FileSearchTool {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for FileSearchTool {
|
|
fn name(&self) -> &str {
|
|
"file_search"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Search for files by glob pattern (e.g. '*.rs', 'test_*.rs'). Internally uses fd (fast find) for efficient searching — if fd is not available, falls back to the find command, then pure Rust glob matching."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"pattern": {
|
|
"type": "string",
|
|
"description": "File glob pattern to search for (e.g. *.rs, test_*.rs, src/**/*.py)"
|
|
},
|
|
"dir": {
|
|
"type": "string",
|
|
"description": "Directory to search in (default: current working directory)"
|
|
},
|
|
"case_sensitive": {
|
|
"type": "boolean",
|
|
"description": "Whether to match case-sensitively (default: true)"
|
|
},
|
|
"max_results": {
|
|
"type": "integer",
|
|
"description": "Maximum number of results to return (default: 200)"
|
|
}
|
|
},
|
|
"required": ["pattern"]
|
|
})
|
|
}
|
|
|
|
fn read_only(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
|
let pattern = match args.get("pattern").and_then(|v| v.as_str()) {
|
|
Some(p) if !p.is_empty() => p,
|
|
_ => {
|
|
return Ok(ToolResult {
|
|
success: false,
|
|
output: String::new(),
|
|
error: Some("Missing required parameter: pattern".to_string()),
|
|
});
|
|
}
|
|
};
|
|
|
|
let dir = self.resolve_dir(args.get("dir").and_then(|v| v.as_str()));
|
|
let case_sensitive = args
|
|
.get("case_sensitive")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(true);
|
|
let max_results = args
|
|
.get("max_results")
|
|
.and_then(|v| v.as_u64())
|
|
.unwrap_or(MAX_RESULTS as u64) as usize;
|
|
|
|
let result = self
|
|
.run_search(pattern, &dir, case_sensitive, max_results)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(lines) => {
|
|
let count = lines.len();
|
|
let mut output = self.truncate_output(&lines);
|
|
output.push_str(&format!("\n\n---\n共 {} 个文件", count));
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output,
|
|
error: None,
|
|
})
|
|
}
|
|
Err(e) => Ok(ToolResult {
|
|
success: false,
|
|
output: String::new(),
|
|
error: Some(e.to_string()),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FileSearchTool {
|
|
async fn run_search(
|
|
&self,
|
|
pattern: &str,
|
|
dir: &str,
|
|
case_sensitive: bool,
|
|
max_results: usize,
|
|
) -> anyhow::Result<Vec<String>> {
|
|
let fd_cmd = if which::which("fd").is_ok() {
|
|
"fd"
|
|
} else if which::which("fdfind").is_ok() {
|
|
"fdfind"
|
|
} else {
|
|
""
|
|
};
|
|
|
|
if !fd_cmd.is_empty() {
|
|
match self
|
|
.search_with_fd(pattern, dir, case_sensitive, max_results, fd_cmd)
|
|
.await
|
|
{
|
|
Ok(lines) if !lines.is_empty() => return Ok(lines),
|
|
Ok(_) => {}
|
|
Err(e) => tracing::warn!("{} failed: {}, falling back", fd_cmd, e),
|
|
}
|
|
}
|
|
|
|
if which::which("find").is_ok() {
|
|
match self.search_with_find(pattern, dir, max_results).await {
|
|
Ok(lines) if !lines.is_empty() => return Ok(lines),
|
|
Ok(_) => {}
|
|
Err(e) => tracing::warn!("find failed: {}, falling back", e),
|
|
}
|
|
}
|
|
|
|
tracing::warn!("No fd/find available, using built-in file search (slower)");
|
|
self.search_with_rust(pattern, dir, case_sensitive, max_results)
|
|
.await
|
|
}
|
|
|
|
async fn search_with_fd(
|
|
&self,
|
|
pattern: &str,
|
|
dir: &str,
|
|
case_sensitive: bool,
|
|
max_results: usize,
|
|
fd_cmd: &str,
|
|
) -> anyhow::Result<Vec<String>> {
|
|
let mut cmd = Command::new(fd_cmd);
|
|
cmd.arg("--search-path")
|
|
.arg(dir)
|
|
.arg("--glob")
|
|
.arg(pattern)
|
|
.arg("--color")
|
|
.arg("never")
|
|
.arg("--max-results")
|
|
.arg(max_results.to_string())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
if !case_sensitive {
|
|
cmd.arg("--ignore-case");
|
|
}
|
|
|
|
let output = timeout(std::time::Duration::from_secs(TIMEOUT_SECS), cmd.output())
|
|
.await
|
|
.map_err(|_| anyhow::anyhow!("fd timed out after {}s", TIMEOUT_SECS))??;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(anyhow::anyhow!("fd error: {}", stderr.trim()));
|
|
}
|
|
|
|
let text = String::from_utf8_lossy(&output.stdout);
|
|
let lines: Vec<String> = text
|
|
.lines()
|
|
.filter(|l| !l.is_empty())
|
|
.map(|l| l.to_string())
|
|
.collect();
|
|
Ok(lines)
|
|
}
|
|
|
|
async fn search_with_find(
|
|
&self,
|
|
pattern: &str,
|
|
dir: &str,
|
|
max_results: usize,
|
|
) -> anyhow::Result<Vec<String>> {
|
|
let mut cmd = Command::new("find");
|
|
cmd.arg(dir)
|
|
.arg("-name")
|
|
.arg(pattern)
|
|
.arg("-not")
|
|
.arg("-path")
|
|
.arg("*/.*")
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null());
|
|
|
|
let output = timeout(std::time::Duration::from_secs(TIMEOUT_SECS), cmd.output())
|
|
.await
|
|
.map_err(|_| anyhow::anyhow!("find timed out after {}s", TIMEOUT_SECS))??;
|
|
|
|
let text = String::from_utf8_lossy(&output.stdout);
|
|
let mut lines: Vec<String> = text
|
|
.lines()
|
|
.filter(|l| !l.is_empty())
|
|
.map(|l| {
|
|
let p = Path::new(l);
|
|
p.to_string_lossy().to_string()
|
|
})
|
|
.collect();
|
|
if lines.len() > max_results {
|
|
lines.truncate(max_results);
|
|
}
|
|
Ok(lines)
|
|
}
|
|
|
|
async fn search_with_rust(
|
|
&self,
|
|
pattern: &str,
|
|
dir: &str,
|
|
case_sensitive: bool,
|
|
max_results: usize,
|
|
) -> anyhow::Result<Vec<String>> {
|
|
let regex_str = glob_to_regex(pattern);
|
|
let re = if case_sensitive {
|
|
regex::Regex::new(®ex_str)
|
|
} else {
|
|
regex::RegexBuilder::new(®ex_str)
|
|
.case_insensitive(true)
|
|
.build()
|
|
}
|
|
.map_err(|e| anyhow::anyhow!("Invalid glob pattern '{}': {}", pattern, e))?;
|
|
|
|
let mut results = Vec::new();
|
|
walk_dir(
|
|
Path::new(dir),
|
|
Path::new(dir),
|
|
&re,
|
|
&mut results,
|
|
max_results,
|
|
)?;
|
|
Ok(results)
|
|
}
|
|
}
|
|
|
|
fn glob_to_regex(glob: &str) -> String {
|
|
let mut regex = String::from("^");
|
|
let chars: Vec<char> = glob.chars().collect();
|
|
let mut i = 0;
|
|
while i < chars.len() {
|
|
match chars[i] {
|
|
'*' => {
|
|
if i + 1 < chars.len() && chars[i + 1] == '*' {
|
|
regex.push_str(".*");
|
|
i += 1;
|
|
} else {
|
|
regex.push_str("[^/]*");
|
|
}
|
|
}
|
|
'?' => regex.push_str("[^/]"),
|
|
'.' | '+' | '(' | ')' | '[' | ']' | '{' | '}' | '^' | '$' | '|' | '\\' => {
|
|
regex.push('\\');
|
|
regex.push(chars[i]);
|
|
}
|
|
c => regex.push(c),
|
|
}
|
|
i += 1;
|
|
}
|
|
regex.push('$');
|
|
regex
|
|
}
|
|
|
|
fn walk_dir(
|
|
base: &Path,
|
|
current: &Path,
|
|
re: ®ex::Regex,
|
|
results: &mut Vec<String>,
|
|
max: usize,
|
|
) -> anyhow::Result<()> {
|
|
if results.len() >= max {
|
|
return Ok(());
|
|
}
|
|
|
|
let entries = match std::fs::read_dir(current) {
|
|
Ok(e) => e,
|
|
Err(_) => return Ok(()),
|
|
};
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
let rel = match path.strip_prefix(base) {
|
|
Ok(r) => r,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
if path.is_dir() {
|
|
if let Some(name) = rel.file_name().and_then(|n| n.to_str())
|
|
&& name.starts_with('.')
|
|
&& name.len() > 1
|
|
{
|
|
continue;
|
|
}
|
|
walk_dir(base, &path, re, results, max)?;
|
|
} else if path.is_file() {
|
|
if let Some(name) = rel.file_name().and_then(|n| n.to_str())
|
|
&& re.is_match(name)
|
|
{
|
|
results.push(rel.to_string_lossy().to_string());
|
|
}
|
|
if results.len() >= max {
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
use tempfile::TempDir;
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn fd_search_path_does_not_use_incompatible_strip_cwd_prefix() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
let fake_fd = dir.path().join("fd");
|
|
fs::write(
|
|
&fake_fd,
|
|
r#"#!/bin/sh
|
|
for arg in "$@"; do
|
|
if [ "$arg" = "--strip-cwd-prefix" ]; then
|
|
echo "--strip-cwd-prefix conflicts with --search-path" >&2
|
|
exit 2
|
|
fi
|
|
done
|
|
printf '%s\n' 'example.rs'
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let mut permissions = fs::metadata(&fake_fd).unwrap().permissions();
|
|
permissions.set_mode(0o755);
|
|
fs::set_permissions(&fake_fd, permissions).unwrap();
|
|
|
|
let tool = FileSearchTool::new();
|
|
let results = tool
|
|
.search_with_fd(
|
|
"*.rs",
|
|
dir.path().to_str().unwrap(),
|
|
true,
|
|
10,
|
|
fake_fd.to_str().unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(results, vec!["example.rs"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_file_search_rust_fallback() {
|
|
let dir = TempDir::new().unwrap();
|
|
fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
|
|
fs::write(dir.path().join("lib.rs"), "pub fn foo() {}").unwrap();
|
|
fs::write(dir.path().join("test.rs"), "#[test] fn t() {}").unwrap();
|
|
fs::write(dir.path().join("README.md"), "# Readme").unwrap();
|
|
fs::create_dir(dir.path().join("src")).unwrap();
|
|
fs::write(dir.path().join("src/nested.rs"), "fn nested() {}").unwrap();
|
|
|
|
let tool = FileSearchTool::new();
|
|
let result = tool
|
|
.execute(json!({
|
|
"pattern": "*.rs",
|
|
"dir": dir.path().to_str().unwrap()
|
|
}))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(result.success);
|
|
assert!(result.output.contains("main.rs"));
|
|
assert!(result.output.contains("lib.rs"));
|
|
assert!(result.output.contains("test.rs"));
|
|
assert!(result.output.contains("nested.rs"));
|
|
assert!(!result.output.contains("README.md"));
|
|
assert!(result.output.contains("共 4 个文件"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_file_search_max_results() {
|
|
let dir = TempDir::new().unwrap();
|
|
for i in 0..5 {
|
|
fs::write(dir.path().join(format!("file_{}.rs", i)), "").unwrap();
|
|
}
|
|
|
|
let tool = FileSearchTool::new();
|
|
let result = tool
|
|
.execute(json!({
|
|
"pattern": "*.rs",
|
|
"dir": dir.path().to_str().unwrap(),
|
|
"max_results": 3
|
|
}))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(result.success);
|
|
assert!(result.output.contains("共 3 个文件"));
|
|
}
|
|
}
|