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