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 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 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 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 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 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 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 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 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 assert_eq!(
406 outer.first.first.last_env.lock().unwrap().as_ref(),
407 Some(&env)
408 );
409 assert_eq!(
411 outer.first.second.last_env.lock().unwrap().as_ref(),
412 Some(&env)
413 );
414 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}