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    async fn execute_tool_call_confirmed(
82        &self,
83        call: &ToolCall,
84    ) -> Result<Option<ToolOutput>, ToolError> {
85        if let Some(output) = self.first.execute_tool_call_confirmed(call).await? {
86            return Ok(Some(output));
87        }
88        self.second.execute_tool_call_confirmed(call).await
89    }
90
91    fn is_tool_retryable(&self, tool_id: &str) -> bool {
92        self.first.is_tool_retryable(tool_id) || self.second.is_tool_retryable(tool_id)
93    }
94
95    fn is_tool_speculatable(&self, tool_id: &str) -> bool {
96        self.first.is_tool_speculatable(tool_id) || self.second.is_tool_speculatable(tool_id)
97    }
98
99    /// Return `true` when either inner executor requires confirmation for `call`.
100    ///
101    /// Mirrors the OR-forwarding used by [`Self::is_tool_retryable`] and
102    /// [`Self::is_tool_speculatable`] — without this override the base
103    /// [`ToolExecutor::requires_confirmation`] default (`false`) silently bypasses
104    /// confirmation gating for any executor composed under `CompositeExecutor`. See #5900.
105    fn requires_confirmation(&self, call: &ToolCall) -> bool {
106        self.first.requires_confirmation(call) || self.second.requires_confirmation(call)
107    }
108
109    /// Forward the active skill's env injection to BOTH inner executors.
110    ///
111    /// The base [`ToolExecutor::set_skill_env`] is a no-op, so without this override the
112    /// composition tree built in `agent_setup` silently swallows env injection — the
113    /// underlying `ShellExecutor` never sees `GITHUB_TOKEN` etc. Each layer ignores the call
114    /// if it does not own a `skill_env` slot; layers that do (e.g. `ShellExecutor`) update
115    /// their state. See #3869.
116    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
117        self.first.set_skill_env(env.clone());
118        self.second.set_skill_env(env);
119    }
120
121    /// Forward the active skill's trust level to BOTH inner executors.
122    ///
123    /// Mirrors [`Self::set_skill_env`]: without this override, `TrustGateExecutor` never
124    /// observes a non-`Trusted` level when composed under `CompositeExecutor`, leaving
125    /// quarantine enforcement effectively bypassed. See #3869.
126    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
127        self.first.set_effective_trust(level);
128        self.second.set_effective_trust(level);
129    }
130
131    /// Delegate undo to the first inner executor that supports checkpoints.
132    fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
133        let result = self.first.checkpoint_undo(n);
134        if result.supported {
135            return result;
136        }
137        self.second.checkpoint_undo(n)
138    }
139
140    /// Delegate redo to the first inner executor that supports checkpoints.
141    fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
142        let result = self.first.checkpoint_redo();
143        if result.supported {
144            return result;
145        }
146        self.second.checkpoint_redo()
147    }
148
149    /// Delegate list to the first inner executor that supports checkpoints.
150    fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
151        let result = self.first.checkpoint_list();
152        if result.supported {
153            return result;
154        }
155        self.second.checkpoint_list()
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::ToolName;
163    use std::assert_matches;
164
165    #[derive(Debug)]
166    struct MatchingExecutor;
167    impl ToolExecutor for MatchingExecutor {
168        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
169            Ok(Some(ToolOutput {
170                tool_name: ToolName::new("test"),
171                summary: "matched".to_owned(),
172                blocks_executed: 1,
173                filter_stats: None,
174                diff: None,
175                streamed: false,
176                terminal_id: None,
177                locations: None,
178                raw_response: None,
179                claim_source: None,
180                ..Default::default()
181            }))
182        }
183
184        crate::tool_executor_no_inner_defaults!();
185    }
186
187    #[derive(Debug)]
188    struct NoMatchExecutor;
189    impl ToolExecutor for NoMatchExecutor {
190        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
191            Ok(None)
192        }
193
194        crate::tool_executor_no_inner_defaults!();
195    }
196
197    #[derive(Debug)]
198    struct ErrorExecutor;
199    impl ToolExecutor for ErrorExecutor {
200        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
201            Err(ToolError::Blocked {
202                command: "test".to_owned(),
203            })
204        }
205
206        crate::tool_executor_no_inner_defaults!();
207    }
208
209    #[derive(Debug)]
210    struct SecondExecutor;
211    impl ToolExecutor for SecondExecutor {
212        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
213            Ok(Some(ToolOutput {
214                tool_name: ToolName::new("test"),
215                summary: "second".to_owned(),
216                blocks_executed: 1,
217                filter_stats: None,
218                diff: None,
219                streamed: false,
220                terminal_id: None,
221                locations: None,
222                raw_response: None,
223                claim_source: None,
224                ..Default::default()
225            }))
226        }
227
228        crate::tool_executor_no_inner_defaults!();
229    }
230
231    #[tokio::test]
232    async fn first_matches_returns_first() {
233        let composite = CompositeExecutor::new(MatchingExecutor, SecondExecutor);
234        let result = composite.execute("anything").await.unwrap();
235        assert_eq!(result.unwrap().summary, "matched");
236    }
237
238    #[tokio::test]
239    async fn first_none_falls_through_to_second() {
240        let composite = CompositeExecutor::new(NoMatchExecutor, SecondExecutor);
241        let result = composite.execute("anything").await.unwrap();
242        assert_eq!(result.unwrap().summary, "second");
243    }
244
245    #[tokio::test]
246    async fn both_none_returns_none() {
247        let composite = CompositeExecutor::new(NoMatchExecutor, NoMatchExecutor);
248        let result = composite.execute("anything").await.unwrap();
249        assert!(result.is_none());
250    }
251
252    #[tokio::test]
253    async fn first_error_propagates_without_trying_second() {
254        let composite = CompositeExecutor::new(ErrorExecutor, SecondExecutor);
255        let result = composite.execute("anything").await;
256        assert_matches!(result, Err(ToolError::Blocked { .. }));
257    }
258
259    #[tokio::test]
260    async fn second_error_propagates_when_first_none() {
261        let composite = CompositeExecutor::new(NoMatchExecutor, ErrorExecutor);
262        let result = composite.execute("anything").await;
263        assert_matches!(result, Err(ToolError::Blocked { .. }));
264    }
265
266    #[tokio::test]
267    async fn execute_confirmed_first_matches() {
268        let composite = CompositeExecutor::new(MatchingExecutor, SecondExecutor);
269        let result = composite.execute_confirmed("anything").await.unwrap();
270        assert_eq!(result.unwrap().summary, "matched");
271    }
272
273    #[tokio::test]
274    async fn execute_confirmed_falls_through() {
275        let composite = CompositeExecutor::new(NoMatchExecutor, SecondExecutor);
276        let result = composite.execute_confirmed("anything").await.unwrap();
277        assert_eq!(result.unwrap().summary, "second");
278    }
279
280    #[test]
281    fn composite_debug() {
282        let composite = CompositeExecutor::new(MatchingExecutor, SecondExecutor);
283        let debug = format!("{composite:?}");
284        assert!(debug.contains("CompositeExecutor"));
285    }
286
287    /// Regression test for #5938: `execute_tool_call_confirmed` must reach the inner
288    /// executor's own `execute_tool_call_confirmed` override, not fall through to the
289    /// trait default (which re-dispatches via `execute_tool_call` and re-runs checks
290    /// the confirmed path is meant to bypass).
291    #[derive(Debug, Default)]
292    struct ConfirmedSpy {
293        confirmed_called: std::sync::Mutex<bool>,
294        unconfirmed_called: std::sync::Mutex<bool>,
295    }
296    impl ToolExecutor for ConfirmedSpy {
297        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
298            Ok(None)
299        }
300        async fn execute_tool_call(
301            &self,
302            call: &ToolCall,
303        ) -> Result<Option<ToolOutput>, ToolError> {
304            *self.unconfirmed_called.lock().unwrap() = true;
305            Ok(Some(ToolOutput {
306                tool_name: call.tool_id.clone(),
307                summary: "unconfirmed".to_owned(),
308                blocks_executed: 1,
309                filter_stats: None,
310                diff: None,
311                streamed: false,
312                terminal_id: None,
313                locations: None,
314                raw_response: None,
315                claim_source: None,
316                ..Default::default()
317            }))
318        }
319        async fn execute_tool_call_confirmed(
320            &self,
321            call: &ToolCall,
322        ) -> Result<Option<ToolOutput>, ToolError> {
323            *self.confirmed_called.lock().unwrap() = true;
324            Ok(Some(ToolOutput {
325                tool_name: call.tool_id.clone(),
326                summary: "confirmed".to_owned(),
327                blocks_executed: 1,
328                filter_stats: None,
329                diff: None,
330                streamed: false,
331                terminal_id: None,
332                locations: None,
333                raw_response: None,
334                claim_source: None,
335                ..Default::default()
336            }))
337        }
338
339        fn checkpoint_undo(&self, _n: usize) -> crate::CheckpointActionResult {
340            crate::CheckpointActionResult::unsupported()
341        }
342        fn checkpoint_redo(&self) -> crate::CheckpointActionResult {
343            crate::CheckpointActionResult::unsupported()
344        }
345        fn checkpoint_list(&self) -> crate::CheckpointListResult {
346            crate::CheckpointListResult::default()
347        }
348        fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
349            false
350        }
351        fn requires_confirmation(&self, _call: &ToolCall) -> bool {
352            false
353        }
354    }
355
356    #[tokio::test]
357    async fn execute_tool_call_confirmed_bypasses_unconfirmed_dispatch() {
358        let spy = ConfirmedSpy::default();
359        let composite = CompositeExecutor::new(spy, NoMatchExecutor);
360        let call = ToolCall {
361            tool_id: ToolName::new("read"),
362            params: serde_json::Map::new(),
363            caller_id: None,
364            context: None,
365            tool_call_id: String::new(),
366            skill_name: None,
367        };
368        let result = composite
369            .execute_tool_call_confirmed(&call)
370            .await
371            .unwrap()
372            .unwrap();
373        assert_eq!(result.summary, "confirmed");
374        assert!(
375            *composite.first.confirmed_called.lock().unwrap(),
376            "execute_tool_call_confirmed must reach the inner executor's confirmed override"
377        );
378        assert!(
379            !*composite.first.unconfirmed_called.lock().unwrap(),
380            "execute_tool_call_confirmed must NOT re-dispatch through execute_tool_call"
381        );
382    }
383
384    #[tokio::test]
385    async fn execute_tool_call_confirmed_falls_through_to_second() {
386        let composite = CompositeExecutor::new(NoMatchExecutor, ConfirmedSpy::default());
387        let call = ToolCall {
388            tool_id: ToolName::new("read"),
389            params: serde_json::Map::new(),
390            caller_id: None,
391            context: None,
392            tool_call_id: String::new(),
393            skill_name: None,
394        };
395        let result = composite
396            .execute_tool_call_confirmed(&call)
397            .await
398            .unwrap()
399            .unwrap();
400        assert_eq!(result.summary, "confirmed");
401        assert!(*composite.second.confirmed_called.lock().unwrap());
402    }
403
404    #[derive(Debug)]
405    struct FileToolExecutor;
406    impl ToolExecutor for FileToolExecutor {
407        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
408            Ok(None)
409        }
410        async fn execute_tool_call(
411            &self,
412            call: &ToolCall,
413        ) -> Result<Option<ToolOutput>, ToolError> {
414            if call.tool_id == "read" || call.tool_id == "write" {
415                Ok(Some(ToolOutput {
416                    tool_name: call.tool_id.clone(),
417                    summary: "file_handler".to_owned(),
418                    blocks_executed: 1,
419                    filter_stats: None,
420                    diff: None,
421                    streamed: false,
422                    terminal_id: None,
423                    locations: None,
424                    raw_response: None,
425                    claim_source: None,
426                    ..Default::default()
427                }))
428            } else {
429                Ok(None)
430            }
431        }
432
433        crate::tool_executor_no_inner_defaults!();
434    }
435
436    #[derive(Debug)]
437    struct ShellToolExecutor;
438    impl ToolExecutor for ShellToolExecutor {
439        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
440            Ok(None)
441        }
442        async fn execute_tool_call(
443            &self,
444            call: &ToolCall,
445        ) -> Result<Option<ToolOutput>, ToolError> {
446            if call.tool_id == "bash" {
447                Ok(Some(ToolOutput {
448                    tool_name: ToolName::new("bash"),
449                    summary: "shell_handler".to_owned(),
450                    blocks_executed: 1,
451                    filter_stats: None,
452                    diff: None,
453                    streamed: false,
454                    terminal_id: None,
455                    locations: None,
456                    raw_response: None,
457                    claim_source: None,
458                    ..Default::default()
459                }))
460            } else {
461                Ok(None)
462            }
463        }
464
465        crate::tool_executor_no_inner_defaults!();
466    }
467
468    #[tokio::test]
469    async fn tool_call_routes_to_file_executor() {
470        let composite = CompositeExecutor::new(FileToolExecutor, ShellToolExecutor);
471        let call = ToolCall {
472            tool_id: ToolName::new("read"),
473            params: serde_json::Map::new(),
474            caller_id: None,
475            context: None,
476
477            tool_call_id: String::new(),
478            skill_name: None,
479        };
480        let result = composite.execute_tool_call(&call).await.unwrap().unwrap();
481        assert_eq!(result.summary, "file_handler");
482    }
483
484    #[tokio::test]
485    async fn tool_call_routes_to_shell_executor() {
486        let composite = CompositeExecutor::new(FileToolExecutor, ShellToolExecutor);
487        let call = ToolCall {
488            tool_id: ToolName::new("bash"),
489            params: serde_json::Map::new(),
490            caller_id: None,
491            context: None,
492
493            tool_call_id: String::new(),
494            skill_name: None,
495        };
496        let result = composite.execute_tool_call(&call).await.unwrap().unwrap();
497        assert_eq!(result.summary, "shell_handler");
498    }
499
500    #[tokio::test]
501    async fn tool_call_unhandled_returns_none() {
502        let composite = CompositeExecutor::new(FileToolExecutor, ShellToolExecutor);
503        let call = ToolCall {
504            tool_id: ToolName::new("unknown"),
505            params: serde_json::Map::new(),
506            caller_id: None,
507            context: None,
508
509            tool_call_id: String::new(),
510            skill_name: None,
511        };
512        let result = composite.execute_tool_call(&call).await.unwrap();
513        assert!(result.is_none());
514    }
515
516    /// Regression test for #3869: state-mutating setters MUST reach both inner executors,
517    /// even across nested compositions. Prior to the fix, `set_skill_env` and
518    /// `set_effective_trust` fell through to the default no-op `ToolExecutor` impls and
519    /// were silently dropped at the `CompositeExecutor` boundary — breaking skill secret
520    /// env injection (`x-requires-secrets`) and quarantine trust enforcement.
521    mod state_forwarding {
522        use super::*;
523        use crate::SkillTrustLevel;
524        use std::sync::Mutex;
525
526        #[derive(Debug, Default)]
527        struct SpyExecutor {
528            last_env: Mutex<Option<std::collections::HashMap<String, String>>>,
529            last_trust: Mutex<Option<SkillTrustLevel>>,
530        }
531        impl ToolExecutor for SpyExecutor {
532            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
533                Ok(None)
534            }
535            fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
536                *self.last_env.lock().unwrap() = env;
537            }
538            fn set_effective_trust(&self, level: SkillTrustLevel) {
539                *self.last_trust.lock().unwrap() = Some(level);
540            }
541
542            crate::tool_executor_no_inner_defaults!();
543        }
544
545        /// Regression test for #5900: `CompositeExecutor::requires_confirmation` must
546        /// OR-forward to both inner executors, mirroring `is_tool_retryable` and
547        /// `is_tool_speculatable`. Before the fix it fell through to the base
548        /// `ToolExecutor::requires_confirmation` default (`false`), silently dropping
549        /// any confirmation requirement declared by either leaf.
550        #[derive(Debug)]
551        struct FixedConfirmation(bool);
552        impl ToolExecutor for FixedConfirmation {
553            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
554                Ok(None)
555            }
556            fn requires_confirmation(&self, _call: &ToolCall) -> bool {
557                self.0
558            }
559
560            async fn execute_tool_call_confirmed(
561                &self,
562                call: &ToolCall,
563            ) -> Result<Option<ToolOutput>, ToolError> {
564                self.execute_tool_call(call).await
565            }
566            fn checkpoint_undo(&self, _n: usize) -> crate::CheckpointActionResult {
567                crate::CheckpointActionResult::unsupported()
568            }
569            fn checkpoint_redo(&self) -> crate::CheckpointActionResult {
570                crate::CheckpointActionResult::unsupported()
571            }
572            fn checkpoint_list(&self) -> crate::CheckpointListResult {
573                crate::CheckpointListResult::default()
574            }
575            fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
576                false
577            }
578        }
579
580        fn confirmation_call() -> ToolCall {
581            ToolCall {
582                tool_id: ToolName::new("shell"),
583                params: serde_json::Map::new(),
584                caller_id: None,
585                context: None,
586                tool_call_id: String::new(),
587                skill_name: None,
588            }
589        }
590
591        #[test]
592        fn requires_confirmation_false_when_both_leaves_false() {
593            let composite =
594                CompositeExecutor::new(FixedConfirmation(false), FixedConfirmation(false));
595            assert!(!composite.requires_confirmation(&confirmation_call()));
596        }
597
598        #[test]
599        fn requires_confirmation_true_when_first_leaf_true() {
600            let composite =
601                CompositeExecutor::new(FixedConfirmation(true), FixedConfirmation(false));
602            assert!(composite.requires_confirmation(&confirmation_call()));
603        }
604
605        #[test]
606        fn requires_confirmation_true_when_second_leaf_true() {
607            let composite =
608                CompositeExecutor::new(FixedConfirmation(false), FixedConfirmation(true));
609            assert!(composite.requires_confirmation(&confirmation_call()));
610        }
611
612        #[test]
613        fn requires_confirmation_or_forwards_across_nested_composition() {
614            let nested = CompositeExecutor::new(FixedConfirmation(false), FixedConfirmation(true));
615            let outer = CompositeExecutor::new(nested, FixedConfirmation(false));
616            assert!(
617                outer.requires_confirmation(&confirmation_call()),
618                "a confirmation requirement on a nested leaf must reach the outer composite"
619            );
620        }
621
622        #[test]
623        fn set_skill_env_reaches_both_inner_executors_in_nested_composition() {
624            // Mirrors the production wiring shape: a tree of CompositeExecutor with
625            // multiple leaves. All leaves must observe the call.
626            let leaf_a = SpyExecutor::default();
627            let leaf_b = SpyExecutor::default();
628            let leaf_c = SpyExecutor::default();
629            let nested = CompositeExecutor::new(leaf_a, leaf_b);
630            let outer = CompositeExecutor::new(nested, leaf_c);
631
632            let mut env = std::collections::HashMap::new();
633            env.insert("GITHUB_TOKEN".to_owned(), "tok".to_owned());
634            outer.set_skill_env(Some(env.clone()));
635
636            // first.first (leaf_a)
637            assert_eq!(
638                outer.first.first.last_env.lock().unwrap().as_ref(),
639                Some(&env)
640            );
641            // first.second (leaf_b)
642            assert_eq!(
643                outer.first.second.last_env.lock().unwrap().as_ref(),
644                Some(&env)
645            );
646            // second (leaf_c)
647            assert_eq!(outer.second.last_env.lock().unwrap().as_ref(), Some(&env));
648        }
649
650        #[test]
651        fn set_effective_trust_reaches_both_inner_executors_in_nested_composition() {
652            let leaf_a = SpyExecutor::default();
653            let leaf_b = SpyExecutor::default();
654            let outer = CompositeExecutor::new(leaf_a, leaf_b);
655
656            outer.set_effective_trust(SkillTrustLevel::Quarantined);
657
658            assert_eq!(
659                *outer.first.last_trust.lock().unwrap(),
660                Some(SkillTrustLevel::Quarantined)
661            );
662            assert_eq!(
663                *outer.second.last_trust.lock().unwrap(),
664                Some(SkillTrustLevel::Quarantined)
665            );
666        }
667    }
668}