use std::sync::Arc; use async_trait::async_trait; use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use schemars::JsonSchema; use serde::Deserialize; use workdir::{GrepOutputMode, GrepRequest, WorkdirPath, WorkdirSessionHandle}; use crate::ToolsError; const DEFAULT_HEAD_LIMIT: usize = 250; #[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum OutputMode { Content, #[default] FilesWithMatches, Count, } #[derive(Debug, Deserialize, JsonSchema)] struct GrepParams { pattern: String, /// Logical Workdir-relative path to search. Defaults to the Workdir root. #[serde(default)] path: Option, #[serde(default)] glob: Option, #[serde(default, rename = "type")] file_type: Option, #[serde(default)] case_insensitive: bool, #[serde(default, rename = "-B")] before: Option, #[serde(default, rename = "-A")] after: Option, #[serde(default, rename = "-C")] context: Option, #[serde(default)] multiline: bool, #[serde(default)] output_mode: Option, #[serde(default)] head_limit: Option, #[serde(default)] offset: Option, } struct GrepTool { session: WorkdirSessionHandle, } #[async_trait] impl Tool for GrepTool { async fn execute( &self, input_json: &str, _ctx: llm_engine::tool::ToolExecutionContext, ) -> Result { let params: GrepParams = serde_json::from_str(input_json) .map_err(|error| ToolError::InvalidArgument(format!("invalid Grep input: {error}")))?; let path = match params.path { Some(path) => WorkdirPath::new(&path).map_err(ToolsError::from)?, None => WorkdirPath::root(), }; let mode = match params.output_mode.unwrap_or_default() { OutputMode::FilesWithMatches => GrepOutputMode::FilesWithMatches, OutputMode::Content => GrepOutputMode::Content, OutputMode::Count => GrepOutputMode::Count, }; let (before_context, after_context) = params .context .map(|context| (context, context)) .unwrap_or((params.before.unwrap_or(0), params.after.unwrap_or(0))); let head_limit = params.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT); let result = self .session .grep(GrepRequest { pattern: params.pattern, path, glob: params.glob, file_type: params.file_type, case_insensitive: params.case_insensitive, before_context, after_context, multiline: params.multiline, output_mode: mode, limit: head_limit, offset: params.offset.unwrap_or(0), }) .await .map_err(ToolsError::from)?; let summary = if result.match_count == 0 { match mode { GrepOutputMode::Content => "No matches".to_owned(), _ => "No files matched".to_owned(), } } else { match mode { GrepOutputMode::FilesWithMatches => { format!("Found matches in {} file(s)", result.matched_files) } GrepOutputMode::Count => format!( "Found matches in {} file(s), {} total line(s)", result.matched_files, result.match_count ), GrepOutputMode::Content => format!( "{} matching line(s) in {} file(s)", result.match_count, result.matched_files ), } }; let summary = if result.truncated { format!("{summary} (truncated at {head_limit})") } else { summary }; Ok(ToolOutput { summary, content: (!result.output.is_empty()).then_some(result.output), attachments: Vec::new(), }) } } pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition { Arc::new(move || { let schema = schemars::schema_for!(GrepParams); let meta = ToolMeta::new("Grep") .description("Search Workdir file contents with a regex. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.") .input_schema(serde_json::to_value(schema).expect("Grep schema serialization")); let tool: Arc = Arc::new(GrepTool { session: session.clone(), }); (meta, tool) }) }