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