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::core::state::State;
18use crate::{LibsyError, Result, RoutingOutcome};
19
20pub struct SubagentRouterConfig {
22 pub classifier: Arc<dyn Classifier<State>>,
24 pub default_target: Category,
26 pub classify_trigger: ClassifyTrigger,
28 pub message_hash_fallback: bool,
30}
31
32impl SubagentRouterConfig {
33 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
44pub struct SubagentRouter {
46 parent: Arc<dyn Algorithm>,
47 subagent: FallThrough<State>,
48 identity_warning_emitted: AtomicBool,
50}
51
52impl SubagentRouter {
53 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 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 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 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 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}