1use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
7use crate::registry::ToolDef;
8
9#[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 #[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 fn requires_confirmation(&self, call: &ToolCall) -> bool {
106 self.first.requires_confirmation(call) || self.second.requires_confirmation(call)
107 }
108
109 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 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 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 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 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 #[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 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 #[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 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 assert_eq!(
638 outer.first.first.last_env.lock().unwrap().as_ref(),
639 Some(&env)
640 );
641 assert_eq!(
643 outer.first.second.last_env.lock().unwrap().as_ref(),
644 Some(&env)
645 );
646 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}