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