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. /// /// /// 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" /// 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, all_matches_len: usize, }, Error { error: String, }, } impl From 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, } impl FindPathTool { pub fn new(project: Entity) -> 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, _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, input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { 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::>(), ), ); 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, cx: &mut App) -> Task>> { 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); }); } }