switchyard_libsy/algorithms/
subagent.rs1use 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
19pub struct SubagentRouterConfig {
21 pub classifier: Arc<dyn Classifier>,
23 pub default_target: Category,
25 pub classify_trigger: ClassifyTrigger,
27 pub message_hash_fallback: bool,
29}
30
31impl SubagentRouterConfig {
32 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
43pub struct SubagentRouter {
45 parent: Arc<dyn Algorithm>,
46 subagent: FallThrough,
47 identity_warning_emitted: AtomicBool,
49}
50
51impl SubagentRouter {
52 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 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 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 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 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}