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    fn needs_history_replay(&self, request: &Request) -> bool {
121        !request
122            .metadata
123            .as_ref()
124            .is_some_and(Metadata::is_subagent_work)
125            && self.parent.needs_history_replay(request)
126    }
127
128    async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome> {
129        if request
130            .metadata
131            .as_ref()
132            .is_some_and(Metadata::is_subagent_work)
133        {
134            // Delegated work routes over the sub-agent's own models, never the parent's.
135            self.subagent.execute(driver.for_subagent()?, request).await
136        } else {
137            self.warn_if_subagent_identity_unsupported(&request);
138            self.parent.clone().route(driver, request).await
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use std::sync::Arc;
146    use std::sync::atomic::{AtomicUsize, Ordering};
147
148    use async_trait::async_trait;
149    use switchyard_protocol::{Category, Metadata, ModelId, Request, Response, text_request};
150
151    use super::{SubagentRouter, SubagentRouterConfig};
152    use crate::algorithms::passthrough::Passthrough;
153    use crate::core::classifier::{Classification, Classifier, Score};
154    use crate::core::testing::{echo, test_drive_with_models};
155    use crate::{ClassifyTrigger, Driver, RuntimeModels, State};
156
157    struct ScriptedClassifier {
158        calls: AtomicUsize,
159    }
160
161    #[async_trait]
162    impl Classifier<State> for ScriptedClassifier {
163        async fn score(
164            &self,
165            _state: &mut State,
166            _request: &mut Request,
167            driver: &Driver,
168        ) -> crate::Result<(Classification, Option<Response>)> {
169            let category = match self.calls.fetch_add(1, Ordering::Relaxed) {
170                0 => Some(Category::Capable),
171                1 => Some(Category::Efficient),
172                _ => None,
173            };
174            let scores = match category {
175                Some(category) => vec![Score {
176                    confidence: 1.0,
177                    target: driver.first_model_for(&category)?.clone(),
178                    category: Some(category),
179                }],
180                None => Vec::new(),
181            };
182            Ok((Classification::Scores(scores), None))
183        }
184    }
185
186    fn request(metadata: Option<Metadata>) -> Request {
187        Request {
188            llm_request: text_request(Some("auto".to_string()), "hi"),
189            raw_request: None,
190            metadata,
191        }
192    }
193
194    fn child(agent_id: &str) -> Request {
195        request(Some(Metadata {
196            session_id: Some("session-1".to_string()),
197            agent_id: Some(agent_id.to_string()),
198            is_subagent: true,
199            is_delegated_work: true,
200            ..Metadata::default()
201        }))
202    }
203
204    fn configured(classifier: Arc<dyn Classifier<State>>) -> crate::Result<Arc<SubagentRouter>> {
205        Ok(Arc::new(SubagentRouter::new(
206            Arc::new(Passthrough),
207            SubagentRouterConfig {
208                classifier,
209                default_target: Category::Capable,
210                classify_trigger: ClassifyTrigger::NewSession,
211                message_hash_fallback: false,
212            },
213        )?))
214    }
215
216    fn without_subagent_identity() -> Request {
217        request(Some(Metadata {
218            session_id: Some("session-1".to_string()),
219            subagent_identity_unsupported: true,
220            ..Metadata::default()
221        }))
222    }
223
224    #[tokio::test]
225    async fn harness_without_subagent_identity_warns_once_and_routes_through_the_parent()
226    -> crate::Result<()> {
227        // Without child identity the request is indistinguishable from the parent's, so it
228        // keeps routing through the parent algorithm; the warning fires once per route.
229        let router = configured(Arc::new(ScriptedClassifier {
230            calls: AtomicUsize::new(0),
231        }))?;
232        let models = RuntimeModels::new([(Category::Any, vec![ModelId::from("parent")])].into())
233            .with_subagent([(Category::Any, vec![ModelId::from("worker")])].into());
234        for _ in 0..2 {
235            let request = without_subagent_identity();
236            let (selected, _) =
237                test_drive_with_models(router.clone(), request, models.clone(), echo()).await?;
238            assert_eq!(selected, "parent");
239        }
240        assert!(router.identity_warning_emitted.load(Ordering::Relaxed));
241        Ok(())
242    }
243
244    #[tokio::test]
245    async fn routes_parent_and_children_with_affinity_and_default() -> crate::Result<()> {
246        let classifier = Arc::new(ScriptedClassifier {
247            calls: AtomicUsize::new(0),
248        });
249        let router = configured(classifier.clone())?;
250
251        // The parent and its children route over separate model groups.
252        let models = RuntimeModels::new([(Category::Any, vec![ModelId::from("parent")])].into())
253            .with_subagent(
254                [
255                    (
256                        Category::Any,
257                        vec![ModelId::from("worker"), ModelId::from("reviewer")],
258                    ),
259                    (Category::Capable, vec![ModelId::from("worker")]),
260                    (Category::Efficient, vec![ModelId::from("reviewer")]),
261                ]
262                .into(),
263            );
264        let (selected_parent, _) =
265            test_drive_with_models(router.clone(), request(None), models.clone(), echo()).await?;
266        let (first, _) =
267            test_drive_with_models(router.clone(), child("child-1"), models.clone(), echo())
268                .await?;
269        let (same_child, _) =
270            test_drive_with_models(router.clone(), child("child-1"), models.clone(), echo())
271                .await?;
272        let (sibling, _) =
273            test_drive_with_models(router.clone(), child("child-2"), models.clone(), echo())
274                .await?;
275        let (defaulted, _) =
276            test_drive_with_models(router.clone(), child("child-3"), models.clone(), echo())
277                .await?;
278        let maintenance = request(Some(Metadata {
279            session_id: Some("session-1".to_string()),
280            agent_id: Some("child-1".to_string()),
281            is_subagent: true,
282            is_delegated_work: false,
283            ..Metadata::default()
284        }));
285        let (maintenance, _) =
286            test_drive_with_models(router, maintenance, models.clone(), echo()).await?;
287
288        assert_eq!(selected_parent, "parent");
289        assert_eq!(first, "worker");
290        assert_eq!(same_child, "worker");
291        assert_eq!(sibling, "reviewer");
292        assert_eq!(defaulted, "worker");
293        assert_eq!(maintenance, "parent");
294        assert_eq!(classifier.calls.load(Ordering::Relaxed), 3);
295
296        let fixed = Arc::new(SubagentRouter::new(
297            Arc::new(Passthrough),
298            SubagentRouterConfig::fixed_target(),
299        )?);
300        let (fixed, _) = test_drive_with_models(fixed, child("fixed"), models, echo()).await?;
301        assert_eq!(fixed, "worker");
302        Ok(())
303    }
304}