1use crate::{AgentTool, ToolCallEventStream};
2use agent_client_protocol as acp;
3use anyhow::{Result, anyhow};
4use futures::FutureExt as _;
5use gpui::{App, AppContext, Entity, SharedString, Task};
6use language_model::LanguageModelToolResultContent;
7use project::Project;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::fmt::Write;
11use std::{cmp, path::PathBuf, sync::Arc};
12use util::paths::PathMatcher;
13
14/// Fast file path pattern matching tool that works with any codebase size
15///
16/// - Supports glob patterns like "**/*.js" or "src/**/*.ts"
17/// - Returns matching file paths sorted alphabetically
18/// - Prefer the `grep` tool to this tool when searching for symbols unless you have specific information about paths.
19/// - Use this tool when you need to find files by name patterns
20/// - Results are paginated with 50 matches per page. Use the optional 'offset' parameter to request subsequent pages.
21#[derive(Debug, Serialize, Deserialize, JsonSchema)]
22pub struct FindPathToolInput {
23 /// The glob to match against every path in the project.
24 ///
25 /// <example>
26 /// If the project has the following root directories:
27 ///
28 /// - directory1/a/something.txt
29 /// - directory2/a/things.txt
30 /// - directory3/a/other.txt
31 ///
32 /// You can get back the first two paths by providing a glob of "*thing*.txt"
33 /// </example>
34 pub glob: String,
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() -> &'static str {
90 "find_path"
91 }
92
93 fn kind() -> acp::ToolKind {
94 acp::ToolKind::Search
95 }
96
97 fn initial_title(
98 &self,
99 input: Result<Self::Input, serde_json::Value>,
100 _cx: &mut App,
101 ) -> SharedString {
102 let mut title = "Find paths".to_string();
103 if let Ok(input) = input {
104 title.push_str(&format!(" matching “`{}`”", input.glob));
105 }
106 title.into()
107 }
108
109 fn run(
110 self: Arc<Self>,
111 input: Self::Input,
112 event_stream: ToolCallEventStream,
113 cx: &mut App,
114 ) -> Task<Result<FindPathToolOutput>> {
115 let search_paths_task = search_paths(&input.glob, self.project.clone(), cx);
116
117 cx.background_spawn(async move {
118 let matches = futures::select! {
119 result = search_paths_task.fuse() => result?,
120 _ = event_stream.cancelled_by_user().fuse() => {
121 anyhow::bail!("Path search cancelled by user");
122 }
123 };
124 let paginated_matches: &[PathBuf] = &matches[cmp::min(input.offset, matches.len())
125 ..cmp::min(input.offset + RESULTS_PER_PAGE, matches.len())];
126
127 event_stream.update_fields(
128 acp::ToolCallUpdateFields::new()
129 .title(if paginated_matches.is_empty() {
130 "No matches".into()
131 } else if paginated_matches.len() == 1 {
132 "1 match".into()
133 } else {
134 format!("{} matches", paginated_matches.len())
135 })
136 .content(
137 paginated_matches
138 .iter()
139 .map(|path| {
140 acp::ToolCallContent::Content(acp::Content::new(
141 acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
142 path.to_string_lossy(),
143 format!("file://{}", path.display()),
144 )),
145 ))
146 })
147 .collect::<Vec<_>>(),
148 ),
149 );
150
151 Ok(FindPathToolOutput {
152 offset: input.offset,
153 current_matches_page: paginated_matches.to_vec(),
154 all_matches_len: matches.len(),
155 })
156 })
157 }
158}
159
160fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Result<Vec<PathBuf>>> {
161 let path_style = project.read(cx).path_style(cx);
162 let path_matcher = match PathMatcher::new(
163 [
164 // Sometimes models try to search for "". In this case, return all paths in the project.
165 if glob.is_empty() { "*" } else { glob },
166 ],
167 path_style,
168 ) {
169 Ok(matcher) => matcher,
170 Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))),
171 };
172 let snapshots: Vec<_> = project
173 .read(cx)
174 .worktrees(cx)
175 .map(|worktree| worktree.read(cx).snapshot())
176 .collect();
177
178 cx.background_spawn(async move {
179 let mut results = Vec::new();
180 for snapshot in snapshots {
181 for entry in snapshot.entries(false, 0) {
182 if path_matcher.is_match(&snapshot.root_name().join(&entry.path)) {
183 results.push(snapshot.absolutize(&entry.path));
184 }
185 }
186 }
187
188 Ok(results)
189 })
190}
191
192#[cfg(test)]
193mod test {
194 use super::*;
195 use gpui::TestAppContext;
196 use project::{FakeFs, Project};
197 use settings::SettingsStore;
198 use util::path;
199
200 #[gpui::test]
201 async fn test_find_path_tool(cx: &mut TestAppContext) {
202 init_test(cx);
203
204 let fs = FakeFs::new(cx.executor());
205 fs.insert_tree(
206 "/root",
207 serde_json::json!({
208 "apple": {
209 "banana": {
210 "carrot": "1",
211 },
212 "bandana": {
213 "carbonara": "2",
214 },
215 "endive": "3"
216 }
217 }),
218 )
219 .await;
220 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
221
222 let matches = cx
223 .update(|cx| search_paths("root/**/car*", project.clone(), cx))
224 .await
225 .unwrap();
226 assert_eq!(
227 matches,
228 &[
229 PathBuf::from(path!("/root/apple/banana/carrot")),
230 PathBuf::from(path!("/root/apple/bandana/carbonara"))
231 ]
232 );
233
234 let matches = cx
235 .update(|cx| search_paths("**/car*", project.clone(), cx))
236 .await
237 .unwrap();
238 assert_eq!(
239 matches,
240 &[
241 PathBuf::from(path!("/root/apple/banana/carrot")),
242 PathBuf::from(path!("/root/apple/bandana/carbonara"))
243 ]
244 );
245 }
246
247 fn init_test(cx: &mut TestAppContext) {
248 cx.update(|cx| {
249 let settings_store = SettingsStore::test(cx);
250 cx.set_global(settings_store);
251 });
252 }
253}