1use std::sync::Arc;
7
8use switchyard_protocol::{ModelId, Request};
9
10use super::fall_through::{DefaultTarget, FallThrough};
11use super::util::affinity::{AffinityRouter, ClassifyTrigger};
12use super::util::subagent::{SubagentGate, SubagentOverride};
13use crate::core::algorithm::{self, Algorithm, Driver};
14use crate::core::classifier::Classifier;
15use crate::core::state::State;
16use crate::{LibsyError, Result, RoutingOutcome};
17
18pub struct Passthrough {
20 parent_target: ModelId,
21 route: FallThrough<State>,
22}
23
24pub struct PassthroughSubagentConfig {
26 pub targets: Vec<ModelId>,
28 pub classifier: Arc<dyn Classifier<State>>,
30 pub default_target: ModelId,
32 pub classify_trigger: ClassifyTrigger,
34 pub message_hash_fallback: bool,
36}
37
38pub struct PassthroughConfig {
40 pub parent_target: ModelId,
42 pub subagent: Option<PassthroughSubagentConfig>,
44}
45
46impl Passthrough {
47 pub fn new(config: PassthroughConfig) -> Result<Self> {
57 let parent_target = config.parent_target;
58 let route = match config.subagent {
59 None => FallThrough::new_with_state(vec![parent_target.clone()])
60 .with_name("passthrough")
61 .with_classifier(Arc::new(DefaultTarget::new(parent_target.clone()))),
62 Some(subagent) => {
63 algorithm::ensure_model_is_target(&subagent.targets, &subagent.default_target)?;
64 if subagent.message_hash_fallback {
65 return Err(LibsyError::AlgorithmError {
66 message: "sub-agent routing cannot use message_hash_fallback".to_string(),
67 });
68 }
69 let mut targets = subagent.targets;
70 if !targets.contains(&parent_target) {
71 targets.push(parent_target.clone());
72 }
73 let mut route = FallThrough::new_with_state(targets).with_name("passthrough");
74 match subagent.classify_trigger {
75 ClassifyTrigger::EveryRequest => {}
76 ClassifyTrigger::NewSession => {
77 let affinity = Arc::new(AffinityRouter::for_subagents());
78 route = route
79 .with_processor(affinity.clone())
80 .with_classifier(affinity);
81 }
82 ClassifyTrigger::UserTurn => {
83 return Err(LibsyError::AlgorithmError {
84 message: "sub-agent routing cannot use classify_trigger = user_turn"
85 .to_string(),
86 });
87 }
88 }
89 route
90 .with_classifier(Arc::new(SubagentGate::new(subagent.classifier)))
91 .with_classifier(Arc::new(SubagentOverride::new(subagent.default_target)))
92 .with_classifier(Arc::new(DefaultTarget::new(parent_target.clone())))
93 }
94 };
95
96 Ok(Self {
97 parent_target,
98 route,
99 })
100 }
101}
102
103#[async_trait::async_trait]
104impl Algorithm for Passthrough {
105 fn name(&self) -> &str {
106 "passthrough"
107 }
108
109 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome> {
110 let mut outcome = self.route.execute(driver, request).await?;
111 if outcome.selected_model_id == self.parent_target {
114 outcome.fallback_models.clear();
115 } else {
116 outcome
117 .fallback_models
118 .retain(|target| *target != self.parent_target);
119 }
120 Ok(outcome)
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use std::sync::Arc;
127 use std::sync::atomic::{AtomicUsize, Ordering};
128
129 use async_trait::async_trait;
130 use parking_lot::Mutex;
131 use serde_json::json;
132
133 use super::{Passthrough, PassthroughConfig, PassthroughSubagentConfig};
134 use crate::core::algorithm::Algorithm;
135 use crate::core::classifier::{Classification, Classifier, Score};
136 use crate::core::testing::{echo, reply, test_drive};
137 use crate::{
138 ClassifyTrigger, CustomClassifierConfig, CustomClassifierPolicy, Driver,
139 LlmClassifierConfig, LlmTaskClassifier, State,
140 };
141 use switchyard_protocol::{
142 ContentBlock, InstructionBlock, Message, Metadata, ModelId, Request, Response, Role,
143 completion_text, text_request,
144 };
145
146 struct ScriptedClassifier {
147 calls: AtomicUsize,
148 }
149
150 #[async_trait]
151 impl Classifier<State> for ScriptedClassifier {
152 async fn score(
153 &self,
154 _state: &mut State,
155 _request: &mut Request,
156 _driver: Option<&Driver>,
157 ) -> crate::Result<(Classification, Option<Response>)> {
158 let scores = match self.calls.fetch_add(1, Ordering::Relaxed) {
159 0 => vec![Score {
160 confidence: 1.0,
161 target: ModelId::from("worker"),
162 }],
163 1 => vec![Score {
164 confidence: 1.0,
165 target: ModelId::from("reviewer"),
166 }],
167 _ => Vec::new(),
168 };
169 Ok((Classification::Scores(scores), None))
170 }
171 }
172
173 fn request(metadata: Option<Metadata>) -> Request {
174 Request {
175 llm_request: text_request(Some("auto".to_string()), "hi"),
176 raw_request: None,
177 metadata,
178 }
179 }
180
181 fn child(agent_id: &str) -> Request {
182 request(Some(Metadata {
183 session_id: Some("session-1".to_string()),
184 agent_id: Some(agent_id.to_string()),
185 is_subagent: true,
186 is_delegated_work: true,
187 ..Metadata::default()
188 }))
189 }
190
191 fn configured(classifier: Arc<dyn Classifier<State>>) -> crate::Result<Arc<Passthrough>> {
192 Ok(Arc::new(Passthrough::new(PassthroughConfig {
193 parent_target: ModelId::from("parent"),
194 subagent: Some(PassthroughSubagentConfig {
195 targets: vec![ModelId::from("worker"), ModelId::from("reviewer")],
196 classifier,
197 default_target: ModelId::from("worker"),
198 classify_trigger: ClassifyTrigger::NewSession,
199 message_hash_fallback: false,
200 }),
201 })?))
202 }
203
204 #[tokio::test]
205 async fn test_passthrough() -> crate::Result<()> {
206 const MODEL_ID: &str = "testing/passthrough";
207 let request = Request {
208 llm_request: text_request(Some("auto".to_string()), "hi"),
209 raw_request: None,
210 metadata: None,
211 };
212 let algorithm: Arc<dyn Algorithm> = Arc::new(Passthrough::new(PassthroughConfig {
213 parent_target: ModelId::from(MODEL_ID),
214 subagent: None,
215 })?);
216 let (selected_model, response) = test_drive(algorithm, request, echo()).await?;
217
218 assert_eq!(
219 response
220 .llm_response
221 .as_agg()
222 .map(completion_text)
223 .unwrap_or_default(),
224 MODEL_ID
225 );
226 assert_eq!(selected_model, MODEL_ID);
227 Ok(())
228 }
229
230 #[tokio::test]
231 async fn routes_parent_and_children_with_affinity_and_default() -> crate::Result<()> {
232 let classifier = Arc::new(ScriptedClassifier {
233 calls: AtomicUsize::new(0),
234 });
235 let router = configured(classifier.clone())?;
236
237 let (parent, _) = test_drive(router.clone(), request(None), echo()).await?;
238 let (first, _) = test_drive(router.clone(), child("child-1"), echo()).await?;
239 let (same_child, _) = test_drive(router.clone(), child("child-1"), echo()).await?;
240 let (sibling, _) = test_drive(router.clone(), child("child-2"), echo()).await?;
241 let (defaulted, _) = test_drive(router.clone(), child("child-3"), echo()).await?;
242 let maintenance = request(Some(Metadata {
243 session_id: Some("session-1".to_string()),
244 agent_id: Some("child-1".to_string()),
245 is_subagent: true,
246 is_delegated_work: false,
247 ..Metadata::default()
248 }));
249 let (maintenance, _) = test_drive(router, maintenance, echo()).await?;
250
251 assert_eq!(parent, "parent");
252 assert_eq!(first, "worker");
253 assert_eq!(same_child, "worker");
254 assert_eq!(sibling, "reviewer");
255 assert_eq!(defaulted, "worker");
256 assert_eq!(maintenance, "parent");
257 assert_eq!(classifier.calls.load(Ordering::Relaxed), 3);
258 Ok(())
259 }
260
261 #[tokio::test]
262 async fn custom_classifier_receives_only_the_delegated_prompt() -> crate::Result<()> {
263 let classifier = LlmTaskClassifier::new(LlmClassifierConfig::Custom {
264 judge_target: ModelId::from("judge"),
265 targets: vec![
266 ("worker".to_string(), ModelId::from("worker")),
267 ("reviewer".to_string(), ModelId::from("reviewer")),
268 ],
269 default_target: "worker".to_string(),
270 config: CustomClassifierConfig::new(
271 "classify the delegated task",
272 json!({
273 "type": "object",
274 "properties": {
275 "target": {"type": "string", "enum": ["worker", "reviewer"]}
276 },
277 "required": ["target"],
278 "additionalProperties": false
279 }),
280 CustomClassifierPolicy::target_selector("/target"),
281 ),
282 })?;
283 let router = configured(Arc::new(classifier))?;
284 let mut request = child("child-1");
285 request.llm_request.instructions = vec![InstructionBlock {
286 role: Role::System,
287 content: Message::text(Role::System, "child system instructions").content,
288 }];
289 request.llm_request.messages = vec![
290 Message::text(Role::User, "harness context"),
291 Message {
292 role: Role::User,
293 content: vec![
294 ContentBlock::Text {
295 text: "<system-reminder>tool context</system-reminder>".to_string(),
296 },
297 ContentBlock::Text {
298 text: "review this parser".to_string(),
299 },
300 ],
301 },
302 ];
303 let calls = Arc::new(Mutex::new(Vec::new()));
304 let served_calls = calls.clone();
305
306 let (selected, _) = test_drive(router, request, move |target, request| {
307 let calls = served_calls.clone();
308 async move {
309 let completion = if target == "judge" {
310 r#"{"target":"reviewer"}"#
311 } else {
312 "child answer"
313 };
314 calls.lock().push((target, request));
315 Ok(reply(completion))
316 }
317 })
318 .await?;
319
320 assert_eq!(selected, "reviewer");
321 let calls = calls.lock();
322 assert_eq!(calls.len(), 2);
323 assert_eq!(calls[0].0, "judge");
324 assert_eq!(
325 calls[0].1.llm_request.instructions[0].content,
326 Message::text(Role::System, "classify the delegated task").content
327 );
328 assert_eq!(
329 calls[0].1.llm_request.messages,
330 vec![Message::text(Role::User, "review this parser")]
331 );
332 assert_eq!(calls[1].0, "reviewer");
333 assert_eq!(calls[1].1.llm_request.instructions.len(), 1);
334 assert_eq!(calls[1].1.llm_request.messages.len(), 2);
335 Ok(())
336 }
337}