agent_sdk_toolkit/workspace/
grep.rs1use std::{fs, sync::Arc};
6
7use agent_sdk_core::{
8 AgentError, AgentErrorKind, ExecutorRef, PolicyRef, RetryClassification, ToolExecutionOutput,
9 ToolExecutionRequest, ToolExecutor, ToolPackId, ToolPackKind, ToolPackSnapshot,
10 policy::{CapabilityPermission, EffectClass, RiskClass},
11};
12use regex::Regex;
13use serde::{Deserialize, Serialize};
14
15use super::{
16 bounds::BoundedWorkspace,
17 policy::WorkspacePolicy,
18 read_pipeline::detect_workspace_file,
19 util::{content_ref_for, first_arg_ref, hash_line, tool_failure, truncate_bytes},
20};
21use crate::{
22 packs::{ToolkitPackBundle, tool_snapshot},
23 testing::{InMemoryJsonArgumentStore, InMemoryToolkitContentStore},
24};
25
26#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
27pub struct WorkspaceSearchRequest {
30 pub pattern: String,
33}
34
35#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
36pub struct WorkspaceSearchOutput {
39 pub pattern: String,
42 pub matches: Vec<SearchMatch>,
46 pub truncated: bool,
49 pub max_matches: usize,
52}
53
54#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
55pub struct SearchMatch {
58 pub path: String,
60 pub line: usize,
62 pub line_hash: String,
65 pub preview: String,
68}
69
70impl BoundedWorkspace {
71 pub fn search(
75 &self,
76 request: &WorkspaceSearchRequest,
77 ) -> Result<WorkspaceSearchOutput, AgentError> {
78 if request.pattern.is_empty() {
79 return Err(AgentError::new(
80 AgentErrorKind::ToolFailure,
81 RetryClassification::UserActionNeeded,
82 "regex pattern must not be empty",
83 ));
84 }
85 let regex = Regex::new(&request.pattern).map_err(|error| {
86 AgentError::new(
87 AgentErrorKind::ToolFailure,
88 RetryClassification::UserActionNeeded,
89 format!("regex compile error: {error}"),
90 )
91 })?;
92 let mut matches = Vec::new();
93 self.visit_files(&self.policy.root, &mut |path| {
94 if matches.len() >= self.policy.max_matches {
95 return Ok(());
96 }
97 let rel = self.relative_path(path)?;
98 let byte_len = fs::metadata(path).map_err(tool_failure)?.len();
99 if byte_len > self.policy.max_file_bytes {
100 return Ok(());
101 }
102 let bytes = fs::read(path).map_err(tool_failure)?;
103 if detect_workspace_file(path, &bytes).binary {
104 return Ok(());
105 }
106 let text = String::from_utf8(bytes).map_err(|error| {
107 AgentError::new(
108 AgentErrorKind::ToolFailure,
109 RetryClassification::UserActionNeeded,
110 format!("workspace search expected UTF-8 text after detection: {error}"),
111 )
112 })?;
113 for (index, line) in text.lines().enumerate() {
114 if regex.is_match(line) {
115 matches.push(SearchMatch {
116 path: rel.clone(),
117 line: index + 1,
118 line_hash: hash_line(line),
119 preview: truncate_bytes(line, self.policy.max_output_bytes as usize),
120 });
121 if matches.len() >= self.policy.max_matches {
122 break;
123 }
124 }
125 }
126 Ok(())
127 })?;
128 let truncated = matches.len() >= self.policy.max_matches;
129 Ok(WorkspaceSearchOutput {
130 pattern: request.pattern.clone(),
131 matches,
132 truncated,
133 max_matches: self.policy.max_matches,
134 })
135 }
136}
137
138#[derive(Clone)]
139pub struct WorkspaceSearchExecutor {
142 executor_ref: ExecutorRef,
143 workspace: Arc<BoundedWorkspace>,
144 arguments: InMemoryJsonArgumentStore,
145 content: InMemoryToolkitContentStore,
146}
147
148impl WorkspaceSearchExecutor {
149 pub fn new(
153 workspace: Arc<BoundedWorkspace>,
154 arguments: InMemoryJsonArgumentStore,
155 content: InMemoryToolkitContentStore,
156 ) -> Self {
157 Self {
158 executor_ref: ExecutorRef::new("executor.toolkit.workspace_search.v1"),
159 workspace,
160 arguments,
161 content,
162 }
163 }
164
165 pub fn pack_bundle(
169 source: agent_sdk_core::SourceRef,
170 policy_ref: PolicyRef,
171 workspace: &WorkspacePolicy,
172 ) -> Result<ToolkitPackBundle, AgentError> {
173 let snapshot = ToolPackSnapshot::new(
174 ToolPackId::new("toolpack.workspace_search.v1"),
175 ToolPackKind::WorkspaceSearch,
176 "v1",
177 source.clone(),
178 )
179 .with_workspace_bounds(workspace.bounds_snapshot(policy_ref.clone()))
180 .with_tool(tool_snapshot(
181 "cap.toolkit.workspace_search",
182 "workspace_search",
183 "executor.toolkit.workspace_search.v1",
184 "schema.toolkit.workspace_search.v1",
185 vec![policy_ref],
186 vec![CapabilityPermission::FilesystemRead],
187 EffectClass::Read,
188 RiskClass::Low,
189 &source,
190 ));
191 ToolkitPackBundle::from_snapshot(snapshot)
192 }
193}
194
195impl ToolExecutor for WorkspaceSearchExecutor {
196 fn executor_ref(&self) -> &ExecutorRef {
197 &self.executor_ref
198 }
199
200 fn execute(&self, request: &ToolExecutionRequest) -> Result<ToolExecutionOutput, AgentError> {
201 let args_ref = first_arg_ref(request)?;
202 let search_request: WorkspaceSearchRequest = self.arguments.get(args_ref)?;
203 let output = self.workspace.search(&search_request)?;
204 let content_ref = content_ref_for(request, "workspace_search");
205 self.content.put(content_ref.clone(), &output)?;
206 let mut envelope = ToolExecutionOutput::completed("workspace search returned content ref");
207 envelope.content_refs.push(content_ref);
208 Ok(envelope)
209 }
210}