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