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