use crate::{AgentTool, ToolCallEventStream, ToolInput};use acp_thread::MentionUri;use agent_client_protocol::schema::v1 as acp;use anyhow::{Result, anyhow};use futures::FutureExt as _;use gpui::{App, AppContext, Entity, SharedString, Task};use language_model::LanguageModelToolResultContent;use project::Project;use schemars::JsonSchema;use serde::{Deserialize, Serialize};use std::fmt::Write;use std::{cmp, path::PathBuf, sync::Arc};use util::paths::PathMatcher; /// Find file paths that match a given pattern.////// - Supports glob patterns like "**/*.js" or "src/**/*.ts"/// - Returns matching file paths sorted alphabetically/// - Prefer the `grep` tool to this tool when searching for symbols unless you have specific information about paths./// - Use this tool when you need to find files by name patterns/// - Results are paginated with 50 matches per page. Use the optional 'offset' parameter to request subsequent pages.#[derive(Debug, Serialize, Deserialize, JsonSchema)]pub struct FindPathToolInput { /// The glob to match against every path in the project. /// /// <example> /// If the project has the following root directories: /// /// - directory1/a/something.txt /// - directory2/a/things.txt /// - directory3/a/other.txt /// /// You can get back the first two paths by providing a glob of "*thing*.txt" /// </example> pub glob: String, /// Optional starting position for paginated results (0-based). /// When not provided, starts from the beginning. #[serde(default)] pub offset: usize,} #[derive(Debug, Serialize, Deserialize)]#[serde(untagged)]pub enum FindPathToolOutput { Success { offset: usize, current_matches_page: Vec<PathBuf>, all_matches_len: usize, }, Error { error: String, },} impl From<FindPathToolOutput> for LanguageModelToolResultContent { fn from(output: FindPathToolOutput) -> Self { match output { FindPathToolOutput::Success { offset, current_matches_page, all_matches_len, } => { if current_matches_page.is_empty() { "No matches found".into() } else { let mut llm_output = format!("Found {} total matches.", all_matches_len); if all_matches_len > RESULTS_PER_PAGE { write!( &mut llm_output, "\nShowing results {}-{} (provide 'offset' parameter for more results):", offset + 1, offset + current_matches_page.len() ) .ok(); } for mat in current_matches_page { write!(&mut llm_output, "\n{}", mat.display()).ok(); } llm_output.into() } } FindPathToolOutput::Error { error } => error.into(), } }} const RESULTS_PER_PAGE: usize = 50; pub struct FindPathTool { project: Entity<Project>,} impl FindPathTool { pub fn new(project: Entity<Project>) -> Self { Self { project } }} impl AgentTool for FindPathTool { type Input = FindPathToolInput; type Output = FindPathToolOutput; const NAME: &'static str = "find_path"; fn kind() -> acp::ToolKind { acp::ToolKind::Search } fn initial_title( &self, input: Result<Self::Input, serde_json::Value>, _cx: &mut App, ) -> SharedString { let mut title = "Find paths".to_string(); if let Ok(input) = input { title.push_str(&format!(" matching “`{}`”", input.glob)); } title.into() } fn run( self: Arc<Self>, input: ToolInput<Self::Input>, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task<Result<Self::Output, Self::Output>> { let project = self.project.clone(); cx.spawn(async move |cx| { let input = input.recv().await.map_err(|e| FindPathToolOutput::Error { error: e.to_string(), })?; let search_paths_task = cx.update(|cx| search_paths(&input.glob, project, cx)); let matches = futures::select! { result = search_paths_task.fuse() => result.map_err(|e| FindPathToolOutput::Error { error: e.to_string() })?, _ = event_stream.cancelled_by_user().fuse() => { return Err(FindPathToolOutput::Error { error: "Path search cancelled by user".to_string() }); } }; let paginated_matches: &[PathBuf] = &matches[cmp::min(input.offset, matches.len()) ..cmp::min(input.offset + RESULTS_PER_PAGE, matches.len())]; event_stream.update_fields( acp::ToolCallUpdateFields::new() .title(if paginated_matches.is_empty() { "No matches".into() } else if paginated_matches.len() == 1 { "1 match".into() } else { format!("{} matches", paginated_matches.len()) }) .content( paginated_matches .iter() .map(|path| { let uri = MentionUri::File { abs_path: path.clone(), }; acp::ToolCallContent::Content(acp::Content::new( acp::ContentBlock::ResourceLink(acp::ResourceLink::new( path.to_string_lossy(), uri.to_uri().to_string(), )), )) }) .collect::<Vec<_>>(), ), ); Ok(FindPathToolOutput::Success { offset: input.offset, current_matches_page: paginated_matches.to_vec(), all_matches_len: matches.len(), }) }) }} fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Result<Vec<PathBuf>>> { let path_style = project.read(cx).path_style(cx); let path_matcher = match PathMatcher::new( [ // Sometimes models try to search for "". In this case, return all paths in the project. if glob.is_empty() { "*" } else { glob }, ], path_style, ) { Ok(matcher) => matcher, Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))), }; let snapshots: Vec<_> = project .read(cx) .worktrees(cx) .map(|worktree| worktree.read(cx).snapshot()) .collect(); cx.background_spawn(async move { let mut results = Vec::new(); for snapshot in snapshots { for entry in snapshot.entries(false, 0) { if path_matcher.is_match(&snapshot.root_name().join(&entry.path)) { results.push(snapshot.absolutize(&entry.path)); } } } Ok(results) })} #[cfg(test)]mod test { use super::*; use gpui::TestAppContext; use project::{FakeFs, Project}; use settings::SettingsStore; use util::path; #[gpui::test] async fn test_find_path_tool(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( "/root", serde_json::json!({ "apple": { "banana": { "carrot": "1", }, "bandana": { "carbonara": "2", }, "endive": "3" } }), ) .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let matches = cx .update(|cx| search_paths("root/**/car*", project.clone(), cx)) .await .unwrap(); assert_eq!( matches, &[ PathBuf::from(path!("/root/apple/banana/carrot")), PathBuf::from(path!("/root/apple/bandana/carbonara")) ] ); let matches = cx .update(|cx| search_paths("**/car*", project.clone(), cx)) .await .unwrap(); assert_eq!( matches, &[ PathBuf::from(path!("/root/apple/banana/carrot")), PathBuf::from(path!("/root/apple/bandana/carbonara")) ] ); } fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); }); }}
use crate::{AgentTool, ToolCallEventStream, ToolInput};
use acp_thread::MentionUri;
use agent_client_protocol::schema::v1 as acp;
use anyhow::{Result, anyhow};
use futures::FutureExt as _;
use gpui::{App, AppContext, Entity, SharedString, Task};
use language_model::LanguageModelToolResultContent;
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::fmt::Write;
use std::{cmp, path::PathBuf, sync::Arc};
use util::paths::PathMatcher;
/// Find file paths that match a given pattern.
///
/// - Supports glob patterns like "**/*.js" or "src/**/*.ts"
/// - Returns matching file paths sorted alphabetically
/// - Prefer the `grep` tool to this tool when searching for symbols unless you have specific information about paths.
/// - Use this tool when you need to find files by name patterns
/// - Results are paginated with 50 matches per page. Use the optional 'offset' parameter to request subsequent pages.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct FindPathToolInput {
/// The glob to match against every path in the project.
///
/// <example>
/// If the project has the following root directories:
///
/// - directory1/a/something.txt
/// - directory2/a/things.txt
/// - directory3/a/other.txt
///
/// You can get back the first two paths by providing a glob of "*thing*.txt"
/// </example>
pub glob: String,
/// Optional starting position for paginated results (0-based).
/// When not provided, starts from the beginning.
#[serde(default)]
pub offset: usize,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FindPathToolOutput {
Success {
offset: usize,
current_matches_page: Vec<PathBuf>,
all_matches_len: usize,
},
Error {
error: String,
},
}
impl From<FindPathToolOutput> for LanguageModelToolResultContent {
fn from(output: FindPathToolOutput) -> Self {
match output {
FindPathToolOutput::Success {
offset,
current_matches_page,
all_matches_len,
} => {
if current_matches_page.is_empty() {
"No matches found".into()
} else {
let mut llm_output = format!("Found {} total matches.", all_matches_len);
if all_matches_len > RESULTS_PER_PAGE {
write!(
&mut llm_output,
"\nShowing results {}-{} (provide 'offset' parameter for more results):",
offset + 1,
offset + current_matches_page.len()
)
.ok();
}
for mat in current_matches_page {
write!(&mut llm_output, "\n{}", mat.display()).ok();
}
llm_output.into()
}
}
FindPathToolOutput::Error { error } => error.into(),
}
}
}
const RESULTS_PER_PAGE: usize = 50;
pub struct FindPathTool {
project: Entity<Project>,
}
impl FindPathTool {
pub fn new(project: Entity<Project>) -> Self {
Self { project }
}
}
impl AgentTool for FindPathTool {
type Input = FindPathToolInput;
type Output = FindPathToolOutput;
const NAME: &'static str = "find_path";
fn kind() -> acp::ToolKind {
acp::ToolKind::Search
}
fn initial_title(
&self,
input: Result<Self::Input, serde_json::Value>,
_cx: &mut App,
) -> SharedString {
let mut title = "Find paths".to_string();
if let Ok(input) = input {
title.push_str(&format!(" matching “`{}`”", input.glob));
}
title.into()
}
fn run(
self: Arc<Self>,
input: ToolInput<Self::Input>,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<Self::Output, Self::Output>> {
let project = self.project.clone();
cx.spawn(async move |cx| {
let input = input.recv().await.map_err(|e| FindPathToolOutput::Error {
error: e.to_string(),
})?;
let search_paths_task = cx.update(|cx| search_paths(&input.glob, project, cx));
let matches = futures::select! {
result = search_paths_task.fuse() => result.map_err(|e| FindPathToolOutput::Error { error: e.to_string() })?,
_ = event_stream.cancelled_by_user().fuse() => {
return Err(FindPathToolOutput::Error { error: "Path search cancelled by user".to_string() });
}
};
let paginated_matches: &[PathBuf] = &matches[cmp::min(input.offset, matches.len())
..cmp::min(input.offset + RESULTS_PER_PAGE, matches.len())];
event_stream.update_fields(
acp::ToolCallUpdateFields::new()
.title(if paginated_matches.is_empty() {
"No matches".into()
} else if paginated_matches.len() == 1 {
"1 match".into()
} else {
format!("{} matches", paginated_matches.len())
})
.content(
paginated_matches
.iter()
.map(|path| {
let uri = MentionUri::File {
abs_path: path.clone(),
};
acp::ToolCallContent::Content(acp::Content::new(
acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
path.to_string_lossy(),
uri.to_uri().to_string(),
)),
))
})
.collect::<Vec<_>>(),
),
);
Ok(FindPathToolOutput::Success {
offset: input.offset,
current_matches_page: paginated_matches.to_vec(),
all_matches_len: matches.len(),
})
})
}
}
fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Result<Vec<PathBuf>>> {
let path_style = project.read(cx).path_style(cx);
let path_matcher = match PathMatcher::new(
[
// Sometimes models try to search for "". In this case, return all paths in the project.
if glob.is_empty() { "*" } else { glob },
],
path_style,
) {
Ok(matcher) => matcher,
Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))),
};
let snapshots: Vec<_> = project
.read(cx)
.worktrees(cx)
.map(|worktree| worktree.read(cx).snapshot())
.collect();
cx.background_spawn(async move {
let mut results = Vec::new();
for snapshot in snapshots {
for entry in snapshot.entries(false, 0) {
if path_matcher.is_match(&snapshot.root_name().join(&entry.path)) {
results.push(snapshot.absolutize(&entry.path));
}
}
}
Ok(results)
})
}
#[cfg(test)]
mod test {
use super::*;
use gpui::TestAppContext;
use project::{FakeFs, Project};
use settings::SettingsStore;
use util::path;
#[gpui::test]
async fn test_find_path_tool(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
serde_json::json!({
"apple": {
"banana": {
"carrot": "1",
},
"bandana": {
"carbonara": "2",
},
"endive": "3"
}
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let matches = cx
.update(|cx| search_paths("root/**/car*", project.clone(), cx))
.await
.unwrap();
assert_eq!(
matches,
&[
PathBuf::from(path!("/root/apple/banana/carrot")),
PathBuf::from(path!("/root/apple/bandana/carbonara"))
]
);
let matches = cx
.update(|cx| search_paths("**/car*", project.clone(), cx))
.await
.unwrap();
assert_eq!(
matches,
&[
PathBuf::from(path!("/root/apple/banana/carrot")),
PathBuf::from(path!("/root/apple/bandana/carbonara"))
]
);
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
}
}