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