find_path_tool.rs

  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 const 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                                meta: None,
142                            }),
143                        })
144                        .collect(),
145                ),
146                ..Default::default()
147            });
148
149            Ok(FindPathToolOutput {
150                offset: input.offset,
151                current_matches_page: paginated_matches.to_vec(),
152                all_matches_len: matches.len(),
153            })
154        })
155    }
156}
157
158fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Result<Vec<PathBuf>>> {
159    let path_style = project.read(cx).path_style(cx);
160    let path_matcher = match PathMatcher::new(
161        [
162            // Sometimes models try to search for "". In this case, return all paths in the project.
163            if glob.is_empty() { "*" } else { glob },
164        ],
165        path_style,
166    ) {
167        Ok(matcher) => matcher,
168        Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))),
169    };
170    let snapshots: Vec<_> = project
171        .read(cx)
172        .worktrees(cx)
173        .map(|worktree| worktree.read(cx).snapshot())
174        .collect();
175
176    cx.background_spawn(async move {
177        let mut results = Vec::new();
178        for snapshot in snapshots {
179            for entry in snapshot.entries(false, 0) {
180                if path_matcher.is_match(snapshot.root_name().join(&entry.path).as_std_path()) {
181                    results.push(snapshot.absolutize(&entry.path));
182                }
183            }
184        }
185
186        Ok(results)
187    })
188}
189
190#[cfg(test)]
191mod test {
192    use super::*;
193    use gpui::TestAppContext;
194    use project::{FakeFs, Project};
195    use settings::SettingsStore;
196    use util::path;
197
198    #[gpui::test]
199    async fn test_find_path_tool(cx: &mut TestAppContext) {
200        init_test(cx);
201
202        let fs = FakeFs::new(cx.executor());
203        fs.insert_tree(
204            "/root",
205            serde_json::json!({
206                "apple": {
207                    "banana": {
208                        "carrot": "1",
209                    },
210                    "bandana": {
211                        "carbonara": "2",
212                    },
213                    "endive": "3"
214                }
215            }),
216        )
217        .await;
218        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
219
220        let matches = cx
221            .update(|cx| search_paths("root/**/car*", project.clone(), cx))
222            .await
223            .unwrap();
224        assert_eq!(
225            matches,
226            &[
227                PathBuf::from(path!("/root/apple/banana/carrot")),
228                PathBuf::from(path!("/root/apple/bandana/carbonara"))
229            ]
230        );
231
232        let matches = cx
233            .update(|cx| search_paths("**/car*", project.clone(), cx))
234            .await
235            .unwrap();
236        assert_eq!(
237            matches,
238            &[
239                PathBuf::from(path!("/root/apple/banana/carrot")),
240                PathBuf::from(path!("/root/apple/bandana/carbonara"))
241            ]
242        );
243    }
244
245    fn init_test(cx: &mut TestAppContext) {
246        cx.update(|cx| {
247            let settings_store = SettingsStore::test(cx);
248            cx.set_global(settings_store);
249            language::init(cx);
250            Project::init_settings(cx);
251        });
252    }
253}