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