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};
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: String,
169 capable_target: String,
170 base_threshold: f64,
171 threshold_step: f64,
172}
173
174impl TaskClassifierPolicy {
175 fn new(
176 efficient_target: impl Into<String>,
177 capable_target: impl Into<String>,
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: String,
437 capable_target: String,
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: &str) -> Classification {
453 Classification::Scores(vec![Score {
454 target: target.to_string(),
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: LlmTarget,
475 efficient: LlmTarget,
476 confirmations: u32,
478}
479
480#[async_trait]
481impl Classifier<State> for EscalationClassifier {
482 fn routing_tier(&self, selected_model_id: &str) -> Option<&'static str> {
483 if self.capable.semantic_name == self.efficient.semantic_name {
484 None
485 } else if selected_model_id == self.capable.semantic_name {
486 Some("strong")
487 } else if selected_model_id == self.efficient.semantic_name {
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.semantic_name), None));
509 }
510
511 let efficient_response = match driver
517 .call_model(
518 request.clone(),
519 Decision::new(
520 self.efficient.semantic_name.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.semantic_name), 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.semantic_name => (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.semantic_name), None));
575 }
576
577 Ok((
578 decisive(&self.efficient.semantic_name),
579 Some(efficient_response),
580 ))
581 }
582}
583
584pub struct LlmTaskClassifier {
586 route: FallThrough<State>,
587 inner: Arc<dyn Classifier<State>>,
589}
590
591struct ClassifierRouteConfig {
592 default_target: String,
593 session_affinity: bool,
594 message_hash_fallback: bool,
595}
596
597#[non_exhaustive]
599pub enum LlmClassifierConfig {
600 Capability {
602 judge_target: LlmTarget,
604 efficient_target: LlmTarget,
606 capable_target: LlmTarget,
608 config: TaskClassifierConfig,
610 },
611 Escalation {
613 judge_target: LlmTarget,
615 efficient_target: LlmTarget,
617 capable_target: LlmTarget,
619 contract: ClassifierContractConfig,
621 config: EscalationJudgeConfig,
623 max_output_tokens: u64,
625 },
626 Custom {
628 judge_target: LlmTarget,
630 targets: Vec<(String, LlmTarget)>,
632 default_target: String,
634 config: CustomClassifierConfig,
636 },
637}
638
639impl LlmTaskClassifier {
640 pub fn new(config: LlmClassifierConfig) -> Result<Self> {
647 match config {
648 LlmClassifierConfig::Capability {
649 judge_target,
650 efficient_target,
651 capable_target,
652 config,
653 } => Self::build_capability(judge_target, efficient_target, capable_target, config),
654 LlmClassifierConfig::Escalation {
655 judge_target,
656 efficient_target,
657 capable_target,
658 contract,
659 config,
660 max_output_tokens,
661 } => Self::build_escalation(
662 judge_target,
663 efficient_target,
664 capable_target,
665 contract,
666 config,
667 max_output_tokens,
668 ),
669 LlmClassifierConfig::Custom {
670 judge_target,
671 targets,
672 default_target,
673 config,
674 } => Self::build_custom(judge_target, targets, default_target, config),
675 }
676 }
677
678 fn build_capability(
679 judge_target: LlmTarget,
680 efficient_target: LlmTarget,
681 capable_target: LlmTarget,
682 config: TaskClassifierConfig,
683 ) -> Result<Self> {
684 config.validate()?;
685 let contract = Self::load_capability_contract(&config.contract)?;
686 let targets = LlmTargetSet::new(vec![efficient_target.clone(), capable_target.clone()]);
687 let session_affinity = config.session_affinity;
688 let message_hash_fallback = config.message_hash_fallback;
689 let classifier = Arc::new(TaskClassifier {
690 classifier: JudgeClassifier::new(
691 StructuredJudge::new(
692 TaskInput {
693 recent_turn_window: config.recent_turn_window,
694 },
695 contract,
696 SerdeDecoder::new(),
697 JudgeRuntimeConfig::new(config.max_output_tokens)?,
698 ),
699 judge_target.clone(),
700 TaskClassifierPolicy::new(
701 efficient_target.semantic_name.clone(),
702 capable_target.semantic_name.clone(),
703 &config,
704 ),
705 ),
706 efficient_target: efficient_target.semantic_name.clone(),
707 capable_target: capable_target.semantic_name.clone(),
708 });
709 let inner: Arc<dyn Classifier<State>> = classifier.clone();
710 Self::from_classifier(
711 targets,
712 inner,
713 ClassifierRouteConfig {
714 default_target: classifier.capable_target.clone(),
715 session_affinity,
716 message_hash_fallback,
717 },
718 )
719 }
720
721 fn build_custom(
722 judge_target: LlmTarget,
723 targets: Vec<(String, LlmTarget)>,
724 default_target: String,
725 config: CustomClassifierConfig,
726 ) -> Result<Self> {
727 config.validate()?;
728 if targets.len() < 2 {
729 return Err(LibsyError::AlgorithmError {
730 message: "custom classifier requires at least two targets".to_string(),
731 });
732 }
733
734 let mut labels = BTreeSet::new();
735 let mut semantic_names = BTreeSet::new();
736 let mut target_map = BTreeMap::new();
737 let mut resolved_targets = Vec::with_capacity(targets.len());
738 for (label, target) in targets {
739 if label.trim().is_empty() || label.trim() != label {
740 return Err(LibsyError::AlgorithmError {
741 message: "custom classifier target labels must be non-empty and have no surrounding whitespace"
742 .to_string(),
743 });
744 }
745 if !labels.insert(label.clone()) {
746 return Err(LibsyError::AlgorithmError {
747 message: format!("custom classifier target label {label:?} is duplicated"),
748 });
749 }
750 if !semantic_names.insert(target.semantic_name.clone()) {
751 return Err(LibsyError::AlgorithmError {
752 message: format!(
753 "custom classifier resolved target {:?} is duplicated",
754 target.semantic_name
755 ),
756 });
757 }
758 target_map.insert(label, target.semantic_name.clone());
759 resolved_targets.push(target);
760 }
761 let default_semantic_name =
762 target_map
763 .get(&default_target)
764 .cloned()
765 .ok_or_else(|| LibsyError::AlgorithmError {
766 message: format!(
767 "default_target {default_target:?} must be one of the configured targets"
768 ),
769 })?;
770
771 let CustomClassifierConfig {
772 prompt,
773 response_schema,
774 policy,
775 session_affinity,
776 message_hash_fallback,
777 recent_turn_window,
778 max_output_tokens,
779 } = config;
780 let contract = ClassifierContract::from_inner_schema(&prompt, response_schema)?;
781 let policy = match policy {
782 CustomClassifierPolicy::TargetSelector { selector } => {
783 CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(
784 selector, target_map,
785 )?)
786 }
787 };
788 let classifier: Arc<dyn Classifier<State>> = Arc::new(JudgeClassifier::new(
789 StructuredJudge::new(
790 TaskInput { recent_turn_window },
791 contract,
792 JsonSchemaDecoder::new(),
793 JudgeRuntimeConfig::new(max_output_tokens)?,
794 ),
795 judge_target,
796 policy,
797 ));
798
799 Self::from_classifier(
800 LlmTargetSet::new(resolved_targets),
801 classifier,
802 ClassifierRouteConfig {
803 default_target: default_semantic_name,
804 session_affinity,
805 message_hash_fallback,
806 },
807 )
808 }
809
810 fn build_escalation(
811 judge_target: LlmTarget,
812 efficient_target: LlmTarget,
813 capable_target: LlmTarget,
814 contract_config: ClassifierContractConfig,
815 config: EscalationJudgeConfig,
816 max_output_tokens: u64,
817 ) -> Result<Self> {
818 let capable_name = capable_target.semantic_name.clone();
819 let efficient_name = efficient_target.semantic_name.clone();
820 let confirmations = config.confirmations;
821 let esc = Arc::new(EscalationClassifier {
822 judge: escalation::build_judge(
823 judge_target,
824 capable_name,
825 efficient_name,
826 &contract_config,
827 config,
828 max_output_tokens,
829 )?,
830 capable: capable_target.clone(),
831 efficient: efficient_target.clone(),
832 confirmations,
833 });
834 let inner: Arc<dyn Classifier<State>> = esc.clone();
835 let targets = LlmTargetSet::new(vec![capable_target, efficient_target]);
836 Ok(Self {
837 route: FallThrough::<State>::new_with_state(targets)
838 .with_name(ALGORITHM_NAME)
839 .with_classifier(esc),
840 inner,
841 })
842 }
843
844 fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
846 ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
847 }
848
849 fn from_classifier(
851 targets: LlmTargetSet,
852 inner: Arc<dyn Classifier<State>>,
853 config: ClassifierRouteConfig,
854 ) -> Result<Self> {
855 targets.get_target(&config.default_target)?;
856 if config.message_hash_fallback && !config.session_affinity {
857 return Err(LibsyError::AlgorithmError {
858 message: "message_hash_fallback requires session_affinity".to_string(),
859 });
860 }
861 let mut route = FallThrough::<State>::new_with_state(targets).with_name(ALGORITHM_NAME);
865 if config.session_affinity {
866 let affinity = if config.message_hash_fallback {
867 AffinityRouter::new().with_message_hash_fallback()
868 } else {
869 AffinityRouter::new()
870 };
871 let affinity = Arc::new(affinity);
873 route = route
874 .with_processor(affinity.clone())
875 .with_classifier(affinity);
876 }
877 let fallback = DefaultTarget::new(config.default_target);
878 Ok(Self {
879 route: route
880 .with_classifier(inner.clone())
881 .with_classifier(Arc::new(fallback)),
882 inner,
883 })
884 }
885}
886
887#[async_trait]
888impl Classifier<State> for TaskClassifier {
889 fn routing_tier(&self, selected_model_id: &str) -> Option<&'static str> {
890 if self.efficient_target == self.capable_target {
891 None
892 } else if selected_model_id == self.efficient_target {
893 Some("weak")
894 } else if selected_model_id == self.capable_target {
895 Some("strong")
896 } else {
897 None
898 }
899 }
900
901 async fn score(
902 &self,
903 state: &mut State,
904 request: &mut Request,
905 driver: Option<&Driver>,
906 ) -> Result<(Classification, Option<Response>)> {
907 self.classifier.score(state, request, driver).await
908 }
909}
910
911#[async_trait]
912impl Classifier<State> for LlmTaskClassifier {
913 fn routing_tier(&self, selected_model_id: &str) -> Option<&'static str> {
914 self.inner.routing_tier(selected_model_id)
915 }
916
917 async fn score(
918 &self,
919 state: &mut State,
920 request: &mut Request,
921 driver: Option<&Driver>,
922 ) -> Result<(Classification, Option<Response>)> {
923 self.inner.score(state, request, driver).await
924 }
925}
926
927#[async_trait]
928impl Algorithm for LlmTaskClassifier {
929 fn name(&self) -> &str {
930 "llm_task_classifier"
931 }
932
933 async fn create_run_task(
934 self: Arc<Self>,
935 driver: Driver,
936 request: Request,
937 ) -> Result<Response> {
938 self.route.execute(driver, request).await
939 }
940}
941
942#[cfg(test)]
943mod tests {
944 use std::sync::Arc;
945
946 use parking_lot::Mutex;
947 use serde_json::Value;
948
949 use super::*;
950 use switchyard_protocol::{
951 ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, ToolCall, ToolResult,
952 completion_text, text_request, text_response,
953 };
954
955 use crate::algorithms::util::llm_judge::Judge;
956 use crate::core::testing::{Serve, reply, test_drive};
957 use switchyard_protocol::{LlmResponse, Response};
958
959 const TEST_THRESHOLD: f64 = 0.5;
960
961 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
962 TaskClassifierConfig {
963 base_threshold,
964 ..TaskClassifierConfig::default()
965 }
966 }
967
968 fn policy() -> TaskClassifierPolicy {
969 TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
970 }
971
972 fn verdict(
973 p_solve: f64,
974 capability_boundary: &str,
975 primary_rule: &str,
976 ) -> TaskClassifierVerdict {
977 TaskClassifierVerdict {
978 crux: "test crux".to_string(),
979 primary_rule: primary_rule.to_string(),
980 capability_boundary: capability_boundary.to_string(),
981 p_solve,
982 }
983 }
984
985 fn selected(
986 policy: &TaskClassifierPolicy,
987 verdict: Option<&TaskClassifierVerdict>,
988 ) -> Result<String> {
989 policy
990 .to_classification(verdict)
991 .argmax(false)?
992 .map(|score| score.target)
993 .ok_or_else(|| LibsyError::AlgorithmError {
994 message: "policy abstained".to_string(),
995 })
996 }
997
998 #[derive(Default)]
1001 struct Recorder {
1002 calls: Mutex<Vec<String>>,
1003 call_roles: Mutex<Vec<(String, bool)>>,
1004 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
1005 judge_system_prompts: Mutex<Vec<String>>,
1006 }
1007
1008 impl Recorder {
1009 fn calls(&self) -> Vec<String> {
1010 self.calls.lock().clone()
1011 }
1012
1013 fn call_roles(&self) -> Vec<(String, bool)> {
1014 self.call_roles.lock().clone()
1015 }
1016
1017 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
1018 self.judge_max_output_tokens.lock().clone()
1019 }
1020
1021 fn judge_system_prompts(&self) -> Vec<String> {
1022 self.judge_system_prompts.lock().clone()
1023 }
1024
1025 fn serve(self: &Arc<Self>) -> impl Serve {
1026 let recorder = Arc::clone(self);
1027 move |decision: Decision, request: Request| {
1028 let recorder = Arc::clone(&recorder);
1029 async move {
1030 let model = decision.selected_model_id().to_string();
1031 recorder.calls.lock().push(model.clone());
1032 recorder
1033 .call_roles
1034 .lock()
1035 .push((model.clone(), decision.is_answer_call()));
1036 let completion = if model == "judge" {
1037 recorder
1038 .judge_max_output_tokens
1039 .lock()
1040 .push(request.llm_request.output.max_output_tokens);
1041 recorder.judge_system_prompts.lock().extend(
1042 request
1043 .llm_request
1044 .instructions
1045 .first()
1046 .and_then(|instruction| {
1047 instruction.content.iter().find_map(|b| {
1048 if let ContentBlock::Text { text } = b {
1049 Some(text.clone())
1050 } else {
1051 None
1052 }
1053 })
1054 }),
1055 );
1056 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1057 } else {
1058 format!("answer from {model}")
1059 };
1060 Ok(Response {
1061 llm_response: LlmResponse::Agg(text_response(None, completion)),
1062 metadata: request.metadata,
1063 })
1064 }
1065 }
1066 }
1067 }
1068
1069 fn unreachable_judge() -> impl Serve {
1071 |decision: Decision, request: Request| async move {
1072 let model = decision.selected_model_id().to_string();
1073 if model == "judge" {
1074 return Err(LlmClientError::Timeout {
1075 source: Box::new(std::io::Error::other("judge unreachable")),
1076 });
1077 }
1078 Ok(Response {
1079 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1080 metadata: request.metadata,
1081 })
1082 }
1083 }
1084
1085 fn router() -> Result<Arc<LlmTaskClassifier>> {
1086 let target = |name: &str| LlmTarget {
1087 semantic_name: name.to_string(),
1088 };
1089 Ok(Arc::new(LlmTaskClassifier::new(
1090 LlmClassifierConfig::Capability {
1091 judge_target: target("judge"),
1092 efficient_target: target("efficient"),
1093 capable_target: target("capable"),
1094 config: test_config(TEST_THRESHOLD),
1095 },
1096 )?))
1097 }
1098
1099 fn classify_request() -> Request {
1100 Request {
1101 llm_request: text_request(Some("auto".to_string()), "classify this task"),
1102 raw_request: None,
1103 metadata: None,
1104 }
1105 }
1106
1107 fn classify_session_request() -> Request {
1108 Request {
1109 metadata: Some(Metadata {
1110 session_id: Some("session-1".to_string()),
1111 ..Metadata::default()
1112 }),
1113 ..classify_request()
1114 }
1115 }
1116
1117 fn classify_follow_up_request() -> Request {
1118 let mut request = classify_request();
1119 request
1120 .llm_request
1121 .messages
1122 .push(Message::text(Role::Assistant, "I will add the test."));
1123 request.llm_request.messages.push(Message::text(
1124 Role::User,
1125 "Now run the test suite and report the result.",
1126 ));
1127 request
1128 }
1129
1130 #[tokio::test]
1131 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1132 let router = router()?;
1133
1134 let (trace, response) = test_drive(router, classify_request(), unreachable_judge()).await?;
1135
1136 assert_eq!(trace.last().map(|d| d.selected_model_id()), Some("capable"));
1137 assert_eq!(
1138 response.llm_response.as_agg().map(completion_text),
1139 Some("answer from capable".to_string())
1140 );
1141 Ok(())
1142 }
1143
1144 #[tokio::test]
1145 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1146 let recorder = Arc::new(Recorder::default());
1147 let router = router()?;
1148 let request = classify_request;
1149
1150 test_drive(router.clone(), request(), recorder.serve()).await?;
1151 test_drive(router.clone(), request(), recorder.serve()).await?;
1152
1153 assert_eq!(
1154 recorder.calls(),
1155 vec!["judge", "efficient", "judge", "efficient"]
1156 );
1157 assert_eq!(
1158 recorder.call_roles(),
1159 vec![
1160 ("judge".to_string(), false),
1161 ("efficient".to_string(), true),
1162 ("judge".to_string(), false),
1163 ("efficient".to_string(), true),
1164 ]
1165 );
1166 Ok(())
1167 }
1168
1169 #[tokio::test]
1170 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1171 let recorder = Arc::new(Recorder::default());
1172 let target = |name: &str| LlmTarget {
1173 semantic_name: name.to_string(),
1174 };
1175 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1176 judge_target: target("judge"),
1177 efficient_target: target("efficient"),
1178 capable_target: target("capable"),
1179 config: TaskClassifierConfig {
1180 max_output_tokens: 512,
1181 ..test_config(TEST_THRESHOLD)
1182 },
1183 })?);
1184
1185 test_drive(router, classify_request(), recorder.serve()).await?;
1186
1187 assert_eq!(recorder.judge_max_output_tokens(), vec![Some(512)]);
1188 Ok(())
1189 }
1190
1191 #[tokio::test]
1192 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1193 let recorder = Arc::new(Recorder::default());
1194 let target = |name: &str| LlmTarget {
1195 semantic_name: name.to_string(),
1196 };
1197 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1198 judge_target: target("judge"),
1199 efficient_target: target("efficient"),
1200 capable_target: target("capable"),
1201 config: TaskClassifierConfig {
1202 contract: ClassifierContractConfig::default()
1203 .with_prompt("Custom capability rubric."),
1204 ..test_config(TEST_THRESHOLD)
1205 },
1206 })?);
1207
1208 test_drive(router, classify_request(), recorder.serve()).await?;
1209
1210 let prompts = recorder.judge_system_prompts();
1211 assert_eq!(prompts.len(), 1);
1212 assert_eq!(prompts[0], "Custom capability rubric.");
1213 Ok(())
1214 }
1215
1216 #[tokio::test]
1217 async fn classifier_config_enables_session_affinity() -> Result<()> {
1218 let recorder = Arc::new(Recorder::default());
1219 let target = |name: &str| LlmTarget {
1220 semantic_name: name.to_string(),
1221 };
1222 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1223 judge_target: target("judge"),
1224 efficient_target: target("efficient"),
1225 capable_target: target("capable"),
1226 config: TaskClassifierConfig {
1227 session_affinity: true,
1228 ..test_config(TEST_THRESHOLD)
1229 },
1230 })?);
1231
1232 let session_request = classify_session_request;
1233 test_drive(router.clone(), session_request(), recorder.serve()).await?;
1234 test_drive(router.clone(), session_request(), recorder.serve()).await?;
1235
1236 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1237 Ok(())
1238 }
1239
1240 #[tokio::test]
1241 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1242 let recorder = Arc::new(Recorder::default());
1243 let target = |name: &str| LlmTarget {
1244 semantic_name: name.to_string(),
1245 };
1246 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1247 judge_target: target("judge"),
1248 efficient_target: target("efficient"),
1249 capable_target: target("capable"),
1250 config: TaskClassifierConfig {
1251 session_affinity: true,
1252 message_hash_fallback: true,
1253 recent_turn_window: None,
1254 ..test_config(TEST_THRESHOLD)
1255 },
1256 })?);
1257
1258 test_drive(router.clone(), classify_request(), recorder.serve()).await?;
1259 test_drive(
1260 router.clone(),
1261 classify_follow_up_request(),
1262 recorder.serve(),
1263 )
1264 .await?;
1265
1266 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1267 Ok(())
1268 }
1269
1270 #[test]
1271 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1272 let policy = policy();
1273 let at_threshold = verdict(0.5, "supported", "SUP-1");
1274 let below_threshold = verdict(0.49, "supported", "SUP-1");
1275 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1276 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1277 Ok(())
1278 }
1279
1280 #[test]
1281 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1282 let borderline = verdict(0.5, "supported", "SUP-1");
1283 let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1284 let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1285 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1286 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1287 Ok(())
1288 }
1289
1290 #[test]
1291 fn classifier_config_rejects_unknown_fields() {
1292 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1293 "base_threshold": 0.5,
1294 "classifier_magic": true,
1295 }))
1296 .expect_err("unknown classifier fields must be rejected");
1297
1298 assert!(
1299 error
1300 .to_string()
1301 .contains("unknown field `classifier_magic`"),
1302 "{error}"
1303 );
1304 }
1305
1306 #[test]
1307 fn invalid_classifier_config_is_rejected() -> Result<()> {
1308 let target = |name: &str| LlmTarget {
1309 semantic_name: name.to_string(),
1310 };
1311 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1312 assert!(
1313 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1314 judge_target: target("judge"),
1315 efficient_target: target("e"),
1316 capable_target: target("c"),
1317 config: test_config(bad),
1318 })
1319 .is_err(),
1320 "base threshold {bad} should be rejected"
1321 );
1322 }
1323 for config in [
1324 TaskClassifierConfig {
1325 base_threshold: 0.5,
1326 threshold_step: -0.1,
1327 ..TaskClassifierConfig::default()
1328 },
1329 TaskClassifierConfig {
1330 base_threshold: 0.8,
1331 threshold_step: 0.11,
1332 ..TaskClassifierConfig::default()
1333 },
1334 TaskClassifierConfig {
1335 base_threshold: 0.5,
1336 message_hash_fallback: true,
1337 ..TaskClassifierConfig::default()
1338 },
1339 TaskClassifierConfig {
1340 base_threshold: 0.5,
1341 max_output_tokens: 0,
1342 ..TaskClassifierConfig::default()
1343 },
1344 ] {
1345 assert!(
1346 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1347 judge_target: target("judge"),
1348 efficient_target: target("e"),
1349 capable_target: target("c"),
1350 config,
1351 })
1352 .is_err()
1353 );
1354 }
1355 for base_threshold in [0.0, 1.0] {
1356 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1357 judge_target: target("judge"),
1358 efficient_target: target("e"),
1359 capable_target: target("c"),
1360 config: test_config(base_threshold),
1361 })?;
1362 }
1363 Ok(())
1364 }
1365
1366 #[test]
1367 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1368 let policy = policy();
1369 let inconsistent_rule = TaskClassifierVerdict {
1370 capability_boundary: "uncertain".to_string(),
1371 ..verdict(1.0, "supported", "SUP-1")
1372 };
1373 let empty_crux = TaskClassifierVerdict {
1374 crux: " ".to_string(),
1375 ..verdict(1.0, "supported", "SUP-1")
1376 };
1377 let unusable = [
1378 Some(verdict(1.1, "supported", "SUP-1")),
1379 Some(inconsistent_rule),
1380 Some(empty_crux),
1381 None,
1382 ];
1383 for verdict in unusable {
1384 let classification = policy.to_classification(verdict.as_ref());
1385 assert!(matches!(classification, Classification::Ambiguous(_)));
1386 assert!(classification.argmax(false)?.is_none());
1387 assert!(classification.argmax(true)?.is_none());
1388 }
1389 Ok(())
1390 }
1391
1392 #[test]
1393 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1394 let policy = TaskClassifierPolicy::new(
1395 "efficient",
1396 "capable",
1397 &TaskClassifierConfig {
1398 threshold_step: 0.1,
1399 ..test_config(0.4)
1400 },
1401 );
1402
1403 assert_eq!(
1404 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1405 "efficient"
1406 );
1407 assert_eq!(
1408 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1409 "capable"
1410 );
1411 assert_eq!(
1412 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1413 "efficient"
1414 );
1415 assert_eq!(
1416 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1417 "efficient"
1418 );
1419 assert_eq!(
1420 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1421 "capable"
1422 );
1423 assert_eq!(
1424 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1425 "efficient"
1426 );
1427 Ok(())
1428 }
1429
1430 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1433 Ok(StructuredJudge::new(
1434 TaskInput { recent_turn_window },
1435 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1436 SerdeDecoder::new(),
1437 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1438 ))
1439 }
1440
1441 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1442 let judge = capability_judge(Some(recent_turn_window))?;
1443 let request = Request {
1444 llm_request: LlmRequest {
1445 messages: vec![
1446 Message::text(Role::System, "client instructions"),
1447 Message::text(Role::User, "initial task"),
1448 Message::text(Role::Assistant, "old response"),
1449 Message::text(Role::User, "old follow-up"),
1450 Message::text(Role::Assistant, "recent 1"),
1451 Message::text(Role::User, "recent 2"),
1452 ],
1453 ..LlmRequest::default()
1454 },
1455 raw_request: None,
1456 metadata: None,
1457 };
1458 Ok(judge
1459 .build_request(&State::default(), &request)
1460 .llm_request
1461 .messages
1462 .iter()
1463 .filter_map(|message| message.text_content("\n"))
1464 .collect())
1465 }
1466
1467 #[test]
1468 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1469 let contents = judged_contents(2)?;
1471 assert!(contents.contains(&"client instructions".to_string()));
1472 assert!(contents.contains(&"initial task".to_string()));
1473 assert!(contents.contains(&"recent 1".to_string()));
1474 assert!(contents.contains(&"recent 2".to_string()));
1475 assert!(!contents.contains(&"old response".to_string()));
1476 Ok(())
1477 }
1478
1479 #[test]
1480 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1481 let contents = judged_contents(0)?;
1482 assert!(contents.contains(&"client instructions".to_string()));
1483 assert!(contents.contains(&"initial task".to_string()));
1484 assert!(!contents.contains(&"recent 2".to_string()));
1485 Ok(())
1486 }
1487
1488 fn tool_call(id: &str) -> Message {
1489 Message {
1490 role: Role::Assistant,
1491 content: vec![ContentBlock::ToolCall(ToolCall {
1492 id: id.to_string(),
1493 name: "search".to_string(),
1494 arguments: Value::Null,
1495 })],
1496 }
1497 }
1498
1499 fn tool_result(id: &str) -> Message {
1500 Message {
1501 role: Role::Tool,
1502 content: vec![ContentBlock::ToolResult(ToolResult {
1503 tool_call_id: id.to_string(),
1504 content: vec![ContentBlock::Text {
1505 text: "tool output".to_string(),
1506 }],
1507 is_error: None,
1508 })],
1509 }
1510 }
1511
1512 #[test]
1515 fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1516 let messages = vec![
1517 Message::text(Role::System, "client instructions"),
1518 Message::text(Role::User, "initial task"),
1519 Message::text(Role::Assistant, "old response"),
1520 tool_call("call-1"),
1521 tool_result("call-1"),
1522 Message::text(Role::Assistant, "recent 1"),
1523 Message::text(Role::User, "recent 2"),
1524 Message::text(Role::Assistant, "recent 3"),
1525 Message::text(Role::User, "recent 4"),
1526 ];
1527
1528 let kept = trim_messages(&messages, 5);
1530
1531 assert_eq!(
1532 kept,
1533 vec![
1534 Message::text(Role::System, "client instructions"),
1535 Message::text(Role::User, "initial task"),
1536 tool_call("call-1"),
1537 tool_result("call-1"),
1538 Message::text(Role::Assistant, "recent 1"),
1539 Message::text(Role::User, "recent 2"),
1540 Message::text(Role::Assistant, "recent 3"),
1541 Message::text(Role::User, "recent 4"),
1542 ]
1543 );
1544 }
1545
1546 #[test]
1549 fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1550 let messages = vec![
1551 Message::text(Role::System, "client instructions"),
1552 Message::text(Role::User, "initial task"),
1553 tool_call("x"),
1554 tool_result("x"),
1555 Message::text(Role::Assistant, "later"),
1556 tool_call("x"),
1557 tool_result("x"),
1558 ];
1559
1560 let kept = trim_messages(&messages, 4);
1562
1563 assert_eq!(
1564 kept,
1565 vec![
1566 Message::text(Role::System, "client instructions"),
1567 Message::text(Role::User, "initial task"),
1568 tool_call("x"),
1569 tool_result("x"),
1570 Message::text(Role::Assistant, "later"),
1571 tool_call("x"),
1572 tool_result("x"),
1573 ]
1574 );
1575 }
1576
1577 #[test]
1580 fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1581 let messages = vec![
1582 Message::text(Role::System, "client instructions"),
1583 tool_call("orphan"),
1584 Message::text(Role::User, "initial task"),
1585 Message::text(Role::Assistant, "old response"),
1586 tool_result("orphan"),
1587 Message::text(Role::Assistant, "recent 1"),
1588 Message::text(Role::User, "recent 2"),
1589 ];
1590
1591 let kept = trim_messages(&messages, 3);
1592
1593 assert_eq!(
1594 kept,
1595 vec![
1596 Message::text(Role::System, "client instructions"),
1597 Message::text(Role::User, "initial task"),
1598 tool_result("orphan"),
1599 Message::text(Role::Assistant, "recent 1"),
1600 Message::text(Role::User, "recent 2"),
1601 ]
1602 );
1603 }
1604
1605 #[test]
1606 fn capability_judge_builds_a_structured_request() -> Result<()> {
1607 let judge = capability_judge(None)?;
1608 let request = Request {
1609 llm_request: LlmRequest {
1610 model: Some("inbound".to_string()),
1611 messages: vec![
1612 Message::text(Role::System, "client instructions"),
1613 Message::text(Role::Developer, "client developer instructions"),
1614 Message::text(Role::User, "initial task"),
1615 Message::text(Role::Assistant, "old response"),
1616 Message::text(Role::User, "old follow-up"),
1617 Message::text(Role::Assistant, "recent 1"),
1618 Message::text(Role::User, "recent 2"),
1619 Message::text(Role::Assistant, "recent 3"),
1620 Message::text(Role::User, "recent 4"),
1621 Message::text(Role::Assistant, "recent 5"),
1622 ],
1623 ..LlmRequest::default()
1624 },
1625 raw_request: None,
1626 metadata: None,
1627 };
1628 let judge_request = judge.build_request(&State::default(), &request);
1629
1630 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1631 assert_eq!(judge_request.llm_request.instructions.len(), 1);
1632 assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
1633 assert_eq!(
1634 judge_request.llm_request.instructions[0].content,
1635 InstructionBlock {
1636 role: Role::System,
1637 content: Message::text(Role::System, judge.contract().system_prompt()).content,
1638 }
1639 .content,
1640 );
1641 assert_eq!(judge_request.llm_request.messages.len(), 2);
1642 let contents = judge_request
1643 .llm_request
1644 .messages
1645 .iter()
1646 .filter_map(|message| message.text_content("\n"))
1647 .collect::<Vec<_>>();
1648 assert!(contents.contains(&"recent 4".to_string()));
1649 assert!(contents.contains(&"initial task".to_string()));
1650 assert!(!contents.contains(&"recent 5".to_string()));
1651 assert!(!contents.contains(&"client instructions".to_string()));
1652 assert_eq!(
1653 judge_request.llm_request.output.response_format,
1654 Some(judge.contract().response_format().clone())
1655 );
1656 assert_eq!(
1657 judge_request.llm_request.output.max_output_tokens,
1658 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1659 );
1660 Ok(())
1661 }
1662
1663 fn sample_value(spec: &Value) -> Value {
1664 if let Some(first) = spec
1665 .get("enum")
1666 .and_then(Value::as_array)
1667 .and_then(|values| values.first())
1668 {
1669 return first.clone();
1670 }
1671 match spec.get("type").and_then(Value::as_str) {
1672 Some("number") => serde_json::json!(0.5),
1673 Some("boolean") => serde_json::json!(false),
1674 _ => serde_json::json!("sample"),
1675 }
1676 }
1677
1678 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1679 let properties = schema
1680 .pointer("/json_schema/schema/properties")
1681 .and_then(Value::as_object)
1682 .ok_or_else(|| LibsyError::AlgorithmError {
1683 message: "packaged schema declares no properties".to_string(),
1684 })?;
1685 Ok(Value::Object(
1686 properties
1687 .iter()
1688 .map(|(name, spec)| (name.clone(), sample_value(spec)))
1689 .collect(),
1690 )
1691 .to_string())
1692 }
1693
1694 #[test]
1697 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1698 let contract =
1699 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1700 let schema = contract.response_format();
1701 let reply = schema_shaped_verdict(schema)?;
1702 let judge: CapabilityJudge = StructuredJudge::new(
1703 TaskInput {
1704 recent_turn_window: None,
1705 },
1706 contract,
1707 SerdeDecoder::new(),
1708 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1709 );
1710
1711 let verdict = judge.parse(&text_response(None, reply))?;
1712
1713 assert!(verdict.is_valid());
1714 assert!((0.0..=1.0).contains(&verdict.p_solve));
1715 Ok(())
1716 }
1717
1718 #[test]
1719 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1720 let contract =
1721 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1722 let prompt = contract.system_prompt();
1723 let schema_name = contract
1724 .response_format()
1725 .pointer("/json_schema/name")
1726 .and_then(Value::as_str)
1727 .ok_or_else(|| LibsyError::AlgorithmError {
1728 message: "packaged response schema has no name".to_string(),
1729 })?;
1730 assert_eq!(schema_name, "CapabilityClassifierDecision");
1731 assert!(prompt.contains("SUP-1 [supported]"));
1732 assert!(prompt.contains("SUP-5 [supported]"));
1733 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1734 assert!(!prompt.contains("\"type\": \"object\""));
1735 assert!(!prompt.contains("\"json_schema\""));
1736 assert!(!prompt.contains(schema_name));
1737 let rule_values = contract
1738 .response_format()
1739 .pointer("/json_schema/schema/properties/primary_rule/enum")
1740 .and_then(Value::as_array)
1741 .ok_or_else(|| LibsyError::AlgorithmError {
1742 message: "rendered response schema has no primary rule enum".to_string(),
1743 })?;
1744 assert!(
1745 rule_values
1746 .iter()
1747 .any(|value| value.as_str() == Some("SUP-1"))
1748 );
1749 assert!(
1750 rule_values
1751 .iter()
1752 .any(|value| value.as_str() == Some("none"))
1753 );
1754 Ok(())
1755 }
1756
1757 use std::collections::VecDeque;
1760
1761 use switchyard_protocol::Decision;
1762
1763 struct Queue(Mutex<VecDeque<String>>);
1765
1766 impl Queue {
1767 fn new(replies: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
1768 Arc::new(Self(Mutex::new(
1769 replies.into_iter().map(String::from).collect(),
1770 )))
1771 }
1772
1773 fn take(&self) -> String {
1774 self.0
1775 .lock()
1776 .pop_front()
1777 .unwrap_or_else(|| "unexpected call".to_string())
1778 }
1779 }
1780
1781 fn queued(model: Arc<Queue>, judge: Arc<Queue>) -> impl Serve {
1784 move |decision: Decision, request: Request| {
1785 let queue = if decision.selected_model_id() == "judge" {
1786 Arc::clone(&judge)
1787 } else {
1788 Arc::clone(&model)
1789 };
1790 async move {
1791 Ok(Response {
1792 llm_response: LlmResponse::Agg(text_response(None, queue.take())),
1793 metadata: request.metadata,
1794 })
1795 }
1796 }
1797 }
1798
1799 fn escalation_router() -> Result<Arc<LlmTaskClassifier>> {
1801 let target = |name: &str| LlmTarget {
1802 semantic_name: name.to_string(),
1803 };
1804 Ok(Arc::new(LlmTaskClassifier::new(
1805 LlmClassifierConfig::Escalation {
1806 judge_target: target("judge"),
1807 efficient_target: target("efficient"),
1808 capable_target: target("capable"),
1809 contract: ClassifierContractConfig::default(),
1810 config: EscalationJudgeConfig {
1811 confirmations: 1,
1812 ..EscalationJudgeConfig::default()
1813 },
1814 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1815 },
1816 )?))
1817 }
1818
1819 #[tokio::test]
1820 async fn escalation_router_serves_efficient_when_judge_declines() -> Result<()> {
1821 let judge = Queue::new([r#"{"escalate":false,"reason":"progressing"}"#]);
1823 let model = Queue::new(["efficient answer"]);
1824 let router = escalation_router()?;
1825
1826 let (trace, response) =
1827 test_drive(router, classify_request(), queued(model, judge)).await?;
1828
1829 assert_eq!(
1831 trace.last().map(|d| d.selected_model_id()),
1832 Some("efficient")
1833 );
1834 assert!(
1835 trace
1836 .last()
1837 .and_then(|decision| decision.reasoning())
1838 .is_some_and(|reasoning| reasoning.contains("routing tier: weak"))
1839 );
1840 assert_eq!(
1841 response.llm_response.as_agg().map(completion_text),
1842 Some("efficient answer".to_string())
1843 );
1844 Ok(())
1845 }
1846
1847 #[tokio::test]
1848 async fn escalation_config_overrides_the_packaged_prompt() -> Result<()> {
1849 let recorder = Arc::new(Recorder::default());
1850 let target = |name: &str| LlmTarget {
1851 semantic_name: name.to_string(),
1852 };
1853 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1854 judge_target: target("judge"),
1855 efficient_target: target("efficient"),
1856 capable_target: target("capable"),
1857 contract: ClassifierContractConfig::default().with_prompt("Custom trajectory rubric."),
1858 config: EscalationJudgeConfig {
1859 confirmations: 1,
1860 ..EscalationJudgeConfig::default()
1861 },
1862 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1863 })?);
1864
1865 test_drive(router, classify_request(), recorder.serve()).await?;
1866
1867 let prompts = recorder.judge_system_prompts();
1868 assert_eq!(prompts.len(), 1);
1869 assert_eq!(prompts[0], "Custom trajectory rubric.");
1870 Ok(())
1871 }
1872
1873 #[tokio::test]
1874 async fn escalation_router_upgrades_to_capable_when_judge_escalates() -> Result<()> {
1875 let judge = Queue::new([r#"{"escalate":true,"reason":"stuck in a loop"}"#]);
1877 let model = Queue::new(["efficient draft", "capable answer"]);
1879 let router = escalation_router()?;
1880
1881 let (trace, response) =
1882 test_drive(router, classify_request(), queued(model, judge)).await?;
1883
1884 assert_eq!(trace.last().map(|d| d.selected_model_id()), Some("capable"));
1885 assert!(
1886 trace
1887 .last()
1888 .and_then(|decision| decision.reasoning())
1889 .is_some_and(|reasoning| reasoning.contains("routing tier: strong"))
1890 );
1891 assert_eq!(
1892 response.llm_response.as_agg().map(completion_text),
1893 Some("capable answer".to_string())
1894 );
1895 Ok(())
1896 }
1897
1898 #[tokio::test]
1899 async fn escalation_router_stays_capable_after_latch() -> Result<()> {
1900 let judge = Queue::new([r#"{"escalate":true,"reason":"stuck"}"#]);
1903 let model = Queue::new(["efficient draft", "capable t1", "capable t2"]);
1904 let router = escalation_router()?;
1905
1906 let session_request = classify_session_request();
1907 test_drive(
1908 router.clone(),
1909 session_request.clone(),
1910 queued(Arc::clone(&model), Arc::clone(&judge)),
1911 )
1912 .await?;
1913 let (trace, _) = test_drive(router.clone(), session_request, queued(model, judge)).await?;
1914
1915 assert_eq!(trace.last().map(|d| d.selected_model_id()), Some("capable"));
1916 Ok(())
1917 }
1918
1919 #[tokio::test]
1920 async fn escalation_classifier_falls_back_to_capable_when_efficient_overflows() -> Result<()> {
1921 let router = escalation_router()?;
1925
1926 let serve = |decision: Decision, _request: Request| async move {
1928 match decision.selected_model_id() {
1929 "efficient" => Err(LlmClientError::ContextWindowExceeded {
1930 model: decision.selected_model_id().to_string(),
1931 message: "prompt is too long".to_string(),
1932 }),
1933 "judge" => panic!("the judge must not be consulted when efficient overflows"),
1934 _ => Ok(reply("capable answer")),
1935 }
1936 };
1937
1938 let (trace, response) = test_drive(router, classify_request(), serve).await?;
1939
1940 assert_eq!(trace.last().map(|d| d.selected_model_id()), Some("capable"));
1941 assert_eq!(
1942 response.llm_response.as_agg().map(completion_text),
1943 Some("capable answer".to_string())
1944 );
1945 Ok(())
1946 }
1947}