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, RoutedLlmClient,
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 fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
940 self.route.count_tokens_client()
941 }
942
943 async fn create_run_task(
944 self: Arc<Self>,
945 ctx: Context,
946 driver: Driver,
947 request: Request,
948 ) -> Result<Response> {
949 self.route.execute(ctx, driver, request).await
950 }
951}
952
953#[cfg(test)]
954mod tests {
955 use std::sync::Arc;
956
957 use parking_lot::Mutex;
958 use serde_json::Value;
959
960 use super::*;
961 use switchyard_protocol::{
962 ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, ToolCall, ToolResult,
963 completion_text, text_request, text_response,
964 };
965
966 use crate::algorithms::util::llm_judge::Judge;
967 use crate::core::algorithm::Algorithm;
968 use switchyard_protocol::{Context, LlmResponse, Response, RoutedLlmClient};
969
970 const TEST_THRESHOLD: f64 = 0.5;
971
972 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
973 TaskClassifierConfig {
974 base_threshold,
975 ..TaskClassifierConfig::default()
976 }
977 }
978
979 fn policy() -> TaskClassifierPolicy {
980 TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
981 }
982
983 fn verdict(
984 p_solve: f64,
985 capability_boundary: &str,
986 primary_rule: &str,
987 ) -> TaskClassifierVerdict {
988 TaskClassifierVerdict {
989 crux: "test crux".to_string(),
990 primary_rule: primary_rule.to_string(),
991 capability_boundary: capability_boundary.to_string(),
992 p_solve,
993 }
994 }
995
996 fn selected(
997 policy: &TaskClassifierPolicy,
998 verdict: Option<&TaskClassifierVerdict>,
999 ) -> Result<String> {
1000 policy
1001 .to_classification(verdict)
1002 .argmax(false)?
1003 .map(|score| score.target)
1004 .ok_or_else(|| LibsyError::AlgorithmError {
1005 message: "policy abstained".to_string(),
1006 })
1007 }
1008
1009 #[derive(Default)]
1010 struct PerRequestClient {
1011 calls: Mutex<Vec<String>>,
1012 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
1013 judge_system_prompts: Mutex<Vec<String>>,
1014 }
1015
1016 impl PerRequestClient {
1017 fn calls(&self) -> Vec<String> {
1018 self.calls.lock().clone()
1019 }
1020
1021 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
1022 self.judge_max_output_tokens.lock().clone()
1023 }
1024
1025 fn judge_system_prompts(&self) -> Vec<String> {
1026 self.judge_system_prompts.lock().clone()
1027 }
1028 }
1029
1030 #[async_trait]
1031 impl RoutedLlmClient for PerRequestClient {
1032 async fn call(
1033 &self,
1034 _ctx: Context,
1035 request: Request,
1036 decision: Arc<dyn Decision>,
1037 ) -> std::result::Result<Response, LlmClientError> {
1038 let model = decision.selected_model().to_string();
1039 self.calls.lock().push(model.clone());
1040 let completion = if model == "judge" {
1041 self.judge_max_output_tokens
1042 .lock()
1043 .push(request.llm_request.output.max_output_tokens);
1044 self.judge_system_prompts.lock().extend(
1045 request
1046 .llm_request
1047 .instructions
1048 .first()
1049 .and_then(|instruction| {
1050 instruction.content.iter().find_map(|b| {
1051 if let ContentBlock::Text { text } = b {
1052 Some(text.clone())
1053 } else {
1054 None
1055 }
1056 })
1057 }),
1058 );
1059 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1060 } else {
1061 format!("answer from {model}")
1062 };
1063 Ok(Response {
1064 llm_response: LlmResponse::Agg(text_response(None, completion)),
1065 metadata: request.metadata,
1066 })
1067 }
1068 }
1069
1070 struct UnreachableJudgeClient;
1071
1072 #[async_trait]
1073 impl RoutedLlmClient for UnreachableJudgeClient {
1074 async fn call(
1075 &self,
1076 _ctx: Context,
1077 request: Request,
1078 decision: Arc<dyn Decision>,
1079 ) -> std::result::Result<Response, LlmClientError> {
1080 let model = decision.selected_model().to_string();
1081 if model == "judge" {
1082 return Err(LlmClientError::Timeout {
1083 source: Box::new(std::io::Error::other("judge unreachable")),
1084 });
1085 }
1086 Ok(Response {
1087 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1088 metadata: request.metadata,
1089 })
1090 }
1091 }
1092
1093 fn router(client: Arc<dyn RoutedLlmClient>) -> Result<Arc<LlmTaskClassifier>> {
1094 let target = |name: &str| LlmTarget {
1095 semantic_name: name.to_string(),
1096 llm_client: Some(client.clone()),
1097 };
1098 Ok(Arc::new(LlmTaskClassifier::new(
1099 LlmClassifierConfig::Capability {
1100 judge_target: target("judge"),
1101 efficient_target: target("efficient"),
1102 capable_target: target("capable"),
1103 config: test_config(TEST_THRESHOLD),
1104 },
1105 )?))
1106 }
1107
1108 fn classify_request() -> Request {
1109 Request {
1110 llm_request: text_request(Some("auto".to_string()), "classify this task"),
1111 raw_request: None,
1112 metadata: None,
1113 }
1114 }
1115
1116 fn classify_session_request() -> Request {
1117 Request {
1118 metadata: Some(Metadata {
1119 session_id: Some("session-1".to_string()),
1120 ..Metadata::default()
1121 }),
1122 ..classify_request()
1123 }
1124 }
1125
1126 fn classify_follow_up_request() -> Request {
1127 let mut request = classify_request();
1128 request
1129 .llm_request
1130 .messages
1131 .push(Message::text(Role::Assistant, "I will add the test."));
1132 request.llm_request.messages.push(Message::text(
1133 Role::User,
1134 "Now run the test suite and report the result.",
1135 ));
1136 request
1137 }
1138
1139 #[tokio::test]
1140 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1141 let router = router(Arc::new(UnreachableJudgeClient))?;
1142
1143 let (trace, response) = router.run(Context::default(), classify_request()).await?;
1144
1145 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1146 assert_eq!(
1147 response.llm_response.as_agg().map(completion_text),
1148 Some("answer from capable".to_string())
1149 );
1150 Ok(())
1151 }
1152
1153 #[tokio::test]
1154 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1155 let client = Arc::new(PerRequestClient::default());
1156 let router = router(client.clone())?;
1157 let request = classify_request;
1158
1159 router.clone().run(Context::default(), request()).await?;
1160 router.clone().run(Context::default(), request()).await?;
1161
1162 assert_eq!(
1163 client.calls(),
1164 vec!["judge", "efficient", "judge", "efficient"]
1165 );
1166 Ok(())
1167 }
1168
1169 #[tokio::test]
1170 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1171 let client = Arc::new(PerRequestClient::default());
1172 let target = |name: &str| LlmTarget {
1173 semantic_name: name.to_string(),
1174 llm_client: Some(client.clone()),
1175 };
1176 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1177 judge_target: target("judge"),
1178 efficient_target: target("efficient"),
1179 capable_target: target("capable"),
1180 config: TaskClassifierConfig {
1181 max_output_tokens: 512,
1182 ..test_config(TEST_THRESHOLD)
1183 },
1184 })?);
1185
1186 router.run(Context::default(), classify_request()).await?;
1187
1188 assert_eq!(client.judge_max_output_tokens(), vec![Some(512)]);
1189 Ok(())
1190 }
1191
1192 #[tokio::test]
1193 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1194 let client = Arc::new(PerRequestClient::default());
1195 let target = |name: &str| LlmTarget {
1196 semantic_name: name.to_string(),
1197 llm_client: Some(client.clone()),
1198 };
1199 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1200 judge_target: target("judge"),
1201 efficient_target: target("efficient"),
1202 capable_target: target("capable"),
1203 config: TaskClassifierConfig {
1204 contract: ClassifierContractConfig::default()
1205 .with_prompt("Custom capability rubric."),
1206 ..test_config(TEST_THRESHOLD)
1207 },
1208 })?);
1209
1210 router.run(Context::default(), classify_request()).await?;
1211
1212 let prompts = client.judge_system_prompts();
1213 assert_eq!(prompts.len(), 1);
1214 assert_eq!(prompts[0], "Custom capability rubric.");
1215 Ok(())
1216 }
1217
1218 #[tokio::test]
1219 async fn classifier_config_enables_session_affinity() -> Result<()> {
1220 let client = Arc::new(PerRequestClient::default());
1221 let target = |name: &str| LlmTarget {
1222 semantic_name: name.to_string(),
1223 llm_client: Some(client.clone()),
1224 };
1225 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1226 judge_target: target("judge"),
1227 efficient_target: target("efficient"),
1228 capable_target: target("capable"),
1229 config: TaskClassifierConfig {
1230 session_affinity: true,
1231 ..test_config(TEST_THRESHOLD)
1232 },
1233 })?);
1234
1235 router
1236 .clone()
1237 .run(Context::default(), classify_session_request())
1238 .await?;
1239 router
1240 .clone()
1241 .run(Context::default(), classify_session_request())
1242 .await?;
1243
1244 assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
1245 Ok(())
1246 }
1247
1248 #[tokio::test]
1249 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1250 let client = Arc::new(PerRequestClient::default());
1251 let target = |name: &str| LlmTarget {
1252 semantic_name: name.to_string(),
1253 llm_client: Some(client.clone()),
1254 };
1255 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1256 judge_target: target("judge"),
1257 efficient_target: target("efficient"),
1258 capable_target: target("capable"),
1259 config: TaskClassifierConfig {
1260 session_affinity: true,
1261 message_hash_fallback: true,
1262 recent_turn_window: None,
1263 ..test_config(TEST_THRESHOLD)
1264 },
1265 })?);
1266
1267 router
1268 .clone()
1269 .run(Context::default(), classify_request())
1270 .await?;
1271 router
1272 .clone()
1273 .run(Context::default(), classify_follow_up_request())
1274 .await?;
1275
1276 assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
1277 Ok(())
1278 }
1279
1280 #[test]
1281 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1282 let policy = policy();
1283 let at_threshold = verdict(0.5, "supported", "SUP-1");
1284 let below_threshold = verdict(0.49, "supported", "SUP-1");
1285 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1286 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1287 Ok(())
1288 }
1289
1290 #[test]
1291 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1292 let borderline = verdict(0.5, "supported", "SUP-1");
1293 let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1294 let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1295 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1296 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1297 Ok(())
1298 }
1299
1300 #[test]
1301 fn classifier_config_rejects_unknown_fields() {
1302 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1303 "base_threshold": 0.5,
1304 "classifier_magic": true,
1305 }))
1306 .expect_err("unknown classifier fields must be rejected");
1307
1308 assert!(
1309 error
1310 .to_string()
1311 .contains("unknown field `classifier_magic`"),
1312 "{error}"
1313 );
1314 }
1315
1316 #[test]
1317 fn invalid_classifier_config_is_rejected() -> Result<()> {
1318 let target = |name: &str| LlmTarget {
1319 semantic_name: name.to_string(),
1320 llm_client: None,
1321 };
1322 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1323 assert!(
1324 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1325 judge_target: target("judge"),
1326 efficient_target: target("e"),
1327 capable_target: target("c"),
1328 config: test_config(bad),
1329 })
1330 .is_err(),
1331 "base threshold {bad} should be rejected"
1332 );
1333 }
1334 for config in [
1335 TaskClassifierConfig {
1336 base_threshold: 0.5,
1337 threshold_step: -0.1,
1338 ..TaskClassifierConfig::default()
1339 },
1340 TaskClassifierConfig {
1341 base_threshold: 0.8,
1342 threshold_step: 0.11,
1343 ..TaskClassifierConfig::default()
1344 },
1345 TaskClassifierConfig {
1346 base_threshold: 0.5,
1347 message_hash_fallback: true,
1348 ..TaskClassifierConfig::default()
1349 },
1350 TaskClassifierConfig {
1351 base_threshold: 0.5,
1352 max_output_tokens: 0,
1353 ..TaskClassifierConfig::default()
1354 },
1355 ] {
1356 assert!(
1357 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1358 judge_target: target("judge"),
1359 efficient_target: target("e"),
1360 capable_target: target("c"),
1361 config,
1362 })
1363 .is_err()
1364 );
1365 }
1366 for base_threshold in [0.0, 1.0] {
1367 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1368 judge_target: target("judge"),
1369 efficient_target: target("e"),
1370 capable_target: target("c"),
1371 config: test_config(base_threshold),
1372 })?;
1373 }
1374 Ok(())
1375 }
1376
1377 #[test]
1378 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1379 let policy = policy();
1380 let inconsistent_rule = TaskClassifierVerdict {
1381 capability_boundary: "uncertain".to_string(),
1382 ..verdict(1.0, "supported", "SUP-1")
1383 };
1384 let empty_crux = TaskClassifierVerdict {
1385 crux: " ".to_string(),
1386 ..verdict(1.0, "supported", "SUP-1")
1387 };
1388 let unusable = [
1389 Some(verdict(1.1, "supported", "SUP-1")),
1390 Some(inconsistent_rule),
1391 Some(empty_crux),
1392 None,
1393 ];
1394 for verdict in unusable {
1395 let classification = policy.to_classification(verdict.as_ref());
1396 assert!(matches!(classification, Classification::Ambiguous(_)));
1397 assert!(classification.argmax(false)?.is_none());
1398 assert!(classification.argmax(true)?.is_none());
1399 }
1400 Ok(())
1401 }
1402
1403 #[test]
1404 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1405 let policy = TaskClassifierPolicy::new(
1406 "efficient",
1407 "capable",
1408 &TaskClassifierConfig {
1409 threshold_step: 0.1,
1410 ..test_config(0.4)
1411 },
1412 );
1413
1414 assert_eq!(
1415 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1416 "efficient"
1417 );
1418 assert_eq!(
1419 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1420 "capable"
1421 );
1422 assert_eq!(
1423 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1424 "efficient"
1425 );
1426 assert_eq!(
1427 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1428 "efficient"
1429 );
1430 assert_eq!(
1431 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1432 "capable"
1433 );
1434 assert_eq!(
1435 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1436 "efficient"
1437 );
1438 Ok(())
1439 }
1440
1441 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1444 Ok(StructuredJudge::new(
1445 TaskInput { recent_turn_window },
1446 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1447 SerdeDecoder::new(),
1448 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1449 ))
1450 }
1451
1452 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1453 let judge = capability_judge(Some(recent_turn_window))?;
1454 let request = Request {
1455 llm_request: LlmRequest {
1456 messages: vec![
1457 Message::text(Role::System, "client instructions"),
1458 Message::text(Role::User, "initial task"),
1459 Message::text(Role::Assistant, "old response"),
1460 Message::text(Role::User, "old follow-up"),
1461 Message::text(Role::Assistant, "recent 1"),
1462 Message::text(Role::User, "recent 2"),
1463 ],
1464 ..LlmRequest::default()
1465 },
1466 raw_request: None,
1467 metadata: None,
1468 };
1469 Ok(judge
1470 .build_request(&State::default(), &request)
1471 .llm_request
1472 .messages
1473 .iter()
1474 .filter_map(|message| message.text_content("\n"))
1475 .collect())
1476 }
1477
1478 #[test]
1479 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1480 let contents = judged_contents(2)?;
1482 assert!(contents.contains(&"client instructions".to_string()));
1483 assert!(contents.contains(&"initial task".to_string()));
1484 assert!(contents.contains(&"recent 1".to_string()));
1485 assert!(contents.contains(&"recent 2".to_string()));
1486 assert!(!contents.contains(&"old response".to_string()));
1487 Ok(())
1488 }
1489
1490 #[test]
1491 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1492 let contents = judged_contents(0)?;
1493 assert!(contents.contains(&"client instructions".to_string()));
1494 assert!(contents.contains(&"initial task".to_string()));
1495 assert!(!contents.contains(&"recent 2".to_string()));
1496 Ok(())
1497 }
1498
1499 fn tool_call(id: &str) -> Message {
1500 Message {
1501 role: Role::Assistant,
1502 content: vec![ContentBlock::ToolCall(ToolCall {
1503 id: id.to_string(),
1504 name: "search".to_string(),
1505 arguments: Value::Null,
1506 })],
1507 }
1508 }
1509
1510 fn tool_result(id: &str) -> Message {
1511 Message {
1512 role: Role::Tool,
1513 content: vec![ContentBlock::ToolResult(ToolResult {
1514 tool_call_id: id.to_string(),
1515 content: vec![ContentBlock::Text {
1516 text: "tool output".to_string(),
1517 }],
1518 is_error: None,
1519 })],
1520 }
1521 }
1522
1523 #[test]
1526 fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1527 let messages = vec![
1528 Message::text(Role::System, "client instructions"),
1529 Message::text(Role::User, "initial task"),
1530 Message::text(Role::Assistant, "old response"),
1531 tool_call("call-1"),
1532 tool_result("call-1"),
1533 Message::text(Role::Assistant, "recent 1"),
1534 Message::text(Role::User, "recent 2"),
1535 Message::text(Role::Assistant, "recent 3"),
1536 Message::text(Role::User, "recent 4"),
1537 ];
1538
1539 let kept = trim_messages(&messages, 5);
1541
1542 assert_eq!(
1543 kept,
1544 vec![
1545 Message::text(Role::System, "client instructions"),
1546 Message::text(Role::User, "initial task"),
1547 tool_call("call-1"),
1548 tool_result("call-1"),
1549 Message::text(Role::Assistant, "recent 1"),
1550 Message::text(Role::User, "recent 2"),
1551 Message::text(Role::Assistant, "recent 3"),
1552 Message::text(Role::User, "recent 4"),
1553 ]
1554 );
1555 }
1556
1557 #[test]
1560 fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1561 let messages = vec![
1562 Message::text(Role::System, "client instructions"),
1563 Message::text(Role::User, "initial task"),
1564 tool_call("x"),
1565 tool_result("x"),
1566 Message::text(Role::Assistant, "later"),
1567 tool_call("x"),
1568 tool_result("x"),
1569 ];
1570
1571 let kept = trim_messages(&messages, 4);
1573
1574 assert_eq!(
1575 kept,
1576 vec![
1577 Message::text(Role::System, "client instructions"),
1578 Message::text(Role::User, "initial task"),
1579 tool_call("x"),
1580 tool_result("x"),
1581 Message::text(Role::Assistant, "later"),
1582 tool_call("x"),
1583 tool_result("x"),
1584 ]
1585 );
1586 }
1587
1588 #[test]
1591 fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1592 let messages = vec![
1593 Message::text(Role::System, "client instructions"),
1594 tool_call("orphan"),
1595 Message::text(Role::User, "initial task"),
1596 Message::text(Role::Assistant, "old response"),
1597 tool_result("orphan"),
1598 Message::text(Role::Assistant, "recent 1"),
1599 Message::text(Role::User, "recent 2"),
1600 ];
1601
1602 let kept = trim_messages(&messages, 3);
1603
1604 assert_eq!(
1605 kept,
1606 vec![
1607 Message::text(Role::System, "client instructions"),
1608 Message::text(Role::User, "initial task"),
1609 tool_result("orphan"),
1610 Message::text(Role::Assistant, "recent 1"),
1611 Message::text(Role::User, "recent 2"),
1612 ]
1613 );
1614 }
1615
1616 #[test]
1617 fn capability_judge_builds_a_structured_request() -> Result<()> {
1618 let judge = capability_judge(None)?;
1619 let request = Request {
1620 llm_request: LlmRequest {
1621 model: Some("inbound".to_string()),
1622 messages: vec![
1623 Message::text(Role::System, "client instructions"),
1624 Message::text(Role::Developer, "client developer instructions"),
1625 Message::text(Role::User, "initial task"),
1626 Message::text(Role::Assistant, "old response"),
1627 Message::text(Role::User, "old follow-up"),
1628 Message::text(Role::Assistant, "recent 1"),
1629 Message::text(Role::User, "recent 2"),
1630 Message::text(Role::Assistant, "recent 3"),
1631 Message::text(Role::User, "recent 4"),
1632 Message::text(Role::Assistant, "recent 5"),
1633 ],
1634 ..LlmRequest::default()
1635 },
1636 raw_request: None,
1637 metadata: None,
1638 };
1639 let judge_request = judge.build_request(&State::default(), &request);
1640
1641 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1642 assert_eq!(judge_request.llm_request.instructions.len(), 1);
1643 assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
1644 assert_eq!(
1645 judge_request.llm_request.instructions[0].content,
1646 InstructionBlock {
1647 role: Role::System,
1648 content: Message::text(Role::System, judge.contract().system_prompt()).content,
1649 }
1650 .content,
1651 );
1652 assert_eq!(judge_request.llm_request.messages.len(), 2);
1653 let contents = judge_request
1654 .llm_request
1655 .messages
1656 .iter()
1657 .filter_map(|message| message.text_content("\n"))
1658 .collect::<Vec<_>>();
1659 assert!(contents.contains(&"recent 4".to_string()));
1660 assert!(contents.contains(&"initial task".to_string()));
1661 assert!(!contents.contains(&"recent 5".to_string()));
1662 assert!(!contents.contains(&"client instructions".to_string()));
1663 assert_eq!(
1664 judge_request.llm_request.output.response_format,
1665 Some(judge.contract().response_format().clone())
1666 );
1667 assert_eq!(
1668 judge_request.llm_request.output.max_output_tokens,
1669 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1670 );
1671 Ok(())
1672 }
1673
1674 fn sample_value(spec: &Value) -> Value {
1675 if let Some(first) = spec
1676 .get("enum")
1677 .and_then(Value::as_array)
1678 .and_then(|values| values.first())
1679 {
1680 return first.clone();
1681 }
1682 match spec.get("type").and_then(Value::as_str) {
1683 Some("number") => serde_json::json!(0.5),
1684 Some("boolean") => serde_json::json!(false),
1685 _ => serde_json::json!("sample"),
1686 }
1687 }
1688
1689 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1690 let properties = schema
1691 .pointer("/json_schema/schema/properties")
1692 .and_then(Value::as_object)
1693 .ok_or_else(|| LibsyError::AlgorithmError {
1694 message: "packaged schema declares no properties".to_string(),
1695 })?;
1696 Ok(Value::Object(
1697 properties
1698 .iter()
1699 .map(|(name, spec)| (name.clone(), sample_value(spec)))
1700 .collect(),
1701 )
1702 .to_string())
1703 }
1704
1705 #[test]
1708 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1709 let contract =
1710 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1711 let schema = contract.response_format();
1712 let reply = schema_shaped_verdict(schema)?;
1713 let judge: CapabilityJudge = StructuredJudge::new(
1714 TaskInput {
1715 recent_turn_window: None,
1716 },
1717 contract,
1718 SerdeDecoder::new(),
1719 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1720 );
1721
1722 let verdict = judge.parse(&text_response(None, reply))?;
1723
1724 assert!(verdict.is_valid());
1725 assert!((0.0..=1.0).contains(&verdict.p_solve));
1726 Ok(())
1727 }
1728
1729 #[test]
1730 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1731 let contract =
1732 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1733 let prompt = contract.system_prompt();
1734 let schema_name = contract
1735 .response_format()
1736 .pointer("/json_schema/name")
1737 .and_then(Value::as_str)
1738 .ok_or_else(|| LibsyError::AlgorithmError {
1739 message: "packaged response schema has no name".to_string(),
1740 })?;
1741 assert_eq!(schema_name, "CapabilityClassifierDecision");
1742 assert!(prompt.contains("SUP-1 [supported]"));
1743 assert!(prompt.contains("SUP-5 [supported]"));
1744 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1745 assert!(!prompt.contains("\"type\": \"object\""));
1746 assert!(!prompt.contains("\"json_schema\""));
1747 assert!(!prompt.contains(schema_name));
1748 let rule_values = contract
1749 .response_format()
1750 .pointer("/json_schema/schema/properties/primary_rule/enum")
1751 .and_then(Value::as_array)
1752 .ok_or_else(|| LibsyError::AlgorithmError {
1753 message: "rendered response schema has no primary rule enum".to_string(),
1754 })?;
1755 assert!(
1756 rule_values
1757 .iter()
1758 .any(|value| value.as_str() == Some("SUP-1"))
1759 );
1760 assert!(
1761 rule_values
1762 .iter()
1763 .any(|value| value.as_str() == Some("none"))
1764 );
1765 Ok(())
1766 }
1767
1768 use std::collections::VecDeque;
1771
1772 use switchyard_protocol::LlmClientError as ClientError;
1773
1774 use switchyard_protocol::Decision;
1775
1776 struct QueuedClient {
1778 replies: Mutex<VecDeque<String>>,
1779 }
1780
1781 impl QueuedClient {
1782 fn new(replies: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
1783 Arc::new(Self {
1784 replies: Mutex::new(replies.into_iter().map(String::from).collect()),
1785 })
1786 }
1787 }
1788
1789 #[async_trait]
1790 impl RoutedLlmClient for QueuedClient {
1791 async fn call(
1792 &self,
1793 _ctx: Context,
1794 request: Request,
1795 _decision: Arc<dyn Decision>,
1796 ) -> std::result::Result<Response, ClientError> {
1797 let reply = self
1798 .replies
1799 .lock()
1800 .pop_front()
1801 .unwrap_or_else(|| "unexpected call".to_string());
1802 Ok(Response {
1803 llm_response: LlmResponse::Agg(text_response(None, reply)),
1804 metadata: request.metadata,
1805 })
1806 }
1807 }
1808
1809 fn escalation_router(
1811 client: Arc<dyn RoutedLlmClient>,
1812 judge_client: Arc<dyn RoutedLlmClient>,
1813 ) -> Result<Arc<LlmTaskClassifier>> {
1814 let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
1815 semantic_name: name.to_string(),
1816 llm_client: Some(c),
1817 };
1818 Ok(Arc::new(LlmTaskClassifier::new(
1819 LlmClassifierConfig::Escalation {
1820 judge_target: target("judge", judge_client),
1821 efficient_target: target("efficient", client.clone()),
1822 capable_target: target("capable", client),
1823 contract: ClassifierContractConfig::default(),
1824 config: EscalationJudgeConfig {
1825 confirmations: 1,
1826 ..EscalationJudgeConfig::default()
1827 },
1828 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1829 },
1830 )?))
1831 }
1832
1833 #[tokio::test]
1834 async fn escalation_router_serves_efficient_when_judge_declines() -> Result<()> {
1835 let judge_client = QueuedClient::new([r#"{"escalate":false,"reason":"progressing"}"#]);
1837 let model_client = QueuedClient::new(["efficient answer"]);
1838 let router = escalation_router(model_client, judge_client)?;
1839 let request = classify_request();
1840
1841 let (trace, response) = router.run(Context::default(), request).await?;
1842
1843 assert_eq!(trace.last().map(|d| d.selected_model()), Some("efficient"));
1845 assert_eq!(
1846 response.llm_response.as_agg().map(completion_text),
1847 Some("efficient answer".to_string())
1848 );
1849 Ok(())
1850 }
1851
1852 #[tokio::test]
1853 async fn escalation_config_overrides_the_packaged_prompt() -> Result<()> {
1854 let client = Arc::new(PerRequestClient::default());
1855 let target = |name: &str| LlmTarget {
1856 semantic_name: name.to_string(),
1857 llm_client: Some(client.clone()),
1858 };
1859 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1860 judge_target: target("judge"),
1861 efficient_target: target("efficient"),
1862 capable_target: target("capable"),
1863 contract: ClassifierContractConfig::default().with_prompt("Custom trajectory rubric."),
1864 config: EscalationJudgeConfig {
1865 confirmations: 1,
1866 ..EscalationJudgeConfig::default()
1867 },
1868 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1869 })?);
1870
1871 router.run(Context::default(), classify_request()).await?;
1872
1873 let prompts = client.judge_system_prompts();
1874 assert_eq!(prompts.len(), 1);
1875 assert_eq!(prompts[0], "Custom trajectory rubric.");
1876 Ok(())
1877 }
1878
1879 #[tokio::test]
1880 async fn escalation_router_upgrades_to_capable_when_judge_escalates() -> Result<()> {
1881 let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck in a loop"}"#]);
1883 let model_client = QueuedClient::new(["efficient draft", "capable answer"]);
1885 let router = escalation_router(model_client, judge_client)?;
1886 let request = classify_request();
1887
1888 let (trace, response) = router.run(Context::default(), request).await?;
1889
1890 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1891 assert_eq!(
1892 response.llm_response.as_agg().map(completion_text),
1893 Some("capable answer".to_string())
1894 );
1895 Ok(())
1896 }
1897
1898 #[tokio::test]
1899 async fn escalation_router_stays_capable_after_latch() -> Result<()> {
1900 let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck"}"#]);
1903 let model_client = QueuedClient::new(["efficient draft", "capable t1", "capable t2"]);
1904 let router = escalation_router(model_client, judge_client)?;
1905
1906 let session_request = classify_session_request();
1907 router
1908 .clone()
1909 .run(Context::default(), session_request.clone())
1910 .await?;
1911 let (trace, _) = router
1912 .clone()
1913 .run(Context::default(), session_request)
1914 .await?;
1915
1916 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1917 Ok(())
1918 }
1919
1920 #[tokio::test]
1921 async fn escalation_classifier_falls_back_to_capable_when_efficient_overflows() -> Result<()> {
1922 struct OverflowClient;
1926 #[async_trait]
1927 impl RoutedLlmClient for OverflowClient {
1928 async fn call(
1929 &self,
1930 _ctx: Context,
1931 _request: Request,
1932 decision: Arc<dyn Decision>,
1933 ) -> std::result::Result<Response, LlmClientError> {
1934 Err(LlmClientError::ContextWindowExceeded {
1935 model: decision.selected_model().to_string(),
1936 message: "prompt is too long".to_string(),
1937 })
1938 }
1939 }
1940
1941 let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
1942 semantic_name: name.to_string(),
1943 llm_client: Some(c),
1944 };
1945 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1946 judge_target: target("judge", QueuedClient::new([])), efficient_target: target("efficient", Arc::new(OverflowClient)),
1948 capable_target: target("capable", QueuedClient::new(["capable answer"])),
1949 contract: ClassifierContractConfig::default(),
1950 config: EscalationJudgeConfig {
1951 confirmations: 1,
1952 ..EscalationJudgeConfig::default()
1953 },
1954 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1955 })?);
1956
1957 let (trace, response) = router.run(Context::default(), classify_request()).await?;
1958
1959 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1960 assert_eq!(
1961 response.llm_response.as_agg().map(completion_text),
1962 Some("capable answer".to_string())
1963 );
1964 Ok(())
1965 }
1966}