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, truncate_line_default, TruncationOptions, TruncationResult,
37 DEFAULT_MAX_BYTES, GREP_MAX_LINE_LENGTH,
38};
39
40const DEFAULT_LIMIT: u32 = 100;
42
43#[derive(Debug, Clone, JsonSchema, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct GrepInput {
49 pub pattern: String,
51 #[serde(default)]
53 pub path: Option<String>,
54 #[serde(default)]
56 pub glob: Option<String>,
57 #[serde(default)]
59 pub ignore_case: Option<bool>,
60 #[serde(default)]
62 pub literal: Option<bool>,
63 #[serde(default)]
65 pub context: Option<u32>,
66 #[serde(default)]
68 pub limit: Option<u32>,
69}
70
71#[derive(Debug, Clone, serde::Serialize)]
73pub struct GrepToolDetails {
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub truncation: Option<TruncationResult>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub match_limit_reached: Option<u32>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub lines_truncated: Option<bool>,
80}
81
82#[derive(Debug, Clone, Default)]
86pub struct GrepToolOptions {}
87
88pub struct GrepTool {
90 schema: Tool,
91 env: Arc<dyn ExecutionEnv>,
92}
93
94impl GrepTool {
95 fn schema() -> Tool {
96 let params = schemars::schema_for!(GrepInput);
97 Tool {
98 name: "grep".to_string(),
99 description: format!(
100 "Search file contents for a pattern. Returns matching lines with file paths and \
101 line numbers. Skips `.git/` directories. Output is truncated to {DEFAULT_LIMIT} \
102 matches or {kb}KB (whichever is hit first). Long lines are truncated to \
103 {GREP_MAX_LINE_LENGTH} chars.",
104 kb = DEFAULT_MAX_BYTES / 1024
105 ),
106 parameters: rpi_ai::types::Schema::new(
107 serde_json::to_value(params).unwrap_or_default(),
108 ),
109 constrained_sampling: None,
110 }
111 }
112}
113
114pub fn create_grep_tool(
116 context: &ExecutionToolContext,
117 options: Option<GrepToolOptions>,
118) -> Arc<dyn AgentTool> {
119 let _ = options; Arc::new(GrepTool {
121 schema: GrepTool::schema(),
122 env: context.env().clone(),
123 })
124}
125
126#[async_trait]
127impl AgentTool for GrepTool {
128 fn schema(&self) -> &Tool {
129 &self.schema
130 }
131 fn label(&self) -> &str {
132 "grep"
133 }
134
135 async fn execute(
136 &self,
137 _tool_call_id: &str,
138 params: serde_json::Value,
139 signal: CancellationToken,
140 _on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
141 ) -> Result<AgentToolResult, AgentError> {
142 let input: GrepInput = serde_json::from_value(params)
143 .map_err(|e| AgentError::Validation(format!("grep input invalid: {e}")))?;
144
145 let cancel = Some(&signal);
146 let effective_limit = input.limit.unwrap_or(DEFAULT_LIMIT).max(1) as usize;
147 let context_val = input.context.unwrap_or(0).min(u32::MAX) as usize;
148
149 let pattern = if input.literal.unwrap_or(false) {
151 regex::escape(&input.pattern)
152 } else {
153 input.pattern.clone()
154 };
155 let re = regex::RegexBuilder::new(&pattern)
156 .case_insensitive(input.ignore_case.unwrap_or(false))
157 .build()
158 .map_err(|e| AgentError::Validation(format!("grep pattern invalid: {e}")))?;
159
160 let glob: Option<(globset::GlobMatcher, bool)> = match input.glob.as_deref() {
164 Some(g) if !g.is_empty() => {
165 let compiled = globset::Glob::new(g)
166 .map_err(|e| AgentError::Validation(format!("grep glob invalid: {e}")))?
167 .compile_matcher();
168 Some((compiled, g.contains('/')))
169 }
170 _ => None,
171 };
172
173 let search_path =
175 resolve_read_tool_path(&*self.env, input.path.as_deref().unwrap_or("."), cancel)
176 .await
177 .map_err(file_err_to_agent)?;
178
179 let info = self
181 .env
182 .file_info(&search_path, cancel)
183 .await
184 .map_err(file_err_to_agent)?;
185 let root_kind = if info.kind == FileKind::Symlink {
186 match self.env.canonical_path(&search_path, cancel).await {
187 Ok(canon) => match self.env.file_info(&canon.to_string_lossy(), cancel).await {
188 Ok(i) => i.kind,
189 Err(_) => info.kind, },
191 Err(_) => info.kind,
192 }
193 } else {
194 info.kind
195 };
196
197 let mut matches: usize = 0;
199 let mut match_limit_reached = false;
200 let mut lines_truncated = false;
201 let mut output_lines: Vec<String> = Vec::new();
202
203 if root_kind == FileKind::Directory {
204 let mut queue: VecDeque<(String, String)> = VecDeque::new();
207 queue.push_back((search_path.clone(), String::new()));
208 while let Some((abs_dir, rel_dir)) = queue.pop_front() {
209 if matches >= effective_limit {
210 break;
211 }
212 let mut entries = match self.env.list_dir(&abs_dir, cancel).await {
213 Ok(e) => e,
214 Err(_) => continue, };
216 entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
217 for entry in entries {
218 if matches >= effective_limit {
219 match_limit_reached = true;
220 break;
221 }
222 let child_abs = entry.path.to_string_lossy().into_owned();
223 match entry.kind {
224 FileKind::Directory => {
225 if entry.name == ".git" {
227 continue;
228 }
229 let rel = if rel_dir.is_empty() {
230 entry.name.clone()
231 } else {
232 format!("{rel_dir}/{}", entry.name)
233 };
234 queue.push_back((child_abs, rel));
235 }
236 FileKind::File => {
237 let rel = if rel_dir.is_empty() {
238 entry.name.clone()
239 } else {
240 format!("{rel_dir}/{}", entry.name)
241 };
242 search_file(
243 &*self.env,
244 cancel,
245 &re,
246 glob.as_ref(),
247 context_val,
248 effective_limit,
249 &child_abs,
250 &rel,
251 &mut matches,
252 &mut match_limit_reached,
253 &mut lines_truncated,
254 &mut output_lines,
255 )
256 .await?;
257 }
258 FileKind::Symlink => {
259 }
262 }
263 }
264 }
265 } else if root_kind == FileKind::File {
266 let rel = basename(&search_path);
267 search_file(
268 &*self.env,
269 cancel,
270 &re,
271 glob.as_ref(),
272 context_val,
273 effective_limit,
274 &search_path,
275 &rel,
276 &mut matches,
277 &mut match_limit_reached,
278 &mut lines_truncated,
279 &mut output_lines,
280 )
281 .await?;
282 } else {
283 return Err(AgentError::Tool(format!(
285 "Path is neither a file nor a directory: {search_path}"
286 )));
287 }
288
289 if output_lines.is_empty() {
290 return Ok(AgentToolResult {
291 content: vec![TextContentOrImage::text("No matches found")],
292 details: serde_json::Value::Null,
293 ..Default::default()
294 });
295 }
296
297 let raw_output = output_lines.join("\n");
298 let truncation = truncate_head(
300 &raw_output,
301 TruncationOptions {
302 max_lines: Some(usize::MAX),
303 max_bytes: None,
304 },
305 );
306 let was_truncated = truncation.truncated;
307 let mut output = truncation.content.clone();
308 let mut details = GrepToolDetails {
309 truncation: None,
310 match_limit_reached: None,
311 lines_truncated: None,
312 };
313 let mut notices: Vec<String> = Vec::new();
314 if match_limit_reached {
315 notices.push(format!(
316 "{effective_limit} matches limit reached. Use limit={} for more, or refine pattern",
317 effective_limit * 2
318 ));
319 details.match_limit_reached = Some(effective_limit as u32);
320 }
321 if was_truncated {
322 notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
323 details.truncation = Some(truncation);
324 }
325 if lines_truncated {
326 notices.push(format!(
327 "Some lines truncated to {GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines"
328 ));
329 details.lines_truncated = Some(true);
330 }
331 if !notices.is_empty() {
332 output.push_str(&format!("\n\n[{}]", notices.join(". ")));
333 }
334
335 let details_value = serde_json::to_value(&details).unwrap_or(serde_json::Value::Null);
336 Ok(AgentToolResult {
337 content: vec![TextContentOrImage::text(output)],
338 details: details_value,
339 ..Default::default()
340 })
341 }
342}
343
344#[allow(clippy::too_many_arguments)]
349async fn search_file(
350 env: &dyn ExecutionEnv,
351 cancel: Option<&CancellationToken>,
352 re: ®ex::Regex,
353 glob: Option<&(globset::GlobMatcher, bool)>,
354 context_val: usize,
355 effective_limit: usize,
356 abs: &str,
357 rel: &str,
358 matches: &mut usize,
359 match_limit_reached: &mut bool,
360 lines_truncated: &mut bool,
361 output_lines: &mut Vec<String>,
362) -> Result<(), AgentError> {
363 if let Some((matcher, has_sep)) = glob {
365 let basename = basename(abs);
366 let passes = if *has_sep {
367 matcher.is_match(rel)
368 } else {
369 matcher.is_match(&basename)
370 };
371 if !passes {
372 return Ok(());
373 }
374 }
375
376 let text = match env.read_text_file(abs, cancel).await {
378 Ok(t) => t,
379 Err(_) => return Ok(()),
380 };
381 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
383 let lines: Vec<&str> = normalized.split('\n').collect();
384
385 for (idx, line) in lines.iter().enumerate() {
386 if *matches >= effective_limit {
387 *match_limit_reached = true;
388 return Ok(());
389 }
390 if !re.is_match(line) {
391 continue;
392 }
393 let line_number = idx + 1; *matches += 1;
395
396 if context_val == 0 {
397 let out = truncate_line_default(line);
399 if out.was_truncated {
400 *lines_truncated = true;
401 }
402 output_lines.push(format!("{rel}:{line_number}: {}", out.text));
403 } else {
404 let start = line_number.saturating_sub(context_val).max(1);
406 let end = (line_number + context_val).min(lines.len());
407 for current in start..=end {
408 let text_line = lines.get(current - 1).copied().unwrap_or("");
409 let out = truncate_line_default(text_line);
410 if out.was_truncated {
411 *lines_truncated = true;
412 }
413 if current == line_number {
414 output_lines.push(format!("{rel}:{current}: {}", out.text));
415 } else {
416 output_lines.push(format!("{rel}-{current}- {}", out.text));
417 }
418 }
419 }
420 }
421 Ok(())
422}
423
424fn basename(p: &str) -> String {
426 Path::new(p)
427 .file_name()
428 .map(|n| n.to_string_lossy().into_owned())
429 .unwrap_or_else(|| p.to_string())
430}
431
432fn file_err_to_agent(e: crate::error::FileError) -> AgentError {
434 AgentError::Tool(e.to_string())
435}