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