find_path_tool.rs

  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}