1use crate::{AgentTool, ToolCallEventStream};
2use agent_client_protocol as acp;
3use anyhow::{Result, anyhow};
4use gpui::{App, AppContext, Entity, SharedString, Task};
5use language_model::LanguageModelToolResultContent;
6use project::Project;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::fmt::Write;
10use std::{cmp, path::PathBuf, sync::Arc};
11use util::paths::PathMatcher;
12
13/// Fast file path pattern matching tool that works with any codebase size
14///
15/// - Supports glob patterns like "**/*.js" or "src/**/*.ts"
16/// - Returns matching file paths sorted alphabetically
17/// - Prefer the `grep` tool to this tool when searching for symbols unless you have specific information about paths.
18/// - Use this tool when you need to find files by name patterns
19/// - Results are paginated with 50 matches per page. Use the optional 'offset' parameter to request subsequent pages.
20#[derive(Debug, Serialize, Deserialize, JsonSchema)]
21pub struct FindPathToolInput {
22 /// The glob to match against every path in the project.
23 ///
24 /// <example>
25 /// If the project has the following root directories:
26 ///
27 /// - directory1/a/something.txt
28 /// - directory2/a/things.txt
29 /// - directory3/a/other.txt
30 ///
31 /// You can get back the first two paths by providing a glob of "*thing*.txt"
32 /// </example>
33 pub glob: String,
34
35 /// Optional starting position for paginated results (0-based).
36 /// When not provided, starts from the beginning.
37 #[serde(default)]
38 pub offset: usize,
39}
40
41#[derive(Debug, Serialize, Deserialize)]
42pub struct FindPathToolOutput {
43 offset: usize,
44 current_matches_page: Vec<PathBuf>,
45 all_matches_len: usize,
46}
47
48impl From<FindPathToolOutput> for LanguageModelToolResultContent {
49 fn from(output: FindPathToolOutput) -> Self {
50 if output.current_matches_page.is_empty() {
51 "No matches found".into()
52 } else {
53 let mut llm_output = format!("Found {} total matches.", output.all_matches_len);
54 if output.all_matches_len > RESULTS_PER_PAGE {
55 write!(
56 &mut llm_output,
57 "\nShowing results {}-{} (provide 'offset' parameter for more results):",
58 output.offset + 1,
59 output.offset + output.current_matches_page.len()
60 )
61 .unwrap();
62 }
63
64 for mat in output.current_matches_page {
65 write!(&mut llm_output, "\n{}", mat.display()).unwrap();
66 }
67
68 llm_output.into()
69 }
70 }
71}
72
73const RESULTS_PER_PAGE: usize = 50;
74
75pub struct FindPathTool {
76 project: Entity<Project>,
77}
78
79impl FindPathTool {
80 pub fn new(project: Entity<Project>) -> Self {
81 Self { project }
82 }
83}
84
85impl AgentTool for FindPathTool {
86 type Input = FindPathToolInput;
87 type Output = FindPathToolOutput;
88
89 fn name(&self) -> SharedString {
90 "find_path".into()
91 }
92
93 fn kind(&self) -> acp::ToolKind {
94 acp::ToolKind::Search
95 }
96
97 fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
98 let mut title = "Find paths".to_string();
99 if let Ok(input) = input {
100 title.push_str(&format!(" matching “`{}`”", input.glob));
101 }
102 title.into()
103 }
104
105 fn run(
106 self: Arc<Self>,
107 input: Self::Input,
108 event_stream: ToolCallEventStream,
109 cx: &mut App,
110 ) -> Task<Result<FindPathToolOutput>> {
111 let search_paths_task = search_paths(&input.glob, self.project.clone(), cx);
112
113 cx.background_spawn(async move {
114 let matches = search_paths_task.await?;
115 let paginated_matches: &[PathBuf] = &matches[cmp::min(input.offset, matches.len())
116 ..cmp::min(input.offset + RESULTS_PER_PAGE, matches.len())];
117
118 event_stream.update_fields(acp::ToolCallUpdateFields {
119 title: Some(if paginated_matches.len() == 0 {
120 "No matches".into()
121 } else if paginated_matches.len() == 1 {
122 "1 match".into()
123 } else {
124 format!("{} matches", paginated_matches.len())
125 }),
126 content: Some(
127 paginated_matches
128 .iter()
129 .map(|path| acp::ToolCallContent::Content {
130 content: acp::ContentBlock::ResourceLink(acp::ResourceLink {
131 uri: format!("file://{}", path.display()),
132 name: path.to_string_lossy().into(),
133 annotations: None,
134 description: None,
135 mime_type: None,
136 size: None,
137 title: None,
138 }),
139 })
140 .collect(),
141 ),
142 ..Default::default()
143 });
144
145 Ok(FindPathToolOutput {
146 offset: input.offset,
147 current_matches_page: paginated_matches.to_vec(),
148 all_matches_len: matches.len(),
149 })
150 })
151 }
152}
153
154fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Result<Vec<PathBuf>>> {
155 let path_matcher = match PathMatcher::new([
156 // Sometimes models try to search for "". In this case, return all paths in the project.
157 if glob.is_empty() { "*" } else { glob },
158 ]) {
159 Ok(matcher) => matcher,
160 Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))),
161 };
162 let snapshots: Vec<_> = project
163 .read(cx)
164 .worktrees(cx)
165 .map(|worktree| worktree.read(cx).snapshot())
166 .collect();
167
168 cx.background_spawn(async move {
169 Ok(snapshots
170 .iter()
171 .flat_map(|snapshot| {
172 let root_name = PathBuf::from(snapshot.root_name());
173 snapshot
174 .entries(false, 0)
175 .map(move |entry| root_name.join(&entry.path))
176 .filter(|path| path_matcher.is_match(&path))
177 })
178 .collect())
179 })
180}
181
182#[cfg(test)]
183mod test {
184 use super::*;
185 use gpui::TestAppContext;
186 use project::{FakeFs, Project};
187 use settings::SettingsStore;
188 use util::path;
189
190 #[gpui::test]
191 async fn test_find_path_tool(cx: &mut TestAppContext) {
192 init_test(cx);
193
194 let fs = FakeFs::new(cx.executor());
195 fs.insert_tree(
196 "/root",
197 serde_json::json!({
198 "apple": {
199 "banana": {
200 "carrot": "1",
201 },
202 "bandana": {
203 "carbonara": "2",
204 },
205 "endive": "3"
206 }
207 }),
208 )
209 .await;
210 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
211
212 let matches = cx
213 .update(|cx| search_paths("root/**/car*", project.clone(), cx))
214 .await
215 .unwrap();
216 assert_eq!(
217 matches,
218 &[
219 PathBuf::from("root/apple/banana/carrot"),
220 PathBuf::from("root/apple/bandana/carbonara")
221 ]
222 );
223
224 let matches = cx
225 .update(|cx| search_paths("**/car*", project.clone(), cx))
226 .await
227 .unwrap();
228 assert_eq!(
229 matches,
230 &[
231 PathBuf::from("root/apple/banana/carrot"),
232 PathBuf::from("root/apple/bandana/carbonara")
233 ]
234 );
235 }
236
237 fn init_test(cx: &mut TestAppContext) {
238 cx.update(|cx| {
239 let settings_store = SettingsStore::test(cx);
240 cx.set_global(settings_store);
241 language::init(cx);
242 Project::init_settings(cx);
243 });
244 }
245}