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