1use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
5use crate::registry::ToolDef;
6
7#[derive(Debug)]
13pub struct ToolFilter<E: ToolExecutor> {
14 inner: E,
15 suppressed: &'static [&'static str],
16}
17
18impl<E: ToolExecutor> ToolFilter<E> {
19 #[must_use]
20 pub fn new(inner: E, suppressed: &'static [&'static str]) -> Self {
21 Self { inner, suppressed }
22 }
23}
24
25impl<E: ToolExecutor> ToolExecutor for ToolFilter<E> {
26 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
27 self.inner.execute(response).await
28 }
29
30 async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
31 self.inner.execute_confirmed(response).await
32 }
33
34 fn tool_definitions(&self) -> Vec<ToolDef> {
35 self.inner
36 .tool_definitions()
37 .into_iter()
38 .filter(|d| !self.suppressed.contains(&d.id.as_ref()))
39 .collect()
40 }
41
42 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
43 if self.suppressed.contains(&call.tool_id.as_str()) {
44 return Ok(None);
45 }
46 self.inner.execute_tool_call(call).await
47 }
48
49 async fn execute_tool_call_confirmed(
50 &self,
51 call: &ToolCall,
52 ) -> Result<Option<ToolOutput>, ToolError> {
53 if self.suppressed.contains(&call.tool_id.as_str()) {
54 return Ok(None);
55 }
56 self.inner.execute_tool_call_confirmed(call).await
57 }
58
59 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
60 self.inner.set_skill_env(env);
61 }
62
63 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
64 self.inner.set_effective_trust(level);
65 }
66
67 fn is_tool_retryable(&self, tool_id: &str) -> bool {
68 self.inner.is_tool_retryable(tool_id)
69 }
70
71 fn is_tool_speculatable(&self, tool_id: &str) -> bool {
72 self.inner.is_tool_speculatable(tool_id)
73 }
74
75 fn requires_confirmation(&self, call: &ToolCall) -> bool {
76 self.inner.requires_confirmation(call)
77 }
78
79 fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
80 self.inner.checkpoint_undo(n)
81 }
82
83 fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
84 self.inner.checkpoint_redo()
85 }
86
87 fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
88 self.inner.checkpoint_list()
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use crate::ToolName;
96
97 #[derive(Debug)]
98 struct StubExecutor;
99 impl ToolExecutor for StubExecutor {
100 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
101 Ok(None)
102 }
103 fn tool_definitions(&self) -> Vec<ToolDef> {
104 vec![
105 ToolDef {
106 id: "read".into(),
107 description: "read a file".into(),
108 schema: schemars::schema_for!(String),
109 invocation: crate::registry::InvocationHint::ToolCall,
110 output_schema: None,
111 server_id: None,
112 },
113 ToolDef {
114 id: "glob".into(),
115 description: "find files".into(),
116 schema: schemars::schema_for!(String),
117 invocation: crate::registry::InvocationHint::ToolCall,
118 output_schema: None,
119 server_id: None,
120 },
121 ToolDef {
122 id: "edit".into(),
123 description: "edit a file".into(),
124 schema: schemars::schema_for!(String),
125 invocation: crate::registry::InvocationHint::ToolCall,
126 output_schema: None,
127 server_id: None,
128 },
129 ]
130 }
131 async fn execute_tool_call(
132 &self,
133 call: &ToolCall,
134 ) -> Result<Option<ToolOutput>, ToolError> {
135 Ok(Some(ToolOutput {
136 tool_name: call.tool_id.clone(),
137 summary: "stub".to_owned(),
138 blocks_executed: 1,
139 filter_stats: None,
140 diff: None,
141 streamed: false,
142 terminal_id: None,
143 locations: None,
144 raw_response: None,
145 claim_source: None,
146 ..Default::default()
147 }))
148 }
149
150 crate::tool_executor_no_inner_defaults!();
151 }
152
153 #[test]
154 fn suppressed_tools_hidden_from_definitions() {
155 let filter = ToolFilter::new(StubExecutor, &["read", "glob"]);
156 let defs = filter.tool_definitions();
157 let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
158 assert!(!ids.contains(&"read"));
159 assert!(!ids.contains(&"glob"));
160 assert!(ids.contains(&"edit"));
161 }
162
163 #[tokio::test]
164 async fn suppressed_tool_call_returns_none() {
165 let filter = ToolFilter::new(StubExecutor, &["read", "glob"]);
166 let call = ToolCall {
167 tool_id: ToolName::new("read"),
168 params: serde_json::Map::new(),
169 caller_id: None,
170 context: None,
171
172 tool_call_id: String::new(),
173 skill_name: None,
174 };
175 let result = filter.execute_tool_call(&call).await.unwrap();
176 assert!(result.is_none());
177 }
178
179 #[tokio::test]
180 async fn allowed_tool_call_passes_through() {
181 let filter = ToolFilter::new(StubExecutor, &["read", "glob"]);
182 let call = ToolCall {
183 tool_id: ToolName::new("edit"),
184 params: serde_json::Map::new(),
185 caller_id: None,
186 context: None,
187
188 tool_call_id: String::new(),
189 skill_name: None,
190 };
191 let result = filter.execute_tool_call(&call).await.unwrap();
192 assert!(result.is_some());
193 }
194
195 #[derive(Debug)]
199 struct CrossCuttingStubExecutor;
200
201 impl ToolExecutor for CrossCuttingStubExecutor {
202 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
203 Ok(None)
204 }
205 async fn execute_tool_call(
206 &self,
207 call: &ToolCall,
208 ) -> Result<Option<ToolOutput>, ToolError> {
209 Ok(Some(ToolOutput {
210 tool_name: call.tool_id.clone(),
211 summary: "stub".to_owned(),
212 blocks_executed: 1,
213 filter_stats: None,
214 diff: None,
215 streamed: false,
216 terminal_id: None,
217 locations: None,
218 raw_response: None,
219 claim_source: None,
220 ..Default::default()
221 }))
222 }
223 async fn execute_tool_call_confirmed(
224 &self,
225 call: &ToolCall,
226 ) -> Result<Option<ToolOutput>, ToolError> {
227 Ok(Some(ToolOutput {
228 tool_name: call.tool_id.clone(),
229 summary: "stub-confirmed".to_owned(),
230 blocks_executed: 1,
231 filter_stats: None,
232 diff: None,
233 streamed: false,
234 terminal_id: None,
235 locations: None,
236 raw_response: None,
237 claim_source: None,
238 ..Default::default()
239 }))
240 }
241 fn is_tool_retryable(&self, _tool_id: &str) -> bool {
242 true
243 }
244 fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
245 true
246 }
247 fn requires_confirmation(&self, _call: &ToolCall) -> bool {
248 true
249 }
250 fn checkpoint_undo(&self, _n: usize) -> crate::executor::CheckpointActionResult {
251 crate::executor::CheckpointActionResult {
252 reverted_commands: 1,
253 restored: 0,
254 deleted: 0,
255 supported: true,
256 message: "stub-undo".to_owned(),
257 }
258 }
259 fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
260 crate::executor::CheckpointActionResult {
261 reverted_commands: 0,
262 restored: 1,
263 deleted: 0,
264 supported: true,
265 message: "stub-redo".to_owned(),
266 }
267 }
268 fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
269 crate::executor::CheckpointListResult {
270 entries: vec![],
271 redo_depth: 3,
272 supported: true,
273 }
274 }
275 }
276
277 fn make_call(tool_id: &str) -> ToolCall {
278 ToolCall {
279 tool_id: ToolName::new(tool_id),
280 params: serde_json::Map::new(),
281 caller_id: None,
282 context: None,
283 tool_call_id: String::new(),
284 skill_name: None,
285 }
286 }
287
288 #[test]
292 fn cross_cutting_methods_delegated_to_inner() {
293 let filter = ToolFilter::new(CrossCuttingStubExecutor, &["read", "glob"]);
294
295 assert!(filter.is_tool_retryable("edit"));
296 assert!(filter.is_tool_speculatable("edit"));
297 assert!(filter.requires_confirmation(&make_call("edit")));
298
299 let undo = filter.checkpoint_undo(1);
300 assert!(undo.supported);
301 assert_eq!(undo.message, "stub-undo");
302
303 let redo = filter.checkpoint_redo();
304 assert!(redo.supported);
305 assert_eq!(redo.message, "stub-redo");
306
307 let list = filter.checkpoint_list();
308 assert!(list.supported);
309 assert_eq!(list.redo_depth, 3);
310 }
311
312 #[tokio::test]
315 async fn suppressed_tool_call_confirmed_returns_none() {
316 let filter = ToolFilter::new(CrossCuttingStubExecutor, &["read", "glob"]);
317 let result = filter
318 .execute_tool_call_confirmed(&make_call("read"))
319 .await
320 .unwrap();
321 assert!(result.is_none());
322 }
323
324 #[tokio::test]
325 async fn allowed_tool_call_confirmed_passes_through() {
326 let filter = ToolFilter::new(CrossCuttingStubExecutor, &["read", "glob"]);
327 let result = filter
328 .execute_tool_call_confirmed(&make_call("edit"))
329 .await
330 .unwrap()
331 .unwrap();
332 assert_eq!(result.summary, "stub-confirmed");
335 }
336}