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
 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(&self) -> SharedString {
 90        "find_path".into()
 91    }
 92
 93    fn kind(&self) -> acp::ToolKind {
 94        acp::ToolKind::Search
 95    }
 96
 97    fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
 98        let mut title = "Find paths".to_string();
 99        if let Ok(input) = input {
100            title.push_str(&format!(" matching “`{}`”", input.glob));
101        }
102        title.into()
103    }
104
105    fn run(
106        self: Arc<Self>,
107        input: Self::Input,
108        event_stream: ToolCallEventStream,
109        cx: &mut App,
110    ) -> Task<Result<FindPathToolOutput>> {
111        let search_paths_task = search_paths(&input.glob, self.project.clone(), cx);
112
113        cx.background_spawn(async move {
114            let matches = search_paths_task.await?;
115            let paginated_matches: &[PathBuf] = &matches[cmp::min(input.offset, matches.len())
116                ..cmp::min(input.offset + RESULTS_PER_PAGE, matches.len())];
117
118            event_stream.update_fields(acp::ToolCallUpdateFields {
119                title: Some(if paginated_matches.len() == 0 {
120                    "No matches".into()
121                } else if paginated_matches.len() == 1 {
122                    "1 match".into()
123                } else {
124                    format!("{} matches", paginated_matches.len())
125                }),
126                content: Some(
127                    paginated_matches
128                        .iter()
129                        .map(|path| acp::ToolCallContent::Content {
130                            content: acp::ContentBlock::ResourceLink(acp::ResourceLink {
131                                uri: format!("file://{}", path.display()),
132                                name: path.to_string_lossy().into(),
133                                annotations: None,
134                                description: None,
135                                mime_type: None,
136                                size: None,
137                                title: None,
138                            }),
139                        })
140                        .collect(),
141                ),
142                raw_output: Some(serde_json::json!({
143                    "paths": &matches,
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        Ok(snapshots
173            .iter()
174            .flat_map(|snapshot| {
175                let root_name = PathBuf::from(snapshot.root_name());
176                snapshot
177                    .entries(false, 0)
178                    .map(move |entry| root_name.join(&entry.path))
179                    .filter(|path| path_matcher.is_match(&path))
180            })
181            .collect())
182    })
183}
184
185#[cfg(test)]
186mod test {
187    use super::*;
188    use gpui::TestAppContext;
189    use project::{FakeFs, Project};
190    use settings::SettingsStore;
191    use util::path;
192
193    #[gpui::test]
194    async fn test_find_path_tool(cx: &mut TestAppContext) {
195        init_test(cx);
196
197        let fs = FakeFs::new(cx.executor());
198        fs.insert_tree(
199            "/root",
200            serde_json::json!({
201                "apple": {
202                    "banana": {
203                        "carrot": "1",
204                    },
205                    "bandana": {
206                        "carbonara": "2",
207                    },
208                    "endive": "3"
209                }
210            }),
211        )
212        .await;
213        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
214
215        let matches = cx
216            .update(|cx| search_paths("root/**/car*", project.clone(), cx))
217            .await
218            .unwrap();
219        assert_eq!(
220            matches,
221            &[
222                PathBuf::from("root/apple/banana/carrot"),
223                PathBuf::from("root/apple/bandana/carbonara")
224            ]
225        );
226
227        let matches = cx
228            .update(|cx| search_paths("**/car*", project.clone(), cx))
229            .await
230            .unwrap();
231        assert_eq!(
232            matches,
233            &[
234                PathBuf::from("root/apple/banana/carrot"),
235                PathBuf::from("root/apple/bandana/carbonara")
236            ]
237        );
238    }
239
240    fn init_test(cx: &mut TestAppContext) {
241        cx.update(|cx| {
242            let settings_store = SettingsStore::test(cx);
243            cx.set_global(settings_store);
244            language::init(cx);
245            Project::init_settings(cx);
246        });
247    }
248}