Skip to main content

zeph_tools/
composite.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Composite executor that chains two [`ToolExecutor`] implementations.
5
6use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
7use crate::registry::ToolDef;
8
9/// Chains two [`ToolExecutor`] implementations with first-match-wins dispatch.
10///
11/// For each method, `first` is tried first. If it returns `Ok(None)` (i.e. it does not
12/// handle the input), `second` is tried. If `first` returns an `Err`, the error propagates
13/// immediately without consulting `second`.
14///
15/// Use this to compose a chain of specialized executors at startup instead of a dynamic
16/// `Vec<Box<dyn ...>>`. Nest multiple `CompositeExecutor`s to handle more than two backends.
17///
18/// Tool definitions from both executors are merged, with `first` taking precedence when
19/// both define a tool with the same ID.
20///
21/// # Example
22///
23/// ```rust
24/// use zeph_tools::{
25///     CompositeExecutor, ShellExecutor, WebScrapeExecutor, ShellConfig, ScrapeConfig,
26/// };
27///
28/// let shell = ShellExecutor::new(&ShellConfig::default());
29/// let scrape = WebScrapeExecutor::new(&ScrapeConfig::default());
30/// let executor = CompositeExecutor::new(shell, scrape);
31/// // executor handles both bash blocks and scrape/fetch tool calls.
32/// ```
33#[derive(Debug)]
34pub struct CompositeExecutor<A: ToolExecutor, B: ToolExecutor> {
35    first: A,
36    second: B,
37}
38
39impl<A: ToolExecutor, B: ToolExecutor> CompositeExecutor<A, B> {
40    /// Create a new `CompositeExecutor` wrapping `first` and `second`.
41    #[must_use]
42    pub fn new(first: A, second: B) -> Self {
43        Self { first, second }
44    }
45}
46
47impl<A: ToolExecutor, B: ToolExecutor> ToolExecutor for CompositeExecutor<A, B> {
48    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
49        if let Some(output) = self.first.execute(response).await? {
50            return Ok(Some(output));
51        }
52        self.second.execute(response).await
53    }
54
55    async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
56        if let Some(output) = self.first.execute_confirmed(response).await? {
57            return Ok(Some(output));
58        }
59        self.second.execute_confirmed(response).await
60    }
61
62    fn tool_definitions(&self) -> Vec<ToolDef> {
63        let mut defs = self.first.tool_definitions();
64        let seen: std::collections::HashSet<String> =
65            defs.iter().map(|d| d.id.to_string()).collect();
66        for def in self.second.tool_definitions() {
67            if !seen.contains(def.id.as_ref()) {
68                defs.push(def);
69            }
70        }
71        defs
72    }
73
74    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
75        if let Some(output) = self.first.execute_tool_call(call).await? {
76            return Ok(Some(output));
77        }
78        self.second.execute_tool_call(call).await
79    }
80
81    fn is_tool_retryable(&self, tool_id: &str) -> bool {
82        self.first.is_tool_retryable(tool_id) || self.second.is_tool_retryable(tool_id)
83    }
84
85    fn is_tool_speculatable(&self, tool_id: &str) -> bool {
86        self.first.is_tool_speculatable(tool_id) || self.second.is_tool_speculatable(tool_id)
87    }
88
89    /// Forward the active skill's env injection to BOTH inner executors.
90    ///
91    /// The base [`ToolExecutor::set_skill_env`] is a no-op, so without this override the
92    /// composition tree built in `agent_setup` silently swallows env injection — the
93    /// underlying `ShellExecutor` never sees `GITHUB_TOKEN` etc. Each layer ignores the call
94    /// if it does not own a `skill_env` slot; layers that do (e.g. `ShellExecutor`) update
95    /// their state. See #3869.
96    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
97        self.first.set_skill_env(env.clone());
98        self.second.set_skill_env(env);
99    }
100
101    /// Forward the active skill's trust level to BOTH inner executors.
102    ///
103    /// Mirrors [`Self::set_skill_env`]: without this override, `TrustGateExecutor` never
104    /// observes a non-`Trusted` level when composed under `CompositeExecutor`, leaving
105    /// quarantine enforcement effectively bypassed. See #3869.
106    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
107        self.first.set_effective_trust(level);
108        self.second.set_effective_trust(level);
109    }
110
111    /// Delegate undo to the first inner executor that supports checkpoints.
112    fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
113        let result = self.first.checkpoint_undo(n);
114        if result.supported {
115            return result;
116        }
117        self.second.checkpoint_undo(n)
118    }
119
120    /// Delegate redo to the first inner executor that supports checkpoints.
121    fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
122        let result = self.first.checkpoint_redo();
123        if result.supported {
124            return result;
125        }
126        self.second.checkpoint_redo()
127    }
128
129    /// Delegate list to the first inner executor that supports checkpoints.
130    fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
131        let result = self.first.checkpoint_list();
132        if result.supported {
133            return result;
134        }
135        self.second.checkpoint_list()
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::ToolName;
143    use std::assert_matches;
144
145    #[derive(Debug)]
146    struct MatchingExecutor;
147    impl ToolExecutor for MatchingExecutor {
148        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
149            Ok(Some(ToolOutput {
150                tool_name: ToolName::new("test"),
151                summary: "matched".to_owned(),
152                blocks_executed: 1,
153                filter_stats: None,
154                diff: None,
155                streamed: false,
156                terminal_id: None,
157                locations: None,
158                raw_response: None,
159                claim_source: None,
160            }))
161        }
162    }
163
164    #[derive(Debug)]
165    struct NoMatchExecutor;
166    impl ToolExecutor for NoMatchExecutor {
167        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
168            Ok(None)
169        }
170    }
171
172    #[derive(Debug)]
173    struct ErrorExecutor;
174    impl ToolExecutor for ErrorExecutor {
175        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
176            Err(ToolError::Blocked {
177                command: "test".to_owned(),
178            })
179        }
180    }
181
182    #[derive(Debug)]
183    struct SecondExecutor;
184    impl ToolExecutor for SecondExecutor {
185        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
186            Ok(Some(ToolOutput {
187                tool_name: ToolName::new("test"),
188                summary: "second".to_owned(),
189                blocks_executed: 1,
190                filter_stats: None,
191                diff: None,
192                streamed: false,
193                terminal_id: None,
194                locations: None,
195                raw_response: None,
196                claim_source: None,
197            }))
198        }
199    }
200
201    #[tokio::test]
202    async fn first_matches_returns_first() {
203        let composite = CompositeExecutor::new(MatchingExecutor, SecondExecutor);
204        let result = composite.execute("anything").await.unwrap();
205        assert_eq!(result.unwrap().summary, "matched");
206    }
207
208    #[tokio::test]
209    async fn first_none_falls_through_to_second() {
210        let composite = CompositeExecutor::new(NoMatchExecutor, SecondExecutor);
211        let result = composite.execute("anything").await.unwrap();
212        assert_eq!(result.unwrap().summary, "second");
213    }
214
215    #[tokio::test]
216    async fn both_none_returns_none() {
217        let composite = CompositeExecutor::new(NoMatchExecutor, NoMatchExecutor);
218        let result = composite.execute("anything").await.unwrap();
219        assert!(result.is_none());
220    }
221
222    #[tokio::test]
223    async fn first_error_propagates_without_trying_second() {
224        let composite = CompositeExecutor::new(ErrorExecutor, SecondExecutor);
225        let result = composite.execute("anything").await;
226        assert_matches!(result, Err(ToolError::Blocked { .. }));
227    }
228
229    #[tokio::test]
230    async fn second_error_propagates_when_first_none() {
231        let composite = CompositeExecutor::new(NoMatchExecutor, ErrorExecutor);
232        let result = composite.execute("anything").await;
233        assert_matches!(result, Err(ToolError::Blocked { .. }));
234    }
235
236    #[tokio::test]
237    async fn execute_confirmed_first_matches() {
238        let composite = CompositeExecutor::new(MatchingExecutor, SecondExecutor);
239        let result = composite.execute_confirmed("anything").await.unwrap();
240        assert_eq!(result.unwrap().summary, "matched");
241    }
242
243    #[tokio::test]
244    async fn execute_confirmed_falls_through() {
245        let composite = CompositeExecutor::new(NoMatchExecutor, SecondExecutor);
246        let result = composite.execute_confirmed("anything").await.unwrap();
247        assert_eq!(result.unwrap().summary, "second");
248    }
249
250    #[test]
251    fn composite_debug() {
252        let composite = CompositeExecutor::new(MatchingExecutor, SecondExecutor);
253        let debug = format!("{composite:?}");
254        assert!(debug.contains("CompositeExecutor"));
255    }
256
257    #[derive(Debug)]
258    struct FileToolExecutor;
259    impl ToolExecutor for FileToolExecutor {
260        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
261            Ok(None)
262        }
263        async fn execute_tool_call(
264            &self,
265            call: &ToolCall,
266        ) -> Result<Option<ToolOutput>, ToolError> {
267            if call.tool_id == "read" || call.tool_id == "write" {
268                Ok(Some(ToolOutput {
269                    tool_name: call.tool_id.clone(),
270                    summary: "file_handler".to_owned(),
271                    blocks_executed: 1,
272                    filter_stats: None,
273                    diff: None,
274                    streamed: false,
275                    terminal_id: None,
276                    locations: None,
277                    raw_response: None,
278                    claim_source: None,
279                }))
280            } else {
281                Ok(None)
282            }
283        }
284    }
285
286    #[derive(Debug)]
287    struct ShellToolExecutor;
288    impl ToolExecutor for ShellToolExecutor {
289        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
290            Ok(None)
291        }
292        async fn execute_tool_call(
293            &self,
294            call: &ToolCall,
295        ) -> Result<Option<ToolOutput>, ToolError> {
296            if call.tool_id == "bash" {
297                Ok(Some(ToolOutput {
298                    tool_name: ToolName::new("bash"),
299                    summary: "shell_handler".to_owned(),
300                    blocks_executed: 1,
301                    filter_stats: None,
302                    diff: None,
303                    streamed: false,
304                    terminal_id: None,
305                    locations: None,
306                    raw_response: None,
307                    claim_source: None,
308                }))
309            } else {
310                Ok(None)
311            }
312        }
313    }
314
315    #[tokio::test]
316    async fn tool_call_routes_to_file_executor() {
317        let composite = CompositeExecutor::new(FileToolExecutor, ShellToolExecutor);
318        let call = ToolCall {
319            tool_id: ToolName::new("read"),
320            params: serde_json::Map::new(),
321            caller_id: None,
322            context: None,
323
324            tool_call_id: String::new(),
325            skill_name: None,
326        };
327        let result = composite.execute_tool_call(&call).await.unwrap().unwrap();
328        assert_eq!(result.summary, "file_handler");
329    }
330
331    #[tokio::test]
332    async fn tool_call_routes_to_shell_executor() {
333        let composite = CompositeExecutor::new(FileToolExecutor, ShellToolExecutor);
334        let call = ToolCall {
335            tool_id: ToolName::new("bash"),
336            params: serde_json::Map::new(),
337            caller_id: None,
338            context: None,
339
340            tool_call_id: String::new(),
341            skill_name: None,
342        };
343        let result = composite.execute_tool_call(&call).await.unwrap().unwrap();
344        assert_eq!(result.summary, "shell_handler");
345    }
346
347    #[tokio::test]
348    async fn tool_call_unhandled_returns_none() {
349        let composite = CompositeExecutor::new(FileToolExecutor, ShellToolExecutor);
350        let call = ToolCall {
351            tool_id: ToolName::new("unknown"),
352            params: serde_json::Map::new(),
353            caller_id: None,
354            context: None,
355
356            tool_call_id: String::new(),
357            skill_name: None,
358        };
359        let result = composite.execute_tool_call(&call).await.unwrap();
360        assert!(result.is_none());
361    }
362
363    /// Regression test for #3869: state-mutating setters MUST reach both inner executors,
364    /// even across nested compositions. Prior to the fix, `set_skill_env` and
365    /// `set_effective_trust` fell through to the default no-op `ToolExecutor` impls and
366    /// were silently dropped at the `CompositeExecutor` boundary — breaking skill secret
367    /// env injection (`x-requires-secrets`) and quarantine trust enforcement.
368    mod state_forwarding {
369        use super::*;
370        use crate::SkillTrustLevel;
371        use std::sync::Mutex;
372
373        #[derive(Debug, Default)]
374        struct SpyExecutor {
375            last_env: Mutex<Option<std::collections::HashMap<String, String>>>,
376            last_trust: Mutex<Option<SkillTrustLevel>>,
377        }
378        impl ToolExecutor for SpyExecutor {
379            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
380                Ok(None)
381            }
382            fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
383                *self.last_env.lock().unwrap() = env;
384            }
385            fn set_effective_trust(&self, level: SkillTrustLevel) {
386                *self.last_trust.lock().unwrap() = Some(level);
387            }
388        }
389
390        #[test]
391        fn set_skill_env_reaches_both_inner_executors_in_nested_composition() {
392            // Mirrors the production wiring shape: a tree of CompositeExecutor with
393            // multiple leaves. All leaves must observe the call.
394            let leaf_a = SpyExecutor::default();
395            let leaf_b = SpyExecutor::default();
396            let leaf_c = SpyExecutor::default();
397            let nested = CompositeExecutor::new(leaf_a, leaf_b);
398            let outer = CompositeExecutor::new(nested, leaf_c);
399
400            let mut env = std::collections::HashMap::new();
401            env.insert("GITHUB_TOKEN".to_owned(), "tok".to_owned());
402            outer.set_skill_env(Some(env.clone()));
403
404            // first.first (leaf_a)
405            assert_eq!(
406                outer.first.first.last_env.lock().unwrap().as_ref(),
407                Some(&env)
408            );
409            // first.second (leaf_b)
410            assert_eq!(
411                outer.first.second.last_env.lock().unwrap().as_ref(),
412                Some(&env)
413            );
414            // second (leaf_c)
415            assert_eq!(outer.second.last_env.lock().unwrap().as_ref(), Some(&env));
416        }
417
418        #[test]
419        fn set_effective_trust_reaches_both_inner_executors_in_nested_composition() {
420            let leaf_a = SpyExecutor::default();
421            let leaf_b = SpyExecutor::default();
422            let outer = CompositeExecutor::new(leaf_a, leaf_b);
423
424            outer.set_effective_trust(SkillTrustLevel::Quarantined);
425
426            assert_eq!(
427                *outer.first.last_trust.lock().unwrap(),
428                Some(SkillTrustLevel::Quarantined)
429            );
430            assert_eq!(
431                *outer.second.last_trust.lock().unwrap(),
432                Some(SkillTrustLevel::Quarantined)
433            );
434        }
435    }
436}