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 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 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 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 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}