Skip to main content

switchyard_libsy/algorithms/
passthrough.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Direct parent routing with an optional delegated-work classifier cascade.
5
6use std::sync::Arc;
7
8use switchyard_protocol::{ModelId, Request};
9
10use super::fall_through::{DefaultTarget, FallThrough};
11use super::util::affinity::{AffinityRouter, ClassifyTrigger};
12use super::util::subagent::{SubagentGate, SubagentOverride};
13use crate::core::algorithm::{self, Algorithm, Driver};
14use crate::core::classifier::Classifier;
15use crate::core::state::State;
16use crate::{LibsyError, Result, RoutingOutcome};
17
18/// Routes parent traffic directly and optionally classifies delegated sub-agent work.
19pub struct Passthrough {
20    parent_target: ModelId,
21    route: FallThrough<State>,
22}
23
24/// Runtime components for classifying and retaining delegated sub-agent work.
25pub struct PassthroughSubagentConfig {
26    /// Targets the delegated-work classifier may select.
27    pub targets: Vec<ModelId>,
28    /// Classifier invoked for the first request from each identified child.
29    pub classifier: Arc<dyn Classifier<State>>,
30    /// Child target used when `classifier` abstains.
31    pub default_target: ModelId,
32    /// Controls whether each child is classified once or on every request.
33    pub classify_trigger: ClassifyTrigger,
34    /// Unsupported for child routing because child identity must come from harness metadata.
35    pub message_hash_fallback: bool,
36}
37
38/// Complete construction settings for [`Passthrough`].
39pub struct PassthroughConfig {
40    /// Target used for parent and harness-maintenance traffic.
41    pub parent_target: ModelId,
42    /// Optional delegated-work decision gate.
43    pub subagent: Option<PassthroughSubagentConfig>,
44}
45
46impl Passthrough {
47    /// Creates direct parent routing, optionally with a decision gate for sub-agents.
48    ///
49    /// When configured, the sub-agent classifier runs once per identified child. Its first
50    /// decision is retained by `session + agent`; an abstaining classifier uses the child
51    /// default. Root and harness-maintenance traffic continue to the parent target.
52    ///
53    /// # Errors
54    ///
55    /// Returns an error when the configured child default is not a child target.
56    pub fn new(config: PassthroughConfig) -> Result<Self> {
57        let parent_target = config.parent_target;
58        let route = match config.subagent {
59            None => FallThrough::new_with_state(vec![parent_target.clone()])
60                .with_name("passthrough")
61                .with_classifier(Arc::new(DefaultTarget::new(parent_target.clone()))),
62            Some(subagent) => {
63                algorithm::ensure_model_is_target(&subagent.targets, &subagent.default_target)?;
64                if subagent.message_hash_fallback {
65                    return Err(LibsyError::AlgorithmError {
66                        message: "sub-agent routing cannot use message_hash_fallback".to_string(),
67                    });
68                }
69                let mut targets = subagent.targets;
70                if !targets.contains(&parent_target) {
71                    targets.push(parent_target.clone());
72                }
73                let mut route = FallThrough::new_with_state(targets).with_name("passthrough");
74                match subagent.classify_trigger {
75                    ClassifyTrigger::EveryRequest => {}
76                    ClassifyTrigger::NewSession => {
77                        let affinity = Arc::new(AffinityRouter::for_subagents());
78                        route = route
79                            .with_processor(affinity.clone())
80                            .with_classifier(affinity);
81                    }
82                    ClassifyTrigger::UserTurn => {
83                        return Err(LibsyError::AlgorithmError {
84                            message: "sub-agent routing cannot use classify_trigger = user_turn"
85                                .to_string(),
86                        });
87                    }
88                }
89                route
90                    .with_classifier(Arc::new(SubagentGate::new(subagent.classifier)))
91                    .with_classifier(Arc::new(SubagentOverride::new(subagent.default_target)))
92                    .with_classifier(Arc::new(DefaultTarget::new(parent_target.clone())))
93            }
94        };
95
96        Ok(Self {
97            parent_target,
98            route,
99        })
100    }
101}
102
103#[async_trait::async_trait]
104impl Algorithm for Passthrough {
105    fn name(&self) -> &str {
106        "passthrough"
107    }
108
109    async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome> {
110        let mut outcome = self.route.execute(driver, request).await?;
111        // Parent traffic preserves passthrough's no-fallback contract. Child traffic may
112        // fall back only within the child target set, never into the parent route.
113        if outcome.selected_model_id == self.parent_target {
114            outcome.fallback_models.clear();
115        } else {
116            outcome
117                .fallback_models
118                .retain(|target| *target != self.parent_target);
119        }
120        Ok(outcome)
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use std::sync::Arc;
127    use std::sync::atomic::{AtomicUsize, Ordering};
128
129    use async_trait::async_trait;
130    use parking_lot::Mutex;
131    use serde_json::json;
132
133    use super::{Passthrough, PassthroughConfig, PassthroughSubagentConfig};
134    use crate::core::algorithm::Algorithm;
135    use crate::core::classifier::{Classification, Classifier, Score};
136    use crate::core::testing::{echo, reply, test_drive};
137    use crate::{
138        ClassifyTrigger, CustomClassifierConfig, CustomClassifierPolicy, Driver,
139        LlmClassifierConfig, LlmTaskClassifier, State,
140    };
141    use switchyard_protocol::{
142        ContentBlock, InstructionBlock, Message, Metadata, ModelId, Request, Response, Role,
143        completion_text, text_request,
144    };
145
146    struct ScriptedClassifier {
147        calls: AtomicUsize,
148    }
149
150    #[async_trait]
151    impl Classifier<State> for ScriptedClassifier {
152        async fn score(
153            &self,
154            _state: &mut State,
155            _request: &mut Request,
156            _driver: Option<&Driver>,
157        ) -> crate::Result<(Classification, Option<Response>)> {
158            let scores = match self.calls.fetch_add(1, Ordering::Relaxed) {
159                0 => vec![Score {
160                    confidence: 1.0,
161                    target: ModelId::from("worker"),
162                }],
163                1 => vec![Score {
164                    confidence: 1.0,
165                    target: ModelId::from("reviewer"),
166                }],
167                _ => Vec::new(),
168            };
169            Ok((Classification::Scores(scores), None))
170        }
171    }
172
173    fn request(metadata: Option<Metadata>) -> Request {
174        Request {
175            llm_request: text_request(Some("auto".to_string()), "hi"),
176            raw_request: None,
177            metadata,
178        }
179    }
180
181    fn child(agent_id: &str) -> Request {
182        request(Some(Metadata {
183            session_id: Some("session-1".to_string()),
184            agent_id: Some(agent_id.to_string()),
185            is_subagent: true,
186            is_delegated_work: true,
187            ..Metadata::default()
188        }))
189    }
190
191    fn configured(classifier: Arc<dyn Classifier<State>>) -> crate::Result<Arc<Passthrough>> {
192        Ok(Arc::new(Passthrough::new(PassthroughConfig {
193            parent_target: ModelId::from("parent"),
194            subagent: Some(PassthroughSubagentConfig {
195                targets: vec![ModelId::from("worker"), ModelId::from("reviewer")],
196                classifier,
197                default_target: ModelId::from("worker"),
198                classify_trigger: ClassifyTrigger::NewSession,
199                message_hash_fallback: false,
200            }),
201        })?))
202    }
203
204    #[tokio::test]
205    async fn test_passthrough() -> crate::Result<()> {
206        const MODEL_ID: &str = "testing/passthrough";
207        let request = Request {
208            llm_request: text_request(Some("auto".to_string()), "hi"),
209            raw_request: None,
210            metadata: None,
211        };
212        let algorithm: Arc<dyn Algorithm> = Arc::new(Passthrough::new(PassthroughConfig {
213            parent_target: ModelId::from(MODEL_ID),
214            subagent: None,
215        })?);
216        let (selected_model, response) = test_drive(algorithm, request, echo()).await?;
217
218        assert_eq!(
219            response
220                .llm_response
221                .as_agg()
222                .map(completion_text)
223                .unwrap_or_default(),
224            MODEL_ID
225        );
226        assert_eq!(selected_model, MODEL_ID);
227        Ok(())
228    }
229
230    #[tokio::test]
231    async fn routes_parent_and_children_with_affinity_and_default() -> crate::Result<()> {
232        let classifier = Arc::new(ScriptedClassifier {
233            calls: AtomicUsize::new(0),
234        });
235        let router = configured(classifier.clone())?;
236
237        let (parent, _) = test_drive(router.clone(), request(None), echo()).await?;
238        let (first, _) = test_drive(router.clone(), child("child-1"), echo()).await?;
239        let (same_child, _) = test_drive(router.clone(), child("child-1"), echo()).await?;
240        let (sibling, _) = test_drive(router.clone(), child("child-2"), echo()).await?;
241        let (defaulted, _) = test_drive(router.clone(), child("child-3"), echo()).await?;
242        let maintenance = request(Some(Metadata {
243            session_id: Some("session-1".to_string()),
244            agent_id: Some("child-1".to_string()),
245            is_subagent: true,
246            is_delegated_work: false,
247            ..Metadata::default()
248        }));
249        let (maintenance, _) = test_drive(router, maintenance, echo()).await?;
250
251        assert_eq!(parent, "parent");
252        assert_eq!(first, "worker");
253        assert_eq!(same_child, "worker");
254        assert_eq!(sibling, "reviewer");
255        assert_eq!(defaulted, "worker");
256        assert_eq!(maintenance, "parent");
257        assert_eq!(classifier.calls.load(Ordering::Relaxed), 3);
258        Ok(())
259    }
260
261    #[tokio::test]
262    async fn custom_classifier_receives_only_the_delegated_prompt() -> crate::Result<()> {
263        let classifier = LlmTaskClassifier::new(LlmClassifierConfig::Custom {
264            judge_target: ModelId::from("judge"),
265            targets: vec![
266                ("worker".to_string(), ModelId::from("worker")),
267                ("reviewer".to_string(), ModelId::from("reviewer")),
268            ],
269            default_target: "worker".to_string(),
270            config: CustomClassifierConfig::new(
271                "classify the delegated task",
272                json!({
273                    "type": "object",
274                    "properties": {
275                        "target": {"type": "string", "enum": ["worker", "reviewer"]}
276                    },
277                    "required": ["target"],
278                    "additionalProperties": false
279                }),
280                CustomClassifierPolicy::target_selector("/target"),
281            ),
282        })?;
283        let router = configured(Arc::new(classifier))?;
284        let mut request = child("child-1");
285        request.llm_request.instructions = vec![InstructionBlock {
286            role: Role::System,
287            content: Message::text(Role::System, "child system instructions").content,
288        }];
289        request.llm_request.messages = vec![
290            Message::text(Role::User, "harness context"),
291            Message {
292                role: Role::User,
293                content: vec![
294                    ContentBlock::Text {
295                        text: "<system-reminder>tool context</system-reminder>".to_string(),
296                    },
297                    ContentBlock::Text {
298                        text: "review this parser".to_string(),
299                    },
300                ],
301            },
302        ];
303        let calls = Arc::new(Mutex::new(Vec::new()));
304        let served_calls = calls.clone();
305
306        let (selected, _) = test_drive(router, request, move |target, request| {
307            let calls = served_calls.clone();
308            async move {
309                let completion = if target == "judge" {
310                    r#"{"target":"reviewer"}"#
311                } else {
312                    "child answer"
313                };
314                calls.lock().push((target, request));
315                Ok(reply(completion))
316            }
317        })
318        .await?;
319
320        assert_eq!(selected, "reviewer");
321        let calls = calls.lock();
322        assert_eq!(calls.len(), 2);
323        assert_eq!(calls[0].0, "judge");
324        assert_eq!(
325            calls[0].1.llm_request.instructions[0].content,
326            Message::text(Role::System, "classify the delegated task").content
327        );
328        assert_eq!(
329            calls[0].1.llm_request.messages,
330            vec![Message::text(Role::User, "review this parser")]
331        );
332        assert_eq!(calls[1].0, "reviewer");
333        assert_eq!(calls[1].1.llm_request.instructions.len(), 1);
334        assert_eq!(calls[1].1.llm_request.messages.len(), 2);
335        Ok(())
336    }
337}