1use std::collections::{BTreeMap, BTreeSet, HashSet};
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use serde::{Deserialize, Deserializer};
11use serde_json::Value;
12use switchyard_protocol::{ContentBlock, Decision, Message, Role};
13
14use super::fall_through::{DefaultTarget, FallThrough};
15use super::util::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS;
16use super::util::affinity::AffinityRouter;
17use super::util::classifier_contract::{ClassifierContract, ClassifierContractConfig};
18use super::util::escalation::{self, EscalationJudge, EscalationJudgeConfig, EscalationPolicy};
19use super::util::llm_judge::{
20 ClassifierInput, JsonSchemaDecoder, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig,
21 SerdeDecoder, StructuredJudge,
22};
23use super::util::target_selector::TargetSelectorPolicy;
24use crate::core::algorithm::{Algorithm, Driver, LlmTarget, LlmTargetSet, RoutedRequest};
25use crate::core::classifier::{Classification, Classifier, Score};
26use crate::core::state::{State, StateValue};
27use crate::{LibsyError, Result};
28use switchyard_protocol::{
29 AggLlmResponse, Context, LlmClientError, LlmResponse, Request, Response,
30};
31
32const PROMPT_TEMPLATE: &str = include_str!("../prompts/capability-classifier/prompt.md");
33const SCHEMA_TEMPLATE: &str = include_str!("../prompts/capability-classifier/schema.json");
34const ALGORITHM_NAME: &str = "llm_task_classifier";
36
37#[derive(Deserialize)]
38#[serde(deny_unknown_fields)]
39struct TaskClassifierVerdict {
40 crux: String,
41 primary_rule: String,
42 capability_boundary: String,
43 p_solve: f64,
44}
45
46impl TaskClassifierVerdict {
47 fn is_valid(&self) -> bool {
49 (0.0..=1.0).contains(&self.p_solve)
50 && !self.crux.trim().is_empty()
51 && matches!(
52 (
53 self.primary_rule.as_str(),
54 self.capability_boundary.as_str()
55 ),
56 ("SUP-1" | "SUP-2" | "SUP-3" | "SUP-4" | "SUP-5", "supported")
57 | ("UNC-1" | "UNC-2", "uncertain")
58 | ("LIM-1" | "LIM-2", "unsupported")
59 | ("none", "unmatched")
60 )
61 }
62
63 fn boundary_steps(&self) -> Option<u8> {
65 match self.capability_boundary.as_str() {
66 "supported" => Some(0),
67 "uncertain" | "unmatched" => Some(1),
68 "unsupported" => Some(2),
69 _ => None,
70 }
71 }
72}
73
74fn trim_messages(messages: &[Message], recent_turn_window: usize) -> Vec<Message> {
84 let is_instruction = |message: &Message| matches!(message.role, Role::System | Role::Developer);
85 let mut kept: Vec<&Message> = messages.iter().filter(|m| is_instruction(m)).collect();
86 let Some(task) = messages.iter().position(|m| m.role == Role::User) else {
87 return kept.into_iter().cloned().collect();
88 };
89 kept.push(&messages[task]);
90
91 let tail: Vec<&Message> = messages[task + 1..]
92 .iter()
93 .filter(|m| !is_instruction(m))
94 .collect();
95 kept.extend(&tail[window_start(&tail, recent_turn_window)..]);
96 kept.into_iter().cloned().collect()
97}
98
99fn window_start(tail: &[&Message], recent_turn_window: usize) -> usize {
112 let counted = tail.len().saturating_sub(recent_turn_window);
113 if counted == tail.len() {
115 return counted;
116 }
117 let mut unpaired: HashSet<&str> = HashSet::new();
118 for (start, message) in tail.iter().enumerate().rev() {
119 for block in message.content.iter().rev() {
122 match block {
123 ContentBlock::ToolResult(result) => {
124 unpaired.insert(result.tool_call_id.as_str());
125 }
126 ContentBlock::ToolCall(call) => {
127 unpaired.remove(call.id.as_str());
128 }
129 _ => {}
130 }
131 }
132 if start <= counted && unpaired.is_empty() {
133 return start;
134 }
135 }
136 counted
137}
138
139fn task_messages(messages: &[Message]) -> Vec<Message> {
141 let mut user_messages = messages.iter().filter(|message| message.role == Role::User);
142 let Some(opening_task) = user_messages.next() else {
143 return Vec::new();
144 };
145 match user_messages.next_back() {
146 Some(latest_follow_up) => vec![opening_task.clone(), latest_follow_up.clone()],
147 None => vec![opening_task.clone()],
148 }
149}
150
151struct TaskInput {
153 recent_turn_window: Option<usize>,
154}
155
156impl ClassifierInput for TaskInput {
157 fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
158 match self.recent_turn_window {
161 Some(window) => trim_messages(&request.llm_request.messages, window),
162 None => task_messages(&request.llm_request.messages),
163 }
164 }
165}
166
167type CapabilityJudge = StructuredJudge<TaskInput, SerdeDecoder<TaskClassifierVerdict>>;
168
169struct TaskClassifierPolicy {
170 efficient_target: String,
171 capable_target: String,
172 base_threshold: f64,
173 threshold_step: f64,
174}
175
176impl TaskClassifierPolicy {
177 fn new(
178 efficient_target: impl Into<String>,
179 capable_target: impl Into<String>,
180 config: &TaskClassifierConfig,
181 ) -> Self {
182 Self {
183 efficient_target: efficient_target.into(),
184 capable_target: capable_target.into(),
185 base_threshold: config.base_threshold,
186 threshold_step: config.threshold_step,
187 }
188 }
189
190 fn threshold(&self, verdict: &TaskClassifierVerdict) -> Option<f64> {
192 Some(self.base_threshold + f64::from(verdict.boundary_steps()?) * self.threshold_step)
193 }
194}
195
196impl JudgePolicy for TaskClassifierPolicy {
197 type Verdict = TaskClassifierVerdict;
198
199 fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
200 let Some(verdict) = verdict.filter(|verdict| verdict.is_valid()) else {
203 return Classification::Ambiguous(vec![]);
204 };
205 let Some(threshold) = self.threshold(verdict) else {
208 return Classification::Ambiguous(vec![]);
209 };
210 let target = if verdict.p_solve >= threshold
211 || (threshold - verdict.p_solve).abs() <= f64::EPSILON
212 {
213 &self.efficient_target
214 } else {
215 &self.capable_target
216 };
217 Classification::Scores(vec![Score {
218 target: target.clone(),
219 confidence: 1.0,
220 }])
221 }
222}
223
224#[derive(Clone, Debug)]
225pub struct TaskClassifierConfig {
227 pub base_threshold: f64,
229 pub threshold_step: f64,
234 pub session_affinity: bool,
236 pub message_hash_fallback: bool,
238 pub recent_turn_window: Option<usize>,
245 pub contract: ClassifierContractConfig,
247 pub max_output_tokens: u64,
249}
250
251#[derive(Deserialize)]
253#[serde(deny_unknown_fields)]
254struct TaskClassifierConfigWire {
255 base_threshold: f64,
256 #[serde(default)]
257 threshold_step: f64,
258 #[serde(default)]
259 session_affinity: bool,
260 #[serde(default)]
261 message_hash_fallback: bool,
262 #[serde(default)]
263 recent_turn_window: Option<usize>,
264 #[serde(default)]
265 prompt: Option<String>,
266 #[serde(default = "default_judge_max_output_tokens")]
267 max_output_tokens: u64,
268}
269
270impl<'de> Deserialize<'de> for TaskClassifierConfig {
271 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
272 where
273 D: Deserializer<'de>,
274 {
275 let wire = TaskClassifierConfigWire::deserialize(deserializer)?;
276 let mut contract = ClassifierContractConfig::default();
277 if let Some(prompt) = wire.prompt {
278 contract = contract.with_prompt(prompt);
279 }
280 Ok(Self {
281 base_threshold: wire.base_threshold,
282 threshold_step: wire.threshold_step,
283 session_affinity: wire.session_affinity,
284 message_hash_fallback: wire.message_hash_fallback,
285 recent_turn_window: wire.recent_turn_window,
286 contract,
287 max_output_tokens: wire.max_output_tokens,
288 })
289 }
290}
291
292const fn default_judge_max_output_tokens() -> u64 {
293 DEFAULT_JUDGE_MAX_OUTPUT_TOKENS
294}
295
296impl Default for TaskClassifierConfig {
297 fn default() -> Self {
298 Self {
299 base_threshold: 0.0,
300 threshold_step: 0.0,
301 session_affinity: false,
302 message_hash_fallback: false,
303 recent_turn_window: None,
304 contract: ClassifierContractConfig::default(),
305 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
306 }
307 }
308}
309
310impl TaskClassifierConfig {
311 fn validate(&self) -> Result<()> {
313 if !(0.0..=1.0).contains(&self.base_threshold) {
314 return Err(LibsyError::AlgorithmError {
315 message: format!(
316 "base_threshold must be between 0 and 1, got {}",
317 self.base_threshold
318 ),
319 });
320 }
321 if !self.threshold_step.is_finite() || self.threshold_step < 0.0 {
322 return Err(LibsyError::AlgorithmError {
323 message: format!(
324 "threshold_step must be finite and greater than or equal to 0, got {}",
325 self.threshold_step
326 ),
327 });
328 }
329 let unsupported_threshold = self.base_threshold + 2.0 * self.threshold_step;
330 if unsupported_threshold > 1.0 && unsupported_threshold - 1.0 > f64::EPSILON {
331 return Err(LibsyError::AlgorithmError {
332 message: format!(
333 "base_threshold + 2 * threshold_step must be at most 1, got {unsupported_threshold}"
334 ),
335 });
336 }
337 if self.max_output_tokens == 0 {
338 return Err(LibsyError::AlgorithmError {
339 message: "max_output_tokens must be at least 1".to_string(),
340 });
341 }
342 if self.message_hash_fallback && !self.session_affinity {
343 return Err(LibsyError::AlgorithmError {
344 message: "message_hash_fallback requires session_affinity".to_string(),
345 });
346 }
347 Ok(())
348 }
349}
350
351#[derive(Clone, Debug)]
353pub enum CustomClassifierPolicy {
354 TargetSelector {
356 selector: String,
358 },
359}
360
361impl CustomClassifierPolicy {
362 pub fn target_selector(selector: impl Into<String>) -> Self {
364 Self::TargetSelector {
365 selector: selector.into(),
366 }
367 }
368}
369
370#[derive(Clone, Debug)]
372pub struct CustomClassifierConfig {
373 pub prompt: String,
375 pub response_schema: Value,
377 pub policy: CustomClassifierPolicy,
379 pub session_affinity: bool,
381 pub message_hash_fallback: bool,
383 pub recent_turn_window: Option<usize>,
385 pub max_output_tokens: u64,
387}
388
389impl CustomClassifierConfig {
390 pub fn new(
392 prompt: impl Into<String>,
393 response_schema: Value,
394 policy: CustomClassifierPolicy,
395 ) -> Self {
396 Self {
397 prompt: prompt.into(),
398 response_schema,
399 policy,
400 session_affinity: false,
401 message_hash_fallback: false,
402 recent_turn_window: None,
403 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
404 }
405 }
406
407 fn validate(&self) -> Result<()> {
408 if self.max_output_tokens == 0 {
409 return Err(LibsyError::AlgorithmError {
410 message: "max_output_tokens must be at least 1".to_string(),
411 });
412 }
413 if self.message_hash_fallback && !self.session_affinity {
414 return Err(LibsyError::AlgorithmError {
415 message: "message_hash_fallback requires session_affinity".to_string(),
416 });
417 }
418 Ok(())
419 }
420}
421
422enum CustomPolicyRuntime {
423 TargetSelector(TargetSelectorPolicy),
424}
425
426impl JudgePolicy for CustomPolicyRuntime {
427 type Verdict = Value;
428
429 fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
430 match self {
431 Self::TargetSelector(policy) => policy.to_classification(verdict),
432 }
433 }
434}
435
436struct TaskClassifier {
437 classifier: JudgeClassifier<CapabilityJudge, TaskClassifierPolicy>,
438 efficient_target: String,
439 capable_target: String,
440}
441
442const STREAK_KEY: &str = "escalation_streak";
446
447fn streak(state: &State) -> u32 {
448 match state.extra.get(STREAK_KEY) {
449 Some(StateValue::Count(n)) => *n,
450 _ => 0,
451 }
452}
453
454fn decisive(target: &str) -> Classification {
455 Classification::Scores(vec![Score {
456 target: target.to_string(),
457 confidence: 1.0,
458 }])
459}
460
461fn assistant_message(response: &AggLlmResponse) -> Message {
462 Message {
463 role: Role::Assistant,
464 content: response
465 .first_output()
466 .map(|output| output.content.clone())
467 .unwrap_or_default(),
468 }
469}
470
471struct EscalationClassifier {
475 judge: JudgeClassifier<EscalationJudge, EscalationPolicy>,
476 capable: LlmTarget,
477 efficient: LlmTarget,
478 confirmations: u32,
480}
481
482#[async_trait]
483impl Classifier<State> for EscalationClassifier {
484 fn routing_tier(&self, selected_model_id: &str) -> Option<&'static str> {
485 if self.capable.semantic_name == self.efficient.semantic_name {
486 None
487 } else if selected_model_id == self.capable.semantic_name {
488 Some("strong")
489 } else if selected_model_id == self.efficient.semantic_name {
490 Some("weak")
491 } else {
492 None
493 }
494 }
495
496 async fn score(
497 &self,
498 state: &mut State,
499 request: &mut Request,
500 driver: Option<&Driver>,
501 ) -> Result<(Classification, Option<Response>)> {
502 let Some(driver) = driver else {
503 return Err(LibsyError::AlgorithmError {
504 message: "escalation classifier requires a driver".into(),
505 });
506 };
507
508 if streak(state) >= self.confirmations {
510 return Ok((decisive(&self.capable.semantic_name), None));
511 }
512
513 let efficient_response = match driver
522 .call_llm(RoutedRequest {
523 request: request.clone(),
524 decision: Arc::new(Decision::new(
525 self.efficient.semantic_name.clone(),
526 Some("escalation classifier: efficient tier".into()),
527 true,
528 )),
529 ctx: Context::default(),
530 })
531 .await
532 {
533 Ok(r) => r,
534 Err(LibsyError::ClientCall {
535 source: LlmClientError::ContextWindowExceeded { .. },
536 ..
537 }) => return Ok((decisive(&self.capable.semantic_name), None)),
538 Err(e) => return Err(e),
539 };
540 let agg = efficient_response
541 .llm_response
542 .into_agg()
543 .await
544 .map_err(|e| LibsyError::AlgorithmError {
545 message: format!("failed to aggregate efficient response: {e}"),
546 })?;
547 let mut judge_request = request.clone();
549 judge_request
550 .llm_request
551 .messages
552 .push(assistant_message(&agg));
553 let efficient_response = Response {
554 llm_response: if request.llm_request.stream {
555 LlmResponse::Stream(agg.into_stream())
556 } else {
557 LlmResponse::Agg(agg)
558 },
559 metadata: efficient_response.metadata,
560 };
561
562 let (classification, _) = self
563 .judge
564 .score(state, &mut judge_request, Some(driver))
565 .await?;
566
567 let held = streak(state);
568 let best = classification.argmax(false)?;
569 let (escalate, pending) = match &best {
570 Some(score) if score.target == self.capable.semantic_name => (true, held + 1),
571 Some(_) => (false, 0),
572 None => (false, held),
573 };
574 state
575 .extra
576 .insert(STREAK_KEY.to_string(), StateValue::Count(pending));
577
578 if escalate && pending >= self.confirmations {
579 return Ok((decisive(&self.capable.semantic_name), None));
581 }
582
583 Ok((
584 decisive(&self.efficient.semantic_name),
585 Some(efficient_response),
586 ))
587 }
588}
589
590pub struct LlmTaskClassifier {
592 route: FallThrough<State>,
593 inner: Arc<dyn Classifier<State>>,
595}
596
597struct ClassifierRouteConfig {
598 default_target: String,
599 session_affinity: bool,
600 message_hash_fallback: bool,
601}
602
603#[non_exhaustive]
605pub enum LlmClassifierConfig {
606 Capability {
608 judge_target: LlmTarget,
610 efficient_target: LlmTarget,
612 capable_target: LlmTarget,
614 config: TaskClassifierConfig,
616 },
617 Escalation {
619 judge_target: LlmTarget,
621 efficient_target: LlmTarget,
623 capable_target: LlmTarget,
625 contract: ClassifierContractConfig,
627 config: EscalationJudgeConfig,
629 max_output_tokens: u64,
631 },
632 Custom {
634 judge_target: LlmTarget,
636 targets: Vec<(String, LlmTarget)>,
638 default_target: String,
640 config: CustomClassifierConfig,
642 },
643}
644
645impl LlmTaskClassifier {
646 pub fn new(config: LlmClassifierConfig) -> Result<Self> {
653 match config {
654 LlmClassifierConfig::Capability {
655 judge_target,
656 efficient_target,
657 capable_target,
658 config,
659 } => Self::build_capability(judge_target, efficient_target, capable_target, config),
660 LlmClassifierConfig::Escalation {
661 judge_target,
662 efficient_target,
663 capable_target,
664 contract,
665 config,
666 max_output_tokens,
667 } => Self::build_escalation(
668 judge_target,
669 efficient_target,
670 capable_target,
671 contract,
672 config,
673 max_output_tokens,
674 ),
675 LlmClassifierConfig::Custom {
676 judge_target,
677 targets,
678 default_target,
679 config,
680 } => Self::build_custom(judge_target, targets, default_target, config),
681 }
682 }
683
684 fn build_capability(
685 judge_target: LlmTarget,
686 efficient_target: LlmTarget,
687 capable_target: LlmTarget,
688 config: TaskClassifierConfig,
689 ) -> Result<Self> {
690 config.validate()?;
691 let contract = Self::load_capability_contract(&config.contract)?;
692 let targets = LlmTargetSet::new(vec![efficient_target.clone(), capable_target.clone()]);
693 let session_affinity = config.session_affinity;
694 let message_hash_fallback = config.message_hash_fallback;
695 let classifier = Arc::new(TaskClassifier {
696 classifier: JudgeClassifier::new(
697 StructuredJudge::new(
698 TaskInput {
699 recent_turn_window: config.recent_turn_window,
700 },
701 contract,
702 SerdeDecoder::new(),
703 JudgeRuntimeConfig::new(config.max_output_tokens)?,
704 ),
705 judge_target.clone(),
706 TaskClassifierPolicy::new(
707 efficient_target.semantic_name.clone(),
708 capable_target.semantic_name.clone(),
709 &config,
710 ),
711 ),
712 efficient_target: efficient_target.semantic_name.clone(),
713 capable_target: capable_target.semantic_name.clone(),
714 });
715 let inner: Arc<dyn Classifier<State>> = classifier.clone();
716 Self::from_classifier(
717 targets,
718 inner,
719 ClassifierRouteConfig {
720 default_target: classifier.capable_target.clone(),
721 session_affinity,
722 message_hash_fallback,
723 },
724 )
725 }
726
727 fn build_custom(
728 judge_target: LlmTarget,
729 targets: Vec<(String, LlmTarget)>,
730 default_target: String,
731 config: CustomClassifierConfig,
732 ) -> Result<Self> {
733 config.validate()?;
734 if targets.len() < 2 {
735 return Err(LibsyError::AlgorithmError {
736 message: "custom classifier requires at least two targets".to_string(),
737 });
738 }
739
740 let mut labels = BTreeSet::new();
741 let mut semantic_names = BTreeSet::new();
742 let mut target_map = BTreeMap::new();
743 let mut resolved_targets = Vec::with_capacity(targets.len());
744 for (label, target) in targets {
745 if label.trim().is_empty() || label.trim() != label {
746 return Err(LibsyError::AlgorithmError {
747 message: "custom classifier target labels must be non-empty and have no surrounding whitespace"
748 .to_string(),
749 });
750 }
751 if !labels.insert(label.clone()) {
752 return Err(LibsyError::AlgorithmError {
753 message: format!("custom classifier target label {label:?} is duplicated"),
754 });
755 }
756 if !semantic_names.insert(target.semantic_name.clone()) {
757 return Err(LibsyError::AlgorithmError {
758 message: format!(
759 "custom classifier resolved target {:?} is duplicated",
760 target.semantic_name
761 ),
762 });
763 }
764 target_map.insert(label, target.semantic_name.clone());
765 resolved_targets.push(target);
766 }
767 let default_semantic_name =
768 target_map
769 .get(&default_target)
770 .cloned()
771 .ok_or_else(|| LibsyError::AlgorithmError {
772 message: format!(
773 "default_target {default_target:?} must be one of the configured targets"
774 ),
775 })?;
776
777 let CustomClassifierConfig {
778 prompt,
779 response_schema,
780 policy,
781 session_affinity,
782 message_hash_fallback,
783 recent_turn_window,
784 max_output_tokens,
785 } = config;
786 let contract = ClassifierContract::from_inner_schema(&prompt, response_schema)?;
787 let policy = match policy {
788 CustomClassifierPolicy::TargetSelector { selector } => {
789 CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(
790 selector, target_map,
791 )?)
792 }
793 };
794 let classifier: Arc<dyn Classifier<State>> = Arc::new(JudgeClassifier::new(
795 StructuredJudge::new(
796 TaskInput { recent_turn_window },
797 contract,
798 JsonSchemaDecoder::new(),
799 JudgeRuntimeConfig::new(max_output_tokens)?,
800 ),
801 judge_target,
802 policy,
803 ));
804
805 Self::from_classifier(
806 LlmTargetSet::new(resolved_targets),
807 classifier,
808 ClassifierRouteConfig {
809 default_target: default_semantic_name,
810 session_affinity,
811 message_hash_fallback,
812 },
813 )
814 }
815
816 fn build_escalation(
817 judge_target: LlmTarget,
818 efficient_target: LlmTarget,
819 capable_target: LlmTarget,
820 contract_config: ClassifierContractConfig,
821 config: EscalationJudgeConfig,
822 max_output_tokens: u64,
823 ) -> Result<Self> {
824 let capable_name = capable_target.semantic_name.clone();
825 let efficient_name = efficient_target.semantic_name.clone();
826 let confirmations = config.confirmations;
827 let esc = Arc::new(EscalationClassifier {
828 judge: escalation::build_judge(
829 judge_target,
830 capable_name,
831 efficient_name,
832 &contract_config,
833 config,
834 max_output_tokens,
835 )?,
836 capable: capable_target.clone(),
837 efficient: efficient_target.clone(),
838 confirmations,
839 });
840 let inner: Arc<dyn Classifier<State>> = esc.clone();
841 let targets = LlmTargetSet::new(vec![capable_target, efficient_target]);
842 Ok(Self {
843 route: FallThrough::<State>::new_with_state(targets)
844 .with_name(ALGORITHM_NAME)
845 .with_classifier(esc),
846 inner,
847 })
848 }
849
850 fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
852 ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
853 }
854
855 fn from_classifier(
857 targets: LlmTargetSet,
858 inner: Arc<dyn Classifier<State>>,
859 config: ClassifierRouteConfig,
860 ) -> Result<Self> {
861 targets.get_target(&config.default_target)?;
862 if config.message_hash_fallback && !config.session_affinity {
863 return Err(LibsyError::AlgorithmError {
864 message: "message_hash_fallback requires session_affinity".to_string(),
865 });
866 }
867 let mut route = FallThrough::<State>::new_with_state(targets).with_name(ALGORITHM_NAME);
871 if config.session_affinity {
872 let affinity = if config.message_hash_fallback {
873 AffinityRouter::new().with_message_hash_fallback()
874 } else {
875 AffinityRouter::new()
876 };
877 let affinity = Arc::new(affinity);
879 route = route
880 .with_processor(affinity.clone())
881 .with_classifier(affinity);
882 }
883 let fallback = DefaultTarget::new(config.default_target);
884 Ok(Self {
885 route: route
886 .with_classifier(inner.clone())
887 .with_classifier(Arc::new(fallback)),
888 inner,
889 })
890 }
891}
892
893#[async_trait]
894impl Classifier<State> for TaskClassifier {
895 fn routing_tier(&self, selected_model_id: &str) -> Option<&'static str> {
896 if self.efficient_target == self.capable_target {
897 None
898 } else if selected_model_id == self.efficient_target {
899 Some("weak")
900 } else if selected_model_id == self.capable_target {
901 Some("strong")
902 } else {
903 None
904 }
905 }
906
907 async fn score(
908 &self,
909 state: &mut State,
910 request: &mut Request,
911 driver: Option<&Driver>,
912 ) -> Result<(Classification, Option<Response>)> {
913 self.classifier.score(state, request, driver).await
914 }
915}
916
917#[async_trait]
918impl Classifier<State> for LlmTaskClassifier {
919 fn routing_tier(&self, selected_model_id: &str) -> Option<&'static str> {
920 self.inner.routing_tier(selected_model_id)
921 }
922
923 async fn score(
924 &self,
925 state: &mut State,
926 request: &mut Request,
927 driver: Option<&Driver>,
928 ) -> Result<(Classification, Option<Response>)> {
929 self.inner.score(state, request, driver).await
930 }
931}
932
933#[async_trait]
934impl Algorithm for LlmTaskClassifier {
935 fn name(&self) -> &str {
936 "llm_task_classifier"
937 }
938
939 async fn create_run_task(
940 self: Arc<Self>,
941 ctx: Context,
942 driver: Driver,
943 request: Request,
944 ) -> Result<Response> {
945 self.route.execute(ctx, driver, request).await
946 }
947}
948
949#[cfg(test)]
950mod tests {
951 use std::sync::Arc;
952
953 use parking_lot::Mutex;
954 use serde_json::Value;
955
956 use super::*;
957 use switchyard_protocol::{
958 ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, ToolCall, ToolResult,
959 completion_text, text_request, text_response,
960 };
961
962 use crate::algorithms::util::llm_judge::Judge;
963 use crate::core::testing::{Serve, reply, test_drive};
964 use switchyard_protocol::{Context, LlmResponse, Response};
965
966 const TEST_THRESHOLD: f64 = 0.5;
967
968 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
969 TaskClassifierConfig {
970 base_threshold,
971 ..TaskClassifierConfig::default()
972 }
973 }
974
975 fn policy() -> TaskClassifierPolicy {
976 TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
977 }
978
979 fn verdict(
980 p_solve: f64,
981 capability_boundary: &str,
982 primary_rule: &str,
983 ) -> TaskClassifierVerdict {
984 TaskClassifierVerdict {
985 crux: "test crux".to_string(),
986 primary_rule: primary_rule.to_string(),
987 capability_boundary: capability_boundary.to_string(),
988 p_solve,
989 }
990 }
991
992 fn selected(
993 policy: &TaskClassifierPolicy,
994 verdict: Option<&TaskClassifierVerdict>,
995 ) -> Result<String> {
996 policy
997 .to_classification(verdict)
998 .argmax(false)?
999 .map(|score| score.target)
1000 .ok_or_else(|| LibsyError::AlgorithmError {
1001 message: "policy abstained".to_string(),
1002 })
1003 }
1004
1005 #[derive(Default)]
1008 struct Recorder {
1009 calls: Mutex<Vec<String>>,
1010 call_roles: Mutex<Vec<(String, bool)>>,
1011 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
1012 judge_system_prompts: Mutex<Vec<String>>,
1013 }
1014
1015 impl Recorder {
1016 fn calls(&self) -> Vec<String> {
1017 self.calls.lock().clone()
1018 }
1019
1020 fn call_roles(&self) -> Vec<(String, bool)> {
1021 self.call_roles.lock().clone()
1022 }
1023
1024 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
1025 self.judge_max_output_tokens.lock().clone()
1026 }
1027
1028 fn judge_system_prompts(&self) -> Vec<String> {
1029 self.judge_system_prompts.lock().clone()
1030 }
1031
1032 fn serve(self: &Arc<Self>) -> impl Serve {
1033 let recorder = Arc::clone(self);
1034 move |decision: Arc<Decision>, request: Request| {
1035 let recorder = Arc::clone(&recorder);
1036 async move {
1037 let model = decision.selected_model_id().to_string();
1038 recorder.calls.lock().push(model.clone());
1039 recorder
1040 .call_roles
1041 .lock()
1042 .push((model.clone(), decision.is_answer_call()));
1043 let completion = if model == "judge" {
1044 recorder
1045 .judge_max_output_tokens
1046 .lock()
1047 .push(request.llm_request.output.max_output_tokens);
1048 recorder.judge_system_prompts.lock().extend(
1049 request
1050 .llm_request
1051 .instructions
1052 .first()
1053 .and_then(|instruction| {
1054 instruction.content.iter().find_map(|b| {
1055 if let ContentBlock::Text { text } = b {
1056 Some(text.clone())
1057 } else {
1058 None
1059 }
1060 })
1061 }),
1062 );
1063 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1064 } else {
1065 format!("answer from {model}")
1066 };
1067 Ok(Response {
1068 llm_response: LlmResponse::Agg(text_response(None, completion)),
1069 metadata: request.metadata,
1070 })
1071 }
1072 }
1073 }
1074 }
1075
1076 fn unreachable_judge() -> impl Serve {
1078 |decision: Arc<Decision>, request: Request| async move {
1079 let model = decision.selected_model_id().to_string();
1080 if model == "judge" {
1081 return Err(LlmClientError::Timeout {
1082 source: Box::new(std::io::Error::other("judge unreachable")),
1083 });
1084 }
1085 Ok(Response {
1086 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1087 metadata: request.metadata,
1088 })
1089 }
1090 }
1091
1092 fn router() -> Result<Arc<LlmTaskClassifier>> {
1093 let target = |name: &str| LlmTarget {
1094 semantic_name: name.to_string(),
1095 };
1096 Ok(Arc::new(LlmTaskClassifier::new(
1097 LlmClassifierConfig::Capability {
1098 judge_target: target("judge"),
1099 efficient_target: target("efficient"),
1100 capable_target: target("capable"),
1101 config: test_config(TEST_THRESHOLD),
1102 },
1103 )?))
1104 }
1105
1106 fn classify_request() -> Request {
1107 Request {
1108 llm_request: text_request(Some("auto".to_string()), "classify this task"),
1109 raw_request: None,
1110 metadata: None,
1111 }
1112 }
1113
1114 fn classify_session_request() -> Request {
1115 Request {
1116 metadata: Some(Metadata {
1117 session_id: Some("session-1".to_string()),
1118 ..Metadata::default()
1119 }),
1120 ..classify_request()
1121 }
1122 }
1123
1124 fn classify_follow_up_request() -> Request {
1125 let mut request = classify_request();
1126 request
1127 .llm_request
1128 .messages
1129 .push(Message::text(Role::Assistant, "I will add the test."));
1130 request.llm_request.messages.push(Message::text(
1131 Role::User,
1132 "Now run the test suite and report the result.",
1133 ));
1134 request
1135 }
1136
1137 #[tokio::test]
1138 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1139 let router = router()?;
1140
1141 let (trace, response) = test_drive(
1142 router,
1143 Context::default(),
1144 classify_request(),
1145 unreachable_judge(),
1146 )
1147 .await?;
1148
1149 assert_eq!(trace.last().map(|d| d.selected_model_id()), Some("capable"));
1150 assert_eq!(
1151 response.llm_response.as_agg().map(completion_text),
1152 Some("answer from capable".to_string())
1153 );
1154 Ok(())
1155 }
1156
1157 #[tokio::test]
1158 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1159 let recorder = Arc::new(Recorder::default());
1160 let router = router()?;
1161 let request = classify_request;
1162
1163 test_drive(
1164 router.clone(),
1165 Context::default(),
1166 request(),
1167 recorder.serve(),
1168 )
1169 .await?;
1170 test_drive(
1171 router.clone(),
1172 Context::default(),
1173 request(),
1174 recorder.serve(),
1175 )
1176 .await?;
1177
1178 assert_eq!(
1179 recorder.calls(),
1180 vec!["judge", "efficient", "judge", "efficient"]
1181 );
1182 assert_eq!(
1183 recorder.call_roles(),
1184 vec![
1185 ("judge".to_string(), false),
1186 ("efficient".to_string(), true),
1187 ("judge".to_string(), false),
1188 ("efficient".to_string(), true),
1189 ]
1190 );
1191 Ok(())
1192 }
1193
1194 #[tokio::test]
1195 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1196 let recorder = Arc::new(Recorder::default());
1197 let target = |name: &str| LlmTarget {
1198 semantic_name: name.to_string(),
1199 };
1200 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1201 judge_target: target("judge"),
1202 efficient_target: target("efficient"),
1203 capable_target: target("capable"),
1204 config: TaskClassifierConfig {
1205 max_output_tokens: 512,
1206 ..test_config(TEST_THRESHOLD)
1207 },
1208 })?);
1209
1210 test_drive(
1211 router,
1212 Context::default(),
1213 classify_request(),
1214 recorder.serve(),
1215 )
1216 .await?;
1217
1218 assert_eq!(recorder.judge_max_output_tokens(), vec![Some(512)]);
1219 Ok(())
1220 }
1221
1222 #[tokio::test]
1223 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1224 let recorder = Arc::new(Recorder::default());
1225 let target = |name: &str| LlmTarget {
1226 semantic_name: name.to_string(),
1227 };
1228 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1229 judge_target: target("judge"),
1230 efficient_target: target("efficient"),
1231 capable_target: target("capable"),
1232 config: TaskClassifierConfig {
1233 contract: ClassifierContractConfig::default()
1234 .with_prompt("Custom capability rubric."),
1235 ..test_config(TEST_THRESHOLD)
1236 },
1237 })?);
1238
1239 test_drive(
1240 router,
1241 Context::default(),
1242 classify_request(),
1243 recorder.serve(),
1244 )
1245 .await?;
1246
1247 let prompts = recorder.judge_system_prompts();
1248 assert_eq!(prompts.len(), 1);
1249 assert_eq!(prompts[0], "Custom capability rubric.");
1250 Ok(())
1251 }
1252
1253 #[tokio::test]
1254 async fn classifier_config_enables_session_affinity() -> Result<()> {
1255 let recorder = Arc::new(Recorder::default());
1256 let target = |name: &str| LlmTarget {
1257 semantic_name: name.to_string(),
1258 };
1259 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1260 judge_target: target("judge"),
1261 efficient_target: target("efficient"),
1262 capable_target: target("capable"),
1263 config: TaskClassifierConfig {
1264 session_affinity: true,
1265 ..test_config(TEST_THRESHOLD)
1266 },
1267 })?);
1268
1269 let session_request = classify_session_request;
1270 test_drive(
1271 router.clone(),
1272 Context::default(),
1273 session_request(),
1274 recorder.serve(),
1275 )
1276 .await?;
1277 test_drive(
1278 router.clone(),
1279 Context::default(),
1280 session_request(),
1281 recorder.serve(),
1282 )
1283 .await?;
1284
1285 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1286 Ok(())
1287 }
1288
1289 #[tokio::test]
1290 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1291 let recorder = Arc::new(Recorder::default());
1292 let target = |name: &str| LlmTarget {
1293 semantic_name: name.to_string(),
1294 };
1295 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1296 judge_target: target("judge"),
1297 efficient_target: target("efficient"),
1298 capable_target: target("capable"),
1299 config: TaskClassifierConfig {
1300 session_affinity: true,
1301 message_hash_fallback: true,
1302 recent_turn_window: None,
1303 ..test_config(TEST_THRESHOLD)
1304 },
1305 })?);
1306
1307 test_drive(
1308 router.clone(),
1309 Context::default(),
1310 classify_request(),
1311 recorder.serve(),
1312 )
1313 .await?;
1314 test_drive(
1315 router.clone(),
1316 Context::default(),
1317 classify_follow_up_request(),
1318 recorder.serve(),
1319 )
1320 .await?;
1321
1322 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1323 Ok(())
1324 }
1325
1326 #[test]
1327 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1328 let policy = policy();
1329 let at_threshold = verdict(0.5, "supported", "SUP-1");
1330 let below_threshold = verdict(0.49, "supported", "SUP-1");
1331 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1332 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1333 Ok(())
1334 }
1335
1336 #[test]
1337 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1338 let borderline = verdict(0.5, "supported", "SUP-1");
1339 let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1340 let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1341 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1342 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1343 Ok(())
1344 }
1345
1346 #[test]
1347 fn classifier_config_rejects_unknown_fields() {
1348 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1349 "base_threshold": 0.5,
1350 "classifier_magic": true,
1351 }))
1352 .expect_err("unknown classifier fields must be rejected");
1353
1354 assert!(
1355 error
1356 .to_string()
1357 .contains("unknown field `classifier_magic`"),
1358 "{error}"
1359 );
1360 }
1361
1362 #[test]
1363 fn invalid_classifier_config_is_rejected() -> Result<()> {
1364 let target = |name: &str| LlmTarget {
1365 semantic_name: name.to_string(),
1366 };
1367 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1368 assert!(
1369 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1370 judge_target: target("judge"),
1371 efficient_target: target("e"),
1372 capable_target: target("c"),
1373 config: test_config(bad),
1374 })
1375 .is_err(),
1376 "base threshold {bad} should be rejected"
1377 );
1378 }
1379 for config in [
1380 TaskClassifierConfig {
1381 base_threshold: 0.5,
1382 threshold_step: -0.1,
1383 ..TaskClassifierConfig::default()
1384 },
1385 TaskClassifierConfig {
1386 base_threshold: 0.8,
1387 threshold_step: 0.11,
1388 ..TaskClassifierConfig::default()
1389 },
1390 TaskClassifierConfig {
1391 base_threshold: 0.5,
1392 message_hash_fallback: true,
1393 ..TaskClassifierConfig::default()
1394 },
1395 TaskClassifierConfig {
1396 base_threshold: 0.5,
1397 max_output_tokens: 0,
1398 ..TaskClassifierConfig::default()
1399 },
1400 ] {
1401 assert!(
1402 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1403 judge_target: target("judge"),
1404 efficient_target: target("e"),
1405 capable_target: target("c"),
1406 config,
1407 })
1408 .is_err()
1409 );
1410 }
1411 for base_threshold in [0.0, 1.0] {
1412 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1413 judge_target: target("judge"),
1414 efficient_target: target("e"),
1415 capable_target: target("c"),
1416 config: test_config(base_threshold),
1417 })?;
1418 }
1419 Ok(())
1420 }
1421
1422 #[test]
1423 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1424 let policy = policy();
1425 let inconsistent_rule = TaskClassifierVerdict {
1426 capability_boundary: "uncertain".to_string(),
1427 ..verdict(1.0, "supported", "SUP-1")
1428 };
1429 let empty_crux = TaskClassifierVerdict {
1430 crux: " ".to_string(),
1431 ..verdict(1.0, "supported", "SUP-1")
1432 };
1433 let unusable = [
1434 Some(verdict(1.1, "supported", "SUP-1")),
1435 Some(inconsistent_rule),
1436 Some(empty_crux),
1437 None,
1438 ];
1439 for verdict in unusable {
1440 let classification = policy.to_classification(verdict.as_ref());
1441 assert!(matches!(classification, Classification::Ambiguous(_)));
1442 assert!(classification.argmax(false)?.is_none());
1443 assert!(classification.argmax(true)?.is_none());
1444 }
1445 Ok(())
1446 }
1447
1448 #[test]
1449 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1450 let policy = TaskClassifierPolicy::new(
1451 "efficient",
1452 "capable",
1453 &TaskClassifierConfig {
1454 threshold_step: 0.1,
1455 ..test_config(0.4)
1456 },
1457 );
1458
1459 assert_eq!(
1460 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1461 "efficient"
1462 );
1463 assert_eq!(
1464 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1465 "capable"
1466 );
1467 assert_eq!(
1468 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1469 "efficient"
1470 );
1471 assert_eq!(
1472 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1473 "efficient"
1474 );
1475 assert_eq!(
1476 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1477 "capable"
1478 );
1479 assert_eq!(
1480 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1481 "efficient"
1482 );
1483 Ok(())
1484 }
1485
1486 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1489 Ok(StructuredJudge::new(
1490 TaskInput { recent_turn_window },
1491 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1492 SerdeDecoder::new(),
1493 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1494 ))
1495 }
1496
1497 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1498 let judge = capability_judge(Some(recent_turn_window))?;
1499 let request = Request {
1500 llm_request: LlmRequest {
1501 messages: vec![
1502 Message::text(Role::System, "client instructions"),
1503 Message::text(Role::User, "initial task"),
1504 Message::text(Role::Assistant, "old response"),
1505 Message::text(Role::User, "old follow-up"),
1506 Message::text(Role::Assistant, "recent 1"),
1507 Message::text(Role::User, "recent 2"),
1508 ],
1509 ..LlmRequest::default()
1510 },
1511 raw_request: None,
1512 metadata: None,
1513 };
1514 Ok(judge
1515 .build_request(&State::default(), &request)
1516 .llm_request
1517 .messages
1518 .iter()
1519 .filter_map(|message| message.text_content("\n"))
1520 .collect())
1521 }
1522
1523 #[test]
1524 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1525 let contents = judged_contents(2)?;
1527 assert!(contents.contains(&"client instructions".to_string()));
1528 assert!(contents.contains(&"initial task".to_string()));
1529 assert!(contents.contains(&"recent 1".to_string()));
1530 assert!(contents.contains(&"recent 2".to_string()));
1531 assert!(!contents.contains(&"old response".to_string()));
1532 Ok(())
1533 }
1534
1535 #[test]
1536 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1537 let contents = judged_contents(0)?;
1538 assert!(contents.contains(&"client instructions".to_string()));
1539 assert!(contents.contains(&"initial task".to_string()));
1540 assert!(!contents.contains(&"recent 2".to_string()));
1541 Ok(())
1542 }
1543
1544 fn tool_call(id: &str) -> Message {
1545 Message {
1546 role: Role::Assistant,
1547 content: vec![ContentBlock::ToolCall(ToolCall {
1548 id: id.to_string(),
1549 name: "search".to_string(),
1550 arguments: Value::Null,
1551 })],
1552 }
1553 }
1554
1555 fn tool_result(id: &str) -> Message {
1556 Message {
1557 role: Role::Tool,
1558 content: vec![ContentBlock::ToolResult(ToolResult {
1559 tool_call_id: id.to_string(),
1560 content: vec![ContentBlock::Text {
1561 text: "tool output".to_string(),
1562 }],
1563 is_error: None,
1564 })],
1565 }
1566 }
1567
1568 #[test]
1571 fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1572 let messages = vec![
1573 Message::text(Role::System, "client instructions"),
1574 Message::text(Role::User, "initial task"),
1575 Message::text(Role::Assistant, "old response"),
1576 tool_call("call-1"),
1577 tool_result("call-1"),
1578 Message::text(Role::Assistant, "recent 1"),
1579 Message::text(Role::User, "recent 2"),
1580 Message::text(Role::Assistant, "recent 3"),
1581 Message::text(Role::User, "recent 4"),
1582 ];
1583
1584 let kept = trim_messages(&messages, 5);
1586
1587 assert_eq!(
1588 kept,
1589 vec![
1590 Message::text(Role::System, "client instructions"),
1591 Message::text(Role::User, "initial task"),
1592 tool_call("call-1"),
1593 tool_result("call-1"),
1594 Message::text(Role::Assistant, "recent 1"),
1595 Message::text(Role::User, "recent 2"),
1596 Message::text(Role::Assistant, "recent 3"),
1597 Message::text(Role::User, "recent 4"),
1598 ]
1599 );
1600 }
1601
1602 #[test]
1605 fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1606 let messages = vec![
1607 Message::text(Role::System, "client instructions"),
1608 Message::text(Role::User, "initial task"),
1609 tool_call("x"),
1610 tool_result("x"),
1611 Message::text(Role::Assistant, "later"),
1612 tool_call("x"),
1613 tool_result("x"),
1614 ];
1615
1616 let kept = trim_messages(&messages, 4);
1618
1619 assert_eq!(
1620 kept,
1621 vec![
1622 Message::text(Role::System, "client instructions"),
1623 Message::text(Role::User, "initial task"),
1624 tool_call("x"),
1625 tool_result("x"),
1626 Message::text(Role::Assistant, "later"),
1627 tool_call("x"),
1628 tool_result("x"),
1629 ]
1630 );
1631 }
1632
1633 #[test]
1636 fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1637 let messages = vec![
1638 Message::text(Role::System, "client instructions"),
1639 tool_call("orphan"),
1640 Message::text(Role::User, "initial task"),
1641 Message::text(Role::Assistant, "old response"),
1642 tool_result("orphan"),
1643 Message::text(Role::Assistant, "recent 1"),
1644 Message::text(Role::User, "recent 2"),
1645 ];
1646
1647 let kept = trim_messages(&messages, 3);
1648
1649 assert_eq!(
1650 kept,
1651 vec![
1652 Message::text(Role::System, "client instructions"),
1653 Message::text(Role::User, "initial task"),
1654 tool_result("orphan"),
1655 Message::text(Role::Assistant, "recent 1"),
1656 Message::text(Role::User, "recent 2"),
1657 ]
1658 );
1659 }
1660
1661 #[test]
1662 fn capability_judge_builds_a_structured_request() -> Result<()> {
1663 let judge = capability_judge(None)?;
1664 let request = Request {
1665 llm_request: LlmRequest {
1666 model: Some("inbound".to_string()),
1667 messages: vec![
1668 Message::text(Role::System, "client instructions"),
1669 Message::text(Role::Developer, "client developer instructions"),
1670 Message::text(Role::User, "initial task"),
1671 Message::text(Role::Assistant, "old response"),
1672 Message::text(Role::User, "old follow-up"),
1673 Message::text(Role::Assistant, "recent 1"),
1674 Message::text(Role::User, "recent 2"),
1675 Message::text(Role::Assistant, "recent 3"),
1676 Message::text(Role::User, "recent 4"),
1677 Message::text(Role::Assistant, "recent 5"),
1678 ],
1679 ..LlmRequest::default()
1680 },
1681 raw_request: None,
1682 metadata: None,
1683 };
1684 let judge_request = judge.build_request(&State::default(), &request);
1685
1686 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1687 assert_eq!(judge_request.llm_request.instructions.len(), 1);
1688 assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
1689 assert_eq!(
1690 judge_request.llm_request.instructions[0].content,
1691 InstructionBlock {
1692 role: Role::System,
1693 content: Message::text(Role::System, judge.contract().system_prompt()).content,
1694 }
1695 .content,
1696 );
1697 assert_eq!(judge_request.llm_request.messages.len(), 2);
1698 let contents = judge_request
1699 .llm_request
1700 .messages
1701 .iter()
1702 .filter_map(|message| message.text_content("\n"))
1703 .collect::<Vec<_>>();
1704 assert!(contents.contains(&"recent 4".to_string()));
1705 assert!(contents.contains(&"initial task".to_string()));
1706 assert!(!contents.contains(&"recent 5".to_string()));
1707 assert!(!contents.contains(&"client instructions".to_string()));
1708 assert_eq!(
1709 judge_request.llm_request.output.response_format,
1710 Some(judge.contract().response_format().clone())
1711 );
1712 assert_eq!(
1713 judge_request.llm_request.output.max_output_tokens,
1714 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1715 );
1716 Ok(())
1717 }
1718
1719 fn sample_value(spec: &Value) -> Value {
1720 if let Some(first) = spec
1721 .get("enum")
1722 .and_then(Value::as_array)
1723 .and_then(|values| values.first())
1724 {
1725 return first.clone();
1726 }
1727 match spec.get("type").and_then(Value::as_str) {
1728 Some("number") => serde_json::json!(0.5),
1729 Some("boolean") => serde_json::json!(false),
1730 _ => serde_json::json!("sample"),
1731 }
1732 }
1733
1734 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1735 let properties = schema
1736 .pointer("/json_schema/schema/properties")
1737 .and_then(Value::as_object)
1738 .ok_or_else(|| LibsyError::AlgorithmError {
1739 message: "packaged schema declares no properties".to_string(),
1740 })?;
1741 Ok(Value::Object(
1742 properties
1743 .iter()
1744 .map(|(name, spec)| (name.clone(), sample_value(spec)))
1745 .collect(),
1746 )
1747 .to_string())
1748 }
1749
1750 #[test]
1753 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1754 let contract =
1755 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1756 let schema = contract.response_format();
1757 let reply = schema_shaped_verdict(schema)?;
1758 let judge: CapabilityJudge = StructuredJudge::new(
1759 TaskInput {
1760 recent_turn_window: None,
1761 },
1762 contract,
1763 SerdeDecoder::new(),
1764 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1765 );
1766
1767 let verdict = judge.parse(&text_response(None, reply))?;
1768
1769 assert!(verdict.is_valid());
1770 assert!((0.0..=1.0).contains(&verdict.p_solve));
1771 Ok(())
1772 }
1773
1774 #[test]
1775 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1776 let contract =
1777 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1778 let prompt = contract.system_prompt();
1779 let schema_name = contract
1780 .response_format()
1781 .pointer("/json_schema/name")
1782 .and_then(Value::as_str)
1783 .ok_or_else(|| LibsyError::AlgorithmError {
1784 message: "packaged response schema has no name".to_string(),
1785 })?;
1786 assert_eq!(schema_name, "CapabilityClassifierDecision");
1787 assert!(prompt.contains("SUP-1 [supported]"));
1788 assert!(prompt.contains("SUP-5 [supported]"));
1789 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1790 assert!(!prompt.contains("\"type\": \"object\""));
1791 assert!(!prompt.contains("\"json_schema\""));
1792 assert!(!prompt.contains(schema_name));
1793 let rule_values = contract
1794 .response_format()
1795 .pointer("/json_schema/schema/properties/primary_rule/enum")
1796 .and_then(Value::as_array)
1797 .ok_or_else(|| LibsyError::AlgorithmError {
1798 message: "rendered response schema has no primary rule enum".to_string(),
1799 })?;
1800 assert!(
1801 rule_values
1802 .iter()
1803 .any(|value| value.as_str() == Some("SUP-1"))
1804 );
1805 assert!(
1806 rule_values
1807 .iter()
1808 .any(|value| value.as_str() == Some("none"))
1809 );
1810 Ok(())
1811 }
1812
1813 use std::collections::VecDeque;
1816
1817 use switchyard_protocol::Decision;
1818
1819 struct Queue(Mutex<VecDeque<String>>);
1821
1822 impl Queue {
1823 fn new(replies: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
1824 Arc::new(Self(Mutex::new(
1825 replies.into_iter().map(String::from).collect(),
1826 )))
1827 }
1828
1829 fn take(&self) -> String {
1830 self.0
1831 .lock()
1832 .pop_front()
1833 .unwrap_or_else(|| "unexpected call".to_string())
1834 }
1835 }
1836
1837 fn queued(model: Arc<Queue>, judge: Arc<Queue>) -> impl Serve {
1840 move |decision: Arc<Decision>, request: Request| {
1841 let queue = if decision.selected_model_id() == "judge" {
1842 Arc::clone(&judge)
1843 } else {
1844 Arc::clone(&model)
1845 };
1846 async move {
1847 Ok(Response {
1848 llm_response: LlmResponse::Agg(text_response(None, queue.take())),
1849 metadata: request.metadata,
1850 })
1851 }
1852 }
1853 }
1854
1855 fn escalation_router() -> Result<Arc<LlmTaskClassifier>> {
1857 let target = |name: &str| LlmTarget {
1858 semantic_name: name.to_string(),
1859 };
1860 Ok(Arc::new(LlmTaskClassifier::new(
1861 LlmClassifierConfig::Escalation {
1862 judge_target: target("judge"),
1863 efficient_target: target("efficient"),
1864 capable_target: target("capable"),
1865 contract: ClassifierContractConfig::default(),
1866 config: EscalationJudgeConfig {
1867 confirmations: 1,
1868 ..EscalationJudgeConfig::default()
1869 },
1870 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1871 },
1872 )?))
1873 }
1874
1875 #[tokio::test]
1876 async fn escalation_router_serves_efficient_when_judge_declines() -> Result<()> {
1877 let judge = Queue::new([r#"{"escalate":false,"reason":"progressing"}"#]);
1879 let model = Queue::new(["efficient answer"]);
1880 let router = escalation_router()?;
1881
1882 let (trace, response) = test_drive(
1883 router,
1884 Context::default(),
1885 classify_request(),
1886 queued(model, judge),
1887 )
1888 .await?;
1889
1890 assert_eq!(
1892 trace.last().map(|d| d.selected_model_id()),
1893 Some("efficient")
1894 );
1895 assert!(
1896 trace
1897 .last()
1898 .and_then(|decision| decision.reasoning())
1899 .is_some_and(|reasoning| reasoning.contains("routing tier: weak"))
1900 );
1901 assert_eq!(
1902 response.llm_response.as_agg().map(completion_text),
1903 Some("efficient answer".to_string())
1904 );
1905 Ok(())
1906 }
1907
1908 #[tokio::test]
1909 async fn escalation_config_overrides_the_packaged_prompt() -> Result<()> {
1910 let recorder = Arc::new(Recorder::default());
1911 let target = |name: &str| LlmTarget {
1912 semantic_name: name.to_string(),
1913 };
1914 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1915 judge_target: target("judge"),
1916 efficient_target: target("efficient"),
1917 capable_target: target("capable"),
1918 contract: ClassifierContractConfig::default().with_prompt("Custom trajectory rubric."),
1919 config: EscalationJudgeConfig {
1920 confirmations: 1,
1921 ..EscalationJudgeConfig::default()
1922 },
1923 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1924 })?);
1925
1926 test_drive(
1927 router,
1928 Context::default(),
1929 classify_request(),
1930 recorder.serve(),
1931 )
1932 .await?;
1933
1934 let prompts = recorder.judge_system_prompts();
1935 assert_eq!(prompts.len(), 1);
1936 assert_eq!(prompts[0], "Custom trajectory rubric.");
1937 Ok(())
1938 }
1939
1940 #[tokio::test]
1941 async fn escalation_router_upgrades_to_capable_when_judge_escalates() -> Result<()> {
1942 let judge = Queue::new([r#"{"escalate":true,"reason":"stuck in a loop"}"#]);
1944 let model = Queue::new(["efficient draft", "capable answer"]);
1946 let router = escalation_router()?;
1947
1948 let (trace, response) = test_drive(
1949 router,
1950 Context::default(),
1951 classify_request(),
1952 queued(model, judge),
1953 )
1954 .await?;
1955
1956 assert_eq!(trace.last().map(|d| d.selected_model_id()), Some("capable"));
1957 assert!(
1958 trace
1959 .last()
1960 .and_then(|decision| decision.reasoning())
1961 .is_some_and(|reasoning| reasoning.contains("routing tier: strong"))
1962 );
1963 assert_eq!(
1964 response.llm_response.as_agg().map(completion_text),
1965 Some("capable answer".to_string())
1966 );
1967 Ok(())
1968 }
1969
1970 #[tokio::test]
1971 async fn escalation_router_stays_capable_after_latch() -> Result<()> {
1972 let judge = Queue::new([r#"{"escalate":true,"reason":"stuck"}"#]);
1975 let model = Queue::new(["efficient draft", "capable t1", "capable t2"]);
1976 let router = escalation_router()?;
1977
1978 let session_request = classify_session_request();
1979 test_drive(
1980 router.clone(),
1981 Context::default(),
1982 session_request.clone(),
1983 queued(Arc::clone(&model), Arc::clone(&judge)),
1984 )
1985 .await?;
1986 let (trace, _) = test_drive(
1987 router.clone(),
1988 Context::default(),
1989 session_request,
1990 queued(model, judge),
1991 )
1992 .await?;
1993
1994 assert_eq!(trace.last().map(|d| d.selected_model_id()), Some("capable"));
1995 Ok(())
1996 }
1997
1998 #[tokio::test]
1999 async fn escalation_classifier_falls_back_to_capable_when_efficient_overflows() -> Result<()> {
2000 let router = escalation_router()?;
2004
2005 let serve = |decision: Arc<Decision>, _request: Request| async move {
2007 match decision.selected_model_id() {
2008 "efficient" => Err(LlmClientError::ContextWindowExceeded {
2009 model: decision.selected_model_id().to_string(),
2010 message: "prompt is too long".to_string(),
2011 }),
2012 "judge" => panic!("the judge must not be consulted when efficient overflows"),
2013 _ => Ok(reply("capable answer")),
2014 }
2015 };
2016
2017 let (trace, response) =
2018 test_drive(router, Context::default(), classify_request(), serve).await?;
2019
2020 assert_eq!(trace.last().map(|d| d.selected_model_id()), Some("capable"));
2021 assert_eq!(
2022 response.llm_response.as_agg().map(completion_text),
2023 Some("capable answer".to_string())
2024 );
2025 Ok(())
2026 }
2027}