find_path_tool.rs

  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}