1use crate::{AgentTool, ToolCallEventStream, ToolInput};
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: ToolInput<Self::Input>,
125 event_stream: ToolCallEventStream,
126 cx: &mut App,
127 ) -> Task<Result<Self::Output, Self::Output>> {
128 let project = self.project.clone();
129 cx.spawn(async move |cx| {
130 let input = input.recv().await.map_err(|e| FindPathToolOutput::Error {
131 error: format!("Failed to receive tool input: {e}"),
132 })?;
133
134 let search_paths_task = cx.update(|cx| search_paths(&input.glob, project, cx));
135
136 let matches = futures::select! {
137 result = search_paths_task.fuse() => result.map_err(|e| FindPathToolOutput::Error { error: e.to_string() })?,
138 _ = event_stream.cancelled_by_user().fuse() => {
139 return Err(FindPathToolOutput::Error { error: "Path search cancelled by user".to_string() });
140 }
141 };
142 let paginated_matches: &[PathBuf] = &matches[cmp::min(input.offset, matches.len())
143 ..cmp::min(input.offset + RESULTS_PER_PAGE, matches.len())];
144
145 event_stream.update_fields(
146 acp::ToolCallUpdateFields::new()
147 .title(if paginated_matches.is_empty() {
148 "No matches".into()
149 } else if paginated_matches.len() == 1 {
150 "1 match".into()
151 } else {
152 format!("{} matches", paginated_matches.len())
153 })
154 .content(
155 paginated_matches
156 .iter()
157 .map(|path| {
158 acp::ToolCallContent::Content(acp::Content::new(
159 acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
160 path.to_string_lossy(),
161 format!("file://{}", path.display()),
162 )),
163 ))
164 })
165 .collect::<Vec<_>>(),
166 ),
167 );
168
169 Ok(FindPathToolOutput::Success {
170 offset: input.offset,
171 current_matches_page: paginated_matches.to_vec(),
172 all_matches_len: matches.len(),
173 })
174 })
175 }
176}
177
178fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Result<Vec<PathBuf>>> {
179 let path_style = project.read(cx).path_style(cx);
180 let path_matcher = match PathMatcher::new(
181 [
182 // Sometimes models try to search for "". In this case, return all paths in the project.
183 if glob.is_empty() { "*" } else { glob },
184 ],
185 path_style,
186 ) {
187 Ok(matcher) => matcher,
188 Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))),
189 };
190 let snapshots: Vec<_> = project
191 .read(cx)
192 .worktrees(cx)
193 .map(|worktree| worktree.read(cx).snapshot())
194 .collect();
195
196 cx.background_spawn(async move {
197 let mut results = Vec::new();
198 for snapshot in snapshots {
199 for entry in snapshot.entries(false, 0) {
200 if path_matcher.is_match(&snapshot.root_name().join(&entry.path)) {
201 results.push(snapshot.absolutize(&entry.path));
202 }
203 }
204 }
205
206 Ok(results)
207 })
208}
209
210#[cfg(test)]
211mod test {
212 use super::*;
213 use gpui::TestAppContext;
214 use project::{FakeFs, Project};
215 use settings::SettingsStore;
216 use util::path;
217
218 #[gpui::test]
219 async fn test_find_path_tool(cx: &mut TestAppContext) {
220 init_test(cx);
221
222 let fs = FakeFs::new(cx.executor());
223 fs.insert_tree(
224 "/root",
225 serde_json::json!({
226 "apple": {
227 "banana": {
228 "carrot": "1",
229 },
230 "bandana": {
231 "carbonara": "2",
232 },
233 "endive": "3"
234 }
235 }),
236 )
237 .await;
238 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
239
240 let matches = cx
241 .update(|cx| search_paths("root/**/car*", project.clone(), cx))
242 .await
243 .unwrap();
244 assert_eq!(
245 matches,
246 &[
247 PathBuf::from(path!("/root/apple/banana/carrot")),
248 PathBuf::from(path!("/root/apple/bandana/carbonara"))
249 ]
250 );
251
252 let matches = cx
253 .update(|cx| search_paths("**/car*", project.clone(), cx))
254 .await
255 .unwrap();
256 assert_eq!(
257 matches,
258 &[
259 PathBuf::from(path!("/root/apple/banana/carrot")),
260 PathBuf::from(path!("/root/apple/bandana/carbonara"))
261 ]
262 );
263 }
264
265 fn init_test(cx: &mut TestAppContext) {
266 cx.update(|cx| {
267 let settings_store = SettingsStore::test(cx);
268 cx.set_global(settings_store);
269 });
270 }
271}