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