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