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)]
42#[serde(untagged)]
43pub enum FindPathToolOutput {
44 Success {
45 offset: usize,
46 current_matches_page: Vec<PathBuf>,
47 all_matches_len: usize,
48 },
49 Error {
50 error: String,
51 },
52}
53
54impl From<FindPathToolOutput> for LanguageModelToolResultContent {
55 fn from(output: FindPathToolOutput) -> Self {
56 match output {
57 FindPathToolOutput::Success {
58 offset,
59 current_matches_page,
60 all_matches_len,
61 } => {
62 if current_matches_page.is_empty() {
63 "No matches found".into()
64 } else {
65 let mut llm_output = format!("Found {} total matches.", all_matches_len);
66 if all_matches_len > RESULTS_PER_PAGE {
67 write!(
68 &mut llm_output,
69 "\nShowing results {}-{} (provide 'offset' parameter for more results):",
70 offset + 1,
71 offset + current_matches_page.len()
72 )
73 .ok();
74 }
75
76 for mat in current_matches_page {
77 write!(&mut llm_output, "\n{}", mat.display()).ok();
78 }
79
80 llm_output.into()
81 }
82 }
83 FindPathToolOutput::Error { error } => error.into(),
84 }
85 }
86}
87
88const RESULTS_PER_PAGE: usize = 50;
89
90pub struct FindPathTool {
91 project: Entity<Project>,
92}
93
94impl FindPathTool {
95 pub fn new(project: Entity<Project>) -> Self {
96 Self { project }
97 }
98}
99
100impl AgentTool for FindPathTool {
101 type Input = FindPathToolInput;
102 type Output = FindPathToolOutput;
103
104 const NAME: &'static str = "find_path";
105
106 fn kind() -> acp::ToolKind {
107 acp::ToolKind::Search
108 }
109
110 fn initial_title(
111 &self,
112 input: Result<Self::Input, serde_json::Value>,
113 _cx: &mut App,
114 ) -> SharedString {
115 let mut title = "Find paths".to_string();
116 if let Ok(input) = input {
117 title.push_str(&format!(" matching “`{}`”", input.glob));
118 }
119 title.into()
120 }
121
122 fn run(
123 self: Arc<Self>,
124 input: Self::Input,
125 event_stream: ToolCallEventStream,
126 cx: &mut App,
127 ) -> Task<Result<Self::Output, Self::Output>> {
128 let search_paths_task = search_paths(&input.glob, self.project.clone(), cx);
129
130 cx.background_spawn(async move {
131 let matches = futures::select! {
132 result = search_paths_task.fuse() => result.map_err(|e| FindPathToolOutput::Error { error: e.to_string() })?,
133 _ = event_stream.cancelled_by_user().fuse() => {
134 return Err(FindPathToolOutput::Error { error: "Path search cancelled by user".to_string() });
135 }
136 };
137 let paginated_matches: &[PathBuf] = &matches[cmp::min(input.offset, matches.len())
138 ..cmp::min(input.offset + RESULTS_PER_PAGE, matches.len())];
139
140 event_stream.update_fields(
141 acp::ToolCallUpdateFields::new()
142 .title(if paginated_matches.is_empty() {
143 "No matches".into()
144 } else if paginated_matches.len() == 1 {
145 "1 match".into()
146 } else {
147 format!("{} matches", paginated_matches.len())
148 })
149 .content(
150 paginated_matches
151 .iter()
152 .map(|path| {
153 acp::ToolCallContent::Content(acp::Content::new(
154 acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
155 path.to_string_lossy(),
156 format!("file://{}", path.display()),
157 )),
158 ))
159 })
160 .collect::<Vec<_>>(),
161 ),
162 );
163
164 Ok(FindPathToolOutput::Success {
165 offset: input.offset,
166 current_matches_page: paginated_matches.to_vec(),
167 all_matches_len: matches.len(),
168 })
169 })
170 }
171}
172
173fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Result<Vec<PathBuf>>> {
174 let path_style = project.read(cx).path_style(cx);
175 let path_matcher = match PathMatcher::new(
176 [
177 // Sometimes models try to search for "". In this case, return all paths in the project.
178 if glob.is_empty() { "*" } else { glob },
179 ],
180 path_style,
181 ) {
182 Ok(matcher) => matcher,
183 Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))),
184 };
185 let snapshots: Vec<_> = project
186 .read(cx)
187 .worktrees(cx)
188 .map(|worktree| worktree.read(cx).snapshot())
189 .collect();
190
191 cx.background_spawn(async move {
192 let mut results = Vec::new();
193 for snapshot in snapshots {
194 for entry in snapshot.entries(false, 0) {
195 if path_matcher.is_match(&snapshot.root_name().join(&entry.path)) {
196 results.push(snapshot.absolutize(&entry.path));
197 }
198 }
199 }
200
201 Ok(results)
202 })
203}
204
205#[cfg(test)]
206mod test {
207 use super::*;
208 use gpui::TestAppContext;
209 use project::{FakeFs, Project};
210 use settings::SettingsStore;
211 use util::path;
212
213 #[gpui::test]
214 async fn test_find_path_tool(cx: &mut TestAppContext) {
215 init_test(cx);
216
217 let fs = FakeFs::new(cx.executor());
218 fs.insert_tree(
219 "/root",
220 serde_json::json!({
221 "apple": {
222 "banana": {
223 "carrot": "1",
224 },
225 "bandana": {
226 "carbonara": "2",
227 },
228 "endive": "3"
229 }
230 }),
231 )
232 .await;
233 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
234
235 let matches = cx
236 .update(|cx| search_paths("root/**/car*", project.clone(), cx))
237 .await
238 .unwrap();
239 assert_eq!(
240 matches,
241 &[
242 PathBuf::from(path!("/root/apple/banana/carrot")),
243 PathBuf::from(path!("/root/apple/bandana/carbonara"))
244 ]
245 );
246
247 let matches = cx
248 .update(|cx| search_paths("**/car*", project.clone(), cx))
249 .await
250 .unwrap();
251 assert_eq!(
252 matches,
253 &[
254 PathBuf::from(path!("/root/apple/banana/carrot")),
255 PathBuf::from(path!("/root/apple/bandana/carbonara"))
256 ]
257 );
258 }
259
260 fn init_test(cx: &mut TestAppContext) {
261 cx.update(|cx| {
262 let settings_store = SettingsStore::test(cx);
263 cx.set_global(settings_store);
264 });
265 }
266}