1use std::sync::Arc;
17
18use async_trait::async_trait;
19
20use super::fall_through::{DefaultTarget, FallThrough};
21use super::llm_class::{LlmClassifierConfig, LlmTaskClassifier, TaskClassifierConfig};
22use super::util::prompts::{SystemPromptProcessor, TargetPrompts};
23use super::util::stage::{
24 DecisionSource, HandoffNoteConfig, PickerMode, StageClassifier, StageTargets,
25 record_decision_source, record_routing_decision,
26};
27use super::util::tool_signals::{DEFAULT_RECENT_WINDOW, ToolSignalProcessor};
28use crate::core::algorithm::{Algorithm, Driver};
29use crate::core::classifier::{Classification, Classifier};
30use crate::core::state::State;
31use crate::{LibsyError, Result};
32use switchyard_protocol::{ModelId, Request, Response};
33
34const STAGE_ROUTER: &str = "stage_router";
36
37struct SourceStamp {
42 inner: Arc<dyn Classifier<State>>,
43 source: DecisionSource,
44}
45
46#[async_trait]
47impl Classifier<State> for SourceStamp {
48 fn routing_tier(&self, selected_model_id: &ModelId) -> Option<&'static str> {
49 self.inner.routing_tier(selected_model_id)
50 }
51
52 async fn score(
53 &self,
54 state: &mut State,
55 request: &mut Request,
56 driver: Option<&Driver>,
57 ) -> Result<(Classification, Option<Response>)> {
58 let (classification, served) = self.inner.score(state, request, driver).await?;
59 if let Some(winner) = classification.argmax(false)? {
61 record_decision_source(state, self.source);
62 record_routing_decision(self.source, &winner.target);
63 }
64 Ok((classification, served))
65 }
66}
67
68pub struct LlmFallback {
70 pub judge_target: ModelId,
73 pub config: TaskClassifierConfig,
78}
79
80pub struct StageRouterConfig {
82 pub mode: PickerMode,
84 pub confidence_threshold: f64,
86 pub recent_window: Option<usize>,
89 pub handoff_notes: Option<HandoffNoteConfig>,
92 pub tier_prompts: TargetPrompts,
95 pub llm_fallback: Option<LlmFallback>,
99}
100
101impl StageRouterConfig {
102 pub fn new(mode: PickerMode, confidence_threshold: f64) -> Self {
105 Self {
106 mode,
107 confidence_threshold,
108 recent_window: None,
109 handoff_notes: None,
110 tier_prompts: TargetPrompts::default(),
111 llm_fallback: None,
112 }
113 }
114}
115
116pub struct StageRouter {
120 route: FallThrough<State>,
121}
122
123impl StageRouter {
124 pub fn new(capable: ModelId, efficient: ModelId, config: StageRouterConfig) -> Result<Self> {
130 Ok(Self {
131 route: build_route(capable, efficient, config)?,
132 })
133 }
134}
135
136#[async_trait]
137impl Algorithm for StageRouter {
138 fn name(&self) -> &str {
139 STAGE_ROUTER
140 }
141
142 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<Response> {
143 self.route.execute(driver, request).await
144 }
145}
146
147fn build_route(
149 capable: ModelId,
150 efficient: ModelId,
151 config: StageRouterConfig,
152) -> Result<FallThrough<State>> {
153 if !(0.0..=1.0).contains(&config.confidence_threshold) {
154 return Err(LibsyError::AlgorithmError {
155 message: format!(
156 "confidence_threshold must be between 0 and 1, got {}",
157 config.confidence_threshold
158 ),
159 });
160 }
161 let targets = StageTargets::new(capable.clone(), efficient.clone());
164 let fall_open = targets.name(config.mode.default_tier()).to_string();
167
168 let mut classifier = StageClassifier::new(targets, config.mode, config.confidence_threshold);
169 if let Some(notes) = config.handoff_notes {
170 classifier = classifier.with_handoff_notes(notes);
171 }
172 let signals = ToolSignalProcessor {
173 recent_window: config.recent_window.unwrap_or(DEFAULT_RECENT_WINDOW),
174 };
175 let target_set = vec![capable.clone(), efficient.clone()];
176 let mut router = FallThrough::<State>::new_with_state(target_set)
177 .with_name(STAGE_ROUTER)
178 .with_processor(Arc::new(signals))
179 .with_classifier(Arc::new(classifier));
180 if let Some(fallback) = config.llm_fallback {
181 router = router.with_classifier(Arc::new(SourceStamp {
184 inner: Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
185 judge_target: fallback.judge_target,
186 efficient_target: efficient,
187 capable_target: capable,
188 config: fallback.config,
189 })?),
190 source: DecisionSource::LlmClassifier,
191 }));
192 }
193 router = router.with_classifier(Arc::new(SourceStamp {
196 inner: Arc::new(DefaultTarget::new(fall_open)),
197 source: DecisionSource::FallOpen,
198 }));
199 router = router.with_processor(Arc::new(SystemPromptProcessor::new(config.tier_prompts)));
203 Ok(router)
204}
205
206#[cfg(test)]
207mod tests {
208 use std::sync::Arc;
209
210 use async_trait::async_trait;
211 use parking_lot::Mutex;
212 use serde_json::json;
213 use switchyard_protocol::{
214 ContentBlock, LlmRequest, Message, Role, ToolCall, ToolResult, WireFormat,
215 };
216
217 use super::*;
218 use crate::algorithms::util::stage::DECISION_SOURCE_KEY;
219 use crate::core::classifier::Score;
220 use crate::core::state::StateValue;
221 use crate::core::testing::{Serve, reply, test_drive};
222 use switchyard_protocol::{Decision, Metadata, Response};
223
224 struct Fixed(&'static str);
226
227 #[async_trait]
228 impl Classifier<State> for Fixed {
229 async fn score(
230 &self,
231 _state: &mut State,
232 _request: &mut Request,
233 _driver: Option<&Driver>,
234 ) -> Result<(Classification, Option<Response>)> {
235 Ok((
236 Classification::Scores(vec![Score {
237 target: ModelId::from(self.0),
238 confidence: 1.0,
239 }]),
240 None,
241 ))
242 }
243 }
244
245 struct Abstains;
247
248 #[async_trait]
249 impl Classifier<State> for Abstains {
250 async fn score(
251 &self,
252 _state: &mut State,
253 _request: &mut Request,
254 _driver: Option<&Driver>,
255 ) -> Result<(Classification, Option<Response>)> {
256 Ok((Classification::Ambiguous(vec![]), None))
257 }
258 }
259
260 async fn stamped(inner: Arc<dyn Classifier<State>>) -> Result<Option<String>> {
261 let stamp = SourceStamp {
262 inner,
263 source: DecisionSource::LlmClassifier,
264 };
265 let mut state = State::default();
266 stamp
267 .score(&mut state, &mut Request::default(), None)
268 .await?;
269 Ok(match state.extra.get(DECISION_SOURCE_KEY) {
270 Some(StateValue::String(source)) => Some(source.clone()),
271 _ => None,
272 })
273 }
274
275 #[tokio::test]
276 async fn a_deciding_classifier_is_credited_with_the_turn() -> Result<()> {
277 assert_eq!(
278 stamped(Arc::new(Fixed("strong"))).await?.as_deref(),
279 Some("llm-classifier")
280 );
281 Ok(())
282 }
283
284 #[tokio::test]
285 async fn an_abstaining_classifier_claims_nothing() -> Result<()> {
286 assert_eq!(stamped(Arc::new(Abstains)).await?, None);
288 Ok(())
289 }
290
291 fn config() -> StageRouterConfig {
292 StageRouterConfig::new(PickerMode::EfficientFirst, 0.5)
293 }
294
295 #[test]
296 fn rejects_an_out_of_range_confidence_threshold() {
297 let mut config = config();
298 config.confidence_threshold = 1.5;
299 assert!(matches!(
300 StageRouter::new(ModelId::from("strong"), ModelId::from("weak"), config),
301 Err(LibsyError::AlgorithmError { .. })
302 ));
303 }
304
305 #[test]
306 fn rejects_an_out_of_range_judge_threshold() {
307 let mut config = config();
308 config.llm_fallback = Some(LlmFallback {
309 judge_target: ModelId::from("judge"),
310 config: TaskClassifierConfig {
311 base_threshold: -0.1,
312 ..Default::default()
313 },
314 });
315 assert!(matches!(
316 StageRouter::new(ModelId::from("strong"), ModelId::from("weak"), config),
317 Err(LibsyError::AlgorithmError { .. })
318 ));
319 }
320
321 #[test]
322 fn builds_over_both_tiers() -> Result<()> {
323 let router = StageRouter::new(ModelId::from("strong"), ModelId::from("weak"), config())?;
324 assert_eq!(router.name(), STAGE_ROUTER);
325 Ok(())
326 }
327
328 const ESCALATION: &str = "the previous model was stalling; pick up the diagnosis";
331 const JUDGE: &str = "judge";
332
333 #[derive(Clone, Debug)]
334 struct Call {
335 target: String,
336 messages: Vec<String>,
337 is_answer_call: bool,
338 }
339
340 #[derive(Default)]
342 struct Recorder {
343 calls: Mutex<Vec<Call>>,
344 judge_p_solve: Mutex<f64>,
345 }
346
347 impl Recorder {
348 fn routed(&self) -> Vec<Call> {
349 self.calls
350 .lock()
351 .iter()
352 .filter(|call| call.is_answer_call)
353 .cloned()
354 .collect()
355 }
356
357 fn serve(self: &Arc<Self>) -> impl Serve {
360 let recorder = Arc::clone(self);
361 move |decision: Decision, request: Request| {
362 let recorder = Arc::clone(&recorder);
363 async move {
364 let target = decision.selected_model_id().to_string();
365 recorder.calls.lock().push(Call {
366 target: target.clone(),
367 messages: request
368 .llm_request
369 .messages
370 .iter()
371 .filter_map(|message| message.text_content("|"))
372 .collect(),
373 is_answer_call: decision.is_answer_call(),
374 });
375 let completion = if target == JUDGE {
376 let p_solve = *recorder.judge_p_solve.lock();
377 format!(
378 r#"{{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":{p_solve}}}"#
379 )
380 } else {
381 target
382 };
383 Ok(reply(completion))
384 }
385 }
386 }
387 }
388
389 fn recording_router(config: StageRouterConfig) -> Result<Arc<StageRouter>> {
390 Ok(Arc::new(StageRouter::new(
391 ModelId::from("strong"),
392 ModelId::from("weak"),
393 config,
394 )?))
395 }
396
397 fn config_with_notes() -> StageRouterConfig {
398 let mut c = config();
399 c.handoff_notes = Some(HandoffNoteConfig::new(ESCALATION, None, true));
400 c
401 }
402
403 fn config_with_judge(recorder: &Arc<Recorder>, p_solve: f64) -> StageRouterConfig {
404 *recorder.judge_p_solve.lock() = p_solve;
405 let mut c = config();
406 c.llm_fallback = Some(LlmFallback {
407 judge_target: ModelId::from(JUDGE),
408 config: TaskClassifierConfig {
409 base_threshold: 0.5,
410 recent_turn_window: Some(3),
411 ..Default::default()
412 },
413 });
414 c
415 }
416
417 fn turn_request(failed: bool) -> Request {
418 let content = if failed {
419 "fatal runtime error: out of memory"
420 } else {
421 "ok"
422 };
423 Request {
424 llm_request: LlmRequest {
425 model: Some("auto".to_string()),
426 messages: vec![
427 Message::text(Role::User, "fix the build"),
428 Message {
429 role: Role::Assistant,
430 content: vec![ContentBlock::ToolCall(ToolCall {
431 id: "call_1".to_string(),
432 name: "Bash".to_string(),
433 arguments: json!({"command": "cargo test"}),
434 })],
435 },
436 Message {
437 role: Role::Tool,
438 content: vec![ContentBlock::ToolResult(ToolResult {
439 tool_call_id: "call_1".to_string(),
440 content: vec![ContentBlock::Text {
441 text: content.to_string(),
442 }],
443 is_error: Some(failed),
444 })],
445 },
446 ],
447 ..LlmRequest::default()
448 },
449 raw_request: Some(json!({
450 "model": "auto",
451 "messages": [
452 {"role": "user", "content": "fix the build"},
453 {"role": "assistant", "tool_calls": [{"id": "call_1", "type": "function",
454 "function": {"name": "Bash", "arguments": "{\"command\": \"cargo test\"}"}}]},
455 {"role": "tool", "tool_call_id": "call_1", "content": content},
456 ],
457 })),
458 metadata: Some(Metadata {
459 wire_format: Some(WireFormat::OpenAiChat),
460 session_id: Some("session-1".to_string()),
461 ..Default::default()
462 }),
463 }
464 }
465
466 #[tokio::test]
467 async fn a_signal_driven_escalation_hands_the_note_to_the_model() -> Result<()> {
468 let recorder = Arc::new(Recorder::default());
469 let router = recording_router(config_with_notes())?;
470
471 test_drive(router.clone(), turn_request(false), recorder.serve()).await?;
472 test_drive(router.clone(), turn_request(true), recorder.serve()).await?;
473
474 let calls = recorder.routed();
475 assert_eq!(calls[0].target, "weak");
476 assert_eq!(calls[1].target, "strong");
477 assert!(
478 !calls[0].messages.iter().any(|t| t.contains(ESCALATION)),
479 "steady-state turn should carry no note: {:?}",
480 calls[0].messages
481 );
482 assert!(
483 calls[1]
484 .messages
485 .last()
486 .is_some_and(|t| t.ends_with(ESCALATION)),
487 "escalating turn should carry the note last: {:?}",
488 calls[1].messages
489 );
490 Ok(())
491 }
492
493 #[tokio::test]
494 async fn the_judge_decides_a_turn_the_signals_leave_undecided() -> Result<()> {
495 let recorder = Arc::new(Recorder::default());
496 let router = recording_router(config_with_judge(&recorder, 0.1))?;
497
498 let (trace, _) = test_drive(router.clone(), turn_request(false), recorder.serve()).await?;
499
500 let calls = recorder.calls.lock();
501 assert!(
502 calls
503 .iter()
504 .any(|call| call.target == JUDGE && !call.is_answer_call),
505 "the judge should be recorded as a routing side call"
506 );
507 assert!(
508 calls
509 .iter()
510 .any(|call| call.target == "strong" && call.is_answer_call),
511 "the selected target should be recorded as an answer call"
512 );
513 drop(calls);
514 assert!(
515 trace
516 .last()
517 .and_then(|decision| decision.reasoning())
518 .is_some_and(|reasoning| reasoning.contains("routing tier: strong"))
519 );
520 Ok(())
521 }
522
523 #[tokio::test]
524 async fn a_decisive_signal_never_reaches_the_judge() -> Result<()> {
525 let recorder = Arc::new(Recorder::default());
526 let router = recording_router(config_with_judge(&recorder, 0.9))?;
527
528 test_drive(router.clone(), turn_request(true), recorder.serve()).await?;
529
530 assert!(
531 !recorder.calls.lock().iter().any(|c| c.target == JUDGE),
532 "a resolved turn should not pay for a judge call"
533 );
534 assert_eq!(recorder.routed()[0].target, "strong");
535 Ok(())
536 }
537
538 #[tokio::test]
539 async fn the_judges_verdict_is_not_pinned_to_the_session() -> Result<()> {
540 let recorder = Arc::new(Recorder::default());
541 let router = recording_router(config_with_judge(&recorder, 0.1))?;
542
543 test_drive(router.clone(), turn_request(false), recorder.serve()).await?;
544 *recorder.judge_p_solve.lock() = 0.9;
545 test_drive(router.clone(), turn_request(false), recorder.serve()).await?;
546
547 let routed = recorder.routed();
548 assert_eq!(routed[0].target, "strong");
549 assert_eq!(routed[1].target, "weak");
550 assert_eq!(
551 recorder
552 .calls
553 .lock()
554 .iter()
555 .filter(|c| c.target == JUDGE)
556 .count(),
557 2,
558 "each undecided turn is its own question"
559 );
560 Ok(())
561 }
562
563 #[tokio::test]
564 async fn a_judge_that_cannot_tell_lands_on_the_picker_default() -> Result<()> {
565 let recorder = Arc::new(Recorder::default());
566 let router = recording_router(config_with_judge(&recorder, 42.0))?;
567
568 test_drive(router.clone(), turn_request(false), recorder.serve()).await?;
569
570 assert_eq!(recorder.routed()[0].target, "weak");
571 Ok(())
572 }
573
574 #[tokio::test]
575 async fn the_judge_reads_the_window_it_was_configured_with() -> Result<()> {
576 let recorder = Arc::new(Recorder::default());
577 let router = recording_router(config_with_judge(&recorder, 0.9))?;
578
579 test_drive(router.clone(), turn_request(false), recorder.serve()).await?;
580
581 let judged = recorder
582 .calls
583 .lock()
584 .iter()
585 .find(|c| c.target == JUDGE)
586 .map(|c| c.messages.join("|"));
587 let Some(judged) = judged else {
588 panic!("the judge was never called");
589 };
590 assert!(
591 judged.contains("fix the build"),
592 "the judge should see the opening task: {judged}"
593 );
594 Ok(())
595 }
596}