Skip to main content

zeph_tools/
tool_filter.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
5use crate::registry::ToolDef;
6
7/// Wraps a `ToolExecutor` and suppresses specified tool ids from both
8/// `tool_definitions` and `execute_tool_call`.
9///
10/// Used to hide `FileExecutor` tools (e.g. `read`, `glob`) when
11/// `AcpFileExecutor` provides equivalent IDE-proxied alternatives.
12#[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    /// Inner executor whose cross-cutting methods return distinguishable non-default
196    /// values, used to prove `ToolFilter` forwards rather than falling through to the
197    /// base `ToolExecutor` defaults.
198    #[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    /// Regression test for #6012: cross-cutting methods must be forwarded to `self.inner`.
289    /// Before the fix every one of these fell through to the base `ToolExecutor` default
290    /// (`false` / `unsupported()`) regardless of the inner executor's actual policy.
291    #[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    /// Suppression must also apply to the confirmed-call dispatch path, not just the
313    /// initial (unconfirmed) `execute_tool_call`.
314    #[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        // Distinguishes forwarding to execute_tool_call_confirmed from an (incorrect)
333        // fallback to execute_tool_call — the two return different summaries.
334        assert_eq!(result.summary, "stub-confirmed");
335    }
336}