1use std::collections::VecDeque;
19use std::path::Path;
20use std::sync::Arc;
21
22use async_trait::async_trait;
23use rpi_agent::agent_tool::AgentTool;
24use rpi_agent::error::AgentError;
25use rpi_agent::types::{AgentToolResult, TextContentOrImage, ToolResultPartial};
26use rpi_ai::types::Tool;
27use schemars::JsonSchema;
28use serde::Deserialize;
29use tokio_util::sync::CancellationToken;
30
31use crate::env::ExecutionEnv;
32use crate::env::FileKind;
33use crate::path_utils::resolve_read_tool_path;
34use crate::tools::tool_context::ExecutionToolContext;
35use crate::truncate::{
36 format_size, truncate_head, TruncationOptions, TruncationResult, DEFAULT_MAX_BYTES,
37};
38
39const DEFAULT_LIMIT: u32 = 1000;
41
42#[derive(Debug, Clone, JsonSchema, Deserialize)]
44pub struct FindInput {
45 pub pattern: String,
47 #[serde(default)]
49 pub path: Option<String>,
50 #[serde(default)]
52 pub limit: Option<u32>,
53}
54
55#[derive(Debug, Clone, serde::Serialize)]
57pub struct FindToolDetails {
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub truncation: Option<TruncationResult>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub result_limit_reached: Option<u32>,
62}
63
64#[derive(Debug, Clone, Default)]
68pub struct FindToolOptions {}
69
70pub struct FindTool {
72 schema: Tool,
73 env: Arc<dyn ExecutionEnv>,
74}
75
76impl FindTool {
77 fn schema() -> Tool {
78 let params = schemars::schema_for!(FindInput);
79 Tool {
80 name: "find".to_string(),
81 description: format!(
82 "Search for files by glob pattern. Returns matching file paths relative to the \
83 search directory. Skips `.git/` directories. Output is truncated to {DEFAULT_LIMIT} \
84 results or {kb}KB (whichever is hit first).",
85 kb = DEFAULT_MAX_BYTES / 1024
86 ),
87 parameters: rpi_ai::types::Schema::new(serde_json::to_value(params).unwrap_or_default()),
88 constrained_sampling: None,
89 }
90 }
91}
92
93pub fn create_find_tool(
95 context: &ExecutionToolContext,
96 options: Option<FindToolOptions>,
97) -> Arc<dyn AgentTool> {
98 let _ = options; Arc::new(FindTool {
100 schema: FindTool::schema(),
101 env: context.env().clone(),
102 })
103}
104
105#[async_trait]
106impl AgentTool for FindTool {
107 fn schema(&self) -> &Tool {
108 &self.schema
109 }
110 fn label(&self) -> &str {
111 "find"
112 }
113
114 async fn execute(
115 &self,
116 _tool_call_id: &str,
117 params: serde_json::Value,
118 signal: CancellationToken,
119 _on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
120 ) -> Result<AgentToolResult, AgentError> {
121 let input: FindInput = serde_json::from_value(params)
122 .map_err(|e| AgentError::Validation(format!("find input invalid: {e}")))?;
123
124 let cancel = Some(&signal);
125 let effective_limit = input.limit.unwrap_or(DEFAULT_LIMIT).max(1) as usize;
126
127 let search_path =
129 resolve_read_tool_path(&*self.env, input.path.as_deref().unwrap_or("."), cancel)
130 .await
131 .map_err(file_err_to_agent)?;
132
133 if !self
136 .env
137 .exists(&search_path, cancel)
138 .await
139 .map_err(file_err_to_agent)?
140 {
141 return Err(AgentError::Tool(format!("Path not found: {search_path}")));
142 }
143
144 let effective_pattern = rewrite_pattern(&input.pattern);
147 let matcher = globset::Glob::new(&effective_pattern)
148 .map_err(|e| AgentError::Validation(format!("find pattern invalid: {e}")))?
149 .compile_matcher();
150
151 let mut collected: Vec<String> = Vec::new();
153 let mut result_limit_reached = false;
154
155 let mut queue: VecDeque<(String, String)> = VecDeque::new();
157 queue.push_back((search_path.clone(), String::new()));
158 while let Some((abs_dir, rel_dir)) = queue.pop_front() {
159 if collected.len() >= effective_limit {
160 result_limit_reached = true;
161 break;
162 }
163 let mut entries = match self.env.list_dir(&abs_dir, cancel).await {
164 Ok(e) => e,
165 Err(_) => continue, };
167 entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
168 for entry in entries {
169 if collected.len() >= effective_limit {
170 result_limit_reached = true;
171 break;
172 }
173 let child_abs = entry.path.to_string_lossy().into_owned();
174 let rel = if rel_dir.is_empty() {
175 entry.name.clone()
176 } else {
177 format!("{rel_dir}/{}", entry.name)
178 };
179 match entry.kind {
180 FileKind::File => {
181 if matcher.is_match(&rel) {
182 collected.push(relativize(&rel));
183 }
184 }
185 FileKind::Directory => {
186 if entry.name == ".git" {
188 continue;
189 }
190 if matcher.is_match(&rel) {
193 let mut r = relativize(&rel);
194 if !r.ends_with('/') {
195 r.push('/');
196 }
197 collected.push(r);
198 }
199 queue.push_back((child_abs, rel));
200 }
201 FileKind::Symlink => {
202 }
204 }
205 }
206 }
207
208 if collected.is_empty() {
209 return Ok(AgentToolResult {
210 content: vec![TextContentOrImage::text("No files found matching pattern")],
211 details: serde_json::Value::Null,
212 ..Default::default()
213 });
214 }
215
216 let raw_output = collected.join("\n");
217 let truncation = truncate_head(
219 &raw_output,
220 TruncationOptions {
221 max_lines: Some(usize::MAX),
222 max_bytes: None,
223 },
224 );
225 let was_truncated = truncation.truncated;
226 let mut output = truncation.content.clone();
227 let mut details = FindToolDetails {
228 truncation: None,
229 result_limit_reached: None,
230 };
231 let mut notices: Vec<String> = Vec::new();
232 if result_limit_reached {
233 notices.push(format!(
234 "{effective_limit} results limit reached. Use limit={} for more, or refine pattern",
235 effective_limit * 2
236 ));
237 details.result_limit_reached = Some(effective_limit as u32);
238 }
239 if was_truncated {
240 notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
241 details.truncation = Some(truncation);
242 }
243 if !notices.is_empty() {
244 output.push_str(&format!("\n\n[{}]", notices.join(". ")));
245 }
246
247 let details_value = serde_json::to_value(&details).unwrap_or(serde_json::Value::Null);
248 Ok(AgentToolResult {
249 content: vec![TextContentOrImage::text(output)],
250 details: details_value,
251 ..Default::default()
252 })
253 }
254}
255
256fn rewrite_pattern(pattern: &str) -> String {
264 if !pattern.contains('/') {
265 return pattern.to_string();
266 }
267 if pattern == "**" || pattern.starts_with('/') || pattern.starts_with("**/") {
268 return strip_native_sep(pattern);
269 }
270 strip_native_sep(&format!("**/{pattern}"))
271}
272
273fn strip_native_sep(p: &str) -> String {
276 p.replace('\\', "/")
277}
278
279fn relativize(rel: &str) -> String {
283 rel.to_string()
284}
285
286#[allow(dead_code)]
289fn basename(p: &str) -> String {
290 Path::new(p)
291 .file_name()
292 .map(|n| n.to_string_lossy().into_owned())
293 .unwrap_or_else(|| p.to_string())
294}
295
296fn file_err_to_agent(e: crate::error::FileError) -> AgentError {
298 AgentError::Tool(e.to_string())
299}