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