Skip to main content

switchyard_libsy/algorithms/
subagent.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Delegated sub-agent routing around an arbitrary parent algorithm.
5
6use std::sync::Arc;
7use std::sync::atomic::{AtomicBool, Ordering};
8
9use switchyard_protocol::{Category, Metadata, Request};
10
11use super::fall_through::FallThrough;
12use super::util::affinity::{AffinityRouter, ClassifyTrigger};
13use super::util::subagent::SubagentGate;
14use crate::algorithms::llm_class::DefaultCategoryClassifier;
15use crate::core::algorithm::{Algorithm, Driver};
16use crate::core::classifier::Classifier;
17use crate::core::state::State;
18use crate::{LibsyError, Result, RoutingOutcome};
19
20/// Runtime components for delegated sub-agent routing.
21pub struct SubagentRouterConfig {
22    /// Classifier invoked for delegated work according to `classify_trigger`.
23    pub classifier: Arc<dyn Classifier<State>>,
24    /// Child model category used when `classifier` abstains.
25    pub default_target: Category,
26    /// Controls whether each child is classified once or on every request.
27    pub classify_trigger: ClassifyTrigger,
28    /// Unsupported for child routing because child identity must come from harness metadata.
29    pub message_hash_fallback: bool,
30}
31
32impl SubagentRouterConfig {
33    /// Routes all delegated work to the first model in the sub-agent `Any` category.
34    pub fn fixed_target() -> Self {
35        Self {
36            classifier: Arc::new(DefaultCategoryClassifier(Category::Any)),
37            default_target: Category::Any,
38            classify_trigger: ClassifyTrigger::EveryRequest,
39            message_hash_fallback: false,
40        }
41    }
42}
43
44/// Routes delegated work independently while preserving the parent algorithm for other traffic.
45pub struct SubagentRouter {
46    parent: Arc<dyn Algorithm>,
47    subagent: FallThrough<State>,
48    /// Whether the one-time warning about a harness without sub-agent identity has been emitted.
49    identity_warning_emitted: AtomicBool,
50}
51
52impl SubagentRouter {
53    /// Wraps `parent` with the configured delegated-work route.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error when the affinity settings cannot identify delegated children safely.
58    pub fn new(parent: Arc<dyn Algorithm>, config: SubagentRouterConfig) -> Result<Self> {
59        if config.message_hash_fallback {
60            return Err(LibsyError::AlgorithmError {
61                message: "sub-agent routing cannot use message_hash_fallback".to_string(),
62            });
63        }
64
65        let mut subagent = match config.classify_trigger {
66            ClassifyTrigger::EveryRequest => FallThrough::new_with_state().with_name("subagent"),
67            ClassifyTrigger::NewSession => {
68                let affinity = Arc::new(AffinityRouter::for_subagents());
69                FallThrough::new_with_state()
70                    .with_name("subagent")
71                    .with_processor(affinity.clone())
72                    .with_classifier(affinity)
73            }
74            ClassifyTrigger::UserTurn => {
75                return Err(LibsyError::AlgorithmError {
76                    message: "sub-agent routing cannot use classify_trigger = user_turn"
77                        .to_string(),
78                });
79            }
80        };
81        subagent = subagent
82            .with_classifier(Arc::new(SubagentGate::new(config.classifier)))
83            .with_classifier(Arc::new(DefaultCategoryClassifier(config.default_target)));
84
85        Ok(Self {
86            parent,
87            subagent,
88            identity_warning_emitted: AtomicBool::new(false),
89        })
90    }
91
92    /// Warns once when a harness that cannot identify its children reaches this route.
93    ///
94    /// Such a build sends only the session id, so its sub-agent requests look like the
95    /// parent's and route through the parent algorithm. Without this the route reports
96    /// itself as configured while never routing any delegated work.
97    fn warn_if_subagent_identity_unsupported(&self, request: &Request) {
98        let unsupported = request
99            .metadata
100            .as_ref()
101            .is_some_and(|metadata| metadata.subagent_identity_unsupported);
102        if !unsupported || self.identity_warning_emitted.swap(true, Ordering::Relaxed) {
103            return;
104        }
105        tracing::warn!(
106            target: "libsy",
107            "this route has sub-agent routing but the calling harness does not send \
108             sub-agent identity, so its delegated requests route through the parent route; \
109             a harness upgrade may be required"
110        );
111    }
112}
113
114#[async_trait::async_trait]
115impl Algorithm for SubagentRouter {
116    fn name(&self) -> &str {
117        self.parent.name()
118    }
119
120    async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome> {
121        if request
122            .metadata
123            .as_ref()
124            .is_some_and(Metadata::is_subagent_work)
125        {
126            // Delegated work routes over the sub-agent's own models, never the parent's.
127            self.subagent.execute(driver.for_subagent()?, request).await
128        } else {
129            self.warn_if_subagent_identity_unsupported(&request);
130            self.parent.clone().route(driver, request).await
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use std::sync::Arc;
138    use std::sync::atomic::{AtomicUsize, Ordering};
139
140    use async_trait::async_trait;
141    use switchyard_protocol::{Category, Metadata, ModelId, Request, Response, text_request};
142
143    use super::{SubagentRouter, SubagentRouterConfig};
144    use crate::algorithms::passthrough::Passthrough;
145    use crate::core::classifier::{Classification, Classifier, Score};
146    use crate::core::testing::{echo, test_drive_with_models};
147    use crate::{ClassifyTrigger, Driver, RuntimeModels, State};
148
149    struct ScriptedClassifier {
150        calls: AtomicUsize,
151    }
152
153    #[async_trait]
154    impl Classifier<State> for ScriptedClassifier {
155        async fn score(
156            &self,
157            _state: &mut State,
158            _request: &mut Request,
159            driver: &Driver,
160        ) -> crate::Result<(Classification, Option<Response>)> {
161            let category = match self.calls.fetch_add(1, Ordering::Relaxed) {
162                0 => Some(Category::Capable),
163                1 => Some(Category::Efficient),
164                _ => None,
165            };
166            let scores = match category {
167                Some(category) => vec![Score {
168                    confidence: 1.0,
169                    target: driver.first_model_for(&category)?.clone(),
170                    category: Some(category),
171                }],
172                None => Vec::new(),
173            };
174            Ok((Classification::Scores(scores), None))
175        }
176    }
177
178    fn request(metadata: Option<Metadata>) -> Request {
179        Request {
180            llm_request: text_request(Some("auto".to_string()), "hi"),
181            raw_request: None,
182            metadata,
183        }
184    }
185
186    fn child(agent_id: &str) -> Request {
187        request(Some(Metadata {
188            session_id: Some("session-1".to_string()),
189            agent_id: Some(agent_id.to_string()),
190            is_subagent: true,
191            is_delegated_work: true,
192            ..Metadata::default()
193        }))
194    }
195
196    fn configured(classifier: Arc<dyn Classifier<State>>) -> crate::Result<Arc<SubagentRouter>> {
197        Ok(Arc::new(SubagentRouter::new(
198            Arc::new(Passthrough),
199            SubagentRouterConfig {
200                classifier,
201                default_target: Category::Capable,
202                classify_trigger: ClassifyTrigger::NewSession,
203                message_hash_fallback: false,
204            },
205        )?))
206    }
207
208    fn without_subagent_identity() -> Request {
209        request(Some(Metadata {
210            session_id: Some("session-1".to_string()),
211            subagent_identity_unsupported: true,
212            ..Metadata::default()
213        }))
214    }
215
216    #[tokio::test]
217    async fn harness_without_subagent_identity_warns_once_and_routes_through_the_parent()
218    -> crate::Result<()> {
219        // Without child identity the request is indistinguishable from the parent's, so it
220        // keeps routing through the parent algorithm; the warning fires once per route.
221        let router = configured(Arc::new(ScriptedClassifier {
222            calls: AtomicUsize::new(0),
223        }))?;
224        let models = RuntimeModels::new([(Category::Any, vec![ModelId::from("parent")])].into())
225            .with_subagent([(Category::Any, vec![ModelId::from("worker")])].into());
226        for _ in 0..2 {
227            let request = without_subagent_identity();
228            let (selected, _) =
229                test_drive_with_models(router.clone(), request, models.clone(), echo()).await?;
230            assert_eq!(selected, "parent");
231        }
232        assert!(router.identity_warning_emitted.load(Ordering::Relaxed));
233        Ok(())
234    }
235
236    #[tokio::test]
237    async fn routes_parent_and_children_with_affinity_and_default() -> crate::Result<()> {
238        let classifier = Arc::new(ScriptedClassifier {
239            calls: AtomicUsize::new(0),
240        });
241        let router = configured(classifier.clone())?;
242
243        // The parent and its children route over separate model groups.
244        let models = RuntimeModels::new([(Category::Any, vec![ModelId::from("parent")])].into())
245            .with_subagent(
246                [
247                    (
248                        Category::Any,
249                        vec![ModelId::from("worker"), ModelId::from("reviewer")],
250                    ),
251                    (Category::Capable, vec![ModelId::from("worker")]),
252                    (Category::Efficient, vec![ModelId::from("reviewer")]),
253                ]
254                .into(),
255            );
256        let (selected_parent, _) =
257            test_drive_with_models(router.clone(), request(None), models.clone(), echo()).await?;
258        let (first, _) =
259            test_drive_with_models(router.clone(), child("child-1"), models.clone(), echo())
260                .await?;
261        let (same_child, _) =
262            test_drive_with_models(router.clone(), child("child-1"), models.clone(), echo())
263                .await?;
264        let (sibling, _) =
265            test_drive_with_models(router.clone(), child("child-2"), models.clone(), echo())
266                .await?;
267        let (defaulted, _) =
268            test_drive_with_models(router.clone(), child("child-3"), models.clone(), echo())
269                .await?;
270        let maintenance = request(Some(Metadata {
271            session_id: Some("session-1".to_string()),
272            agent_id: Some("child-1".to_string()),
273            is_subagent: true,
274            is_delegated_work: false,
275            ..Metadata::default()
276        }));
277        let (maintenance, _) =
278            test_drive_with_models(router, maintenance, models.clone(), echo()).await?;
279
280        assert_eq!(selected_parent, "parent");
281        assert_eq!(first, "worker");
282        assert_eq!(same_child, "worker");
283        assert_eq!(sibling, "reviewer");
284        assert_eq!(defaulted, "worker");
285        assert_eq!(maintenance, "parent");
286        assert_eq!(classifier.calls.load(Ordering::Relaxed), 3);
287
288        let fixed = Arc::new(SubagentRouter::new(
289            Arc::new(Passthrough),
290            SubagentRouterConfig::fixed_target(),
291        )?);
292        let (fixed, _) = test_drive_with_models(fixed, child("fixed"), models, echo()).await?;
293        assert_eq!(fixed, "worker");
294        Ok(())
295    }
296}