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()], true)
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(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, LlmResponseChunk, Metadata,
948 ToolCall, ToolResult, 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<ModelId> {
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 |model: ModelId, request: Request| {
1024 let recorder = Arc::clone(&recorder);
1025 async move {
1026 let model = model.to_string();
1027 recorder.calls.lock().push(model.clone());
1028 recorder
1029 .call_roles
1030 .lock()
1031 .push((model.clone(), model != "judge"));
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 |model: ModelId, request: Request| async move {
1068 let model = model.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 Ok(Arc::new(LlmTaskClassifier::new(
1083 LlmClassifierConfig::Capability {
1084 judge_target: ModelId::from("judge"),
1085 efficient_target: ModelId::from("efficient"),
1086 capable_target: ModelId::from("capable"),
1087 config: test_config(TEST_THRESHOLD),
1088 },
1089 )?))
1090 }
1091
1092 fn classify_request() -> Request {
1093 Request {
1094 llm_request: text_request(Some("auto".to_string()), "classify this task"),
1095 raw_request: None,
1096 metadata: None,
1097 }
1098 }
1099
1100 fn classify_session_request() -> Request {
1101 Request {
1102 metadata: Some(Metadata {
1103 session_id: Some("session-1".to_string()),
1104 ..Metadata::default()
1105 }),
1106 ..classify_request()
1107 }
1108 }
1109
1110 fn classify_follow_up_request() -> Request {
1111 let mut request = classify_request();
1112 request
1113 .llm_request
1114 .messages
1115 .push(Message::text(Role::Assistant, "I will add the test."));
1116 request.llm_request.messages.push(Message::text(
1117 Role::User,
1118 "Now run the test suite and report the result.",
1119 ));
1120 request
1121 }
1122
1123 #[tokio::test]
1124 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1125 let router = router()?;
1126
1127 let (trace, response) = test_drive(router, classify_request(), unreachable_judge()).await?;
1128
1129 assert_eq!(
1130 trace.last().map(|d| d.selected_model_id().as_str()),
1131 Some("capable")
1132 );
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 router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1169 judge_target: ModelId::from("judge"),
1170 efficient_target: ModelId::from("efficient"),
1171 capable_target: ModelId::from("capable"),
1172 config: TaskClassifierConfig {
1173 max_output_tokens: 512,
1174 ..test_config(TEST_THRESHOLD)
1175 },
1176 })?);
1177
1178 test_drive(router, classify_request(), recorder.serve()).await?;
1179
1180 assert_eq!(recorder.judge_max_output_tokens(), vec![Some(512)]);
1181 Ok(())
1182 }
1183
1184 #[tokio::test]
1185 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1186 let recorder = Arc::new(Recorder::default());
1187 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1188 judge_target: ModelId::from("judge"),
1189 efficient_target: ModelId::from("efficient"),
1190 capable_target: ModelId::from("capable"),
1191 config: TaskClassifierConfig {
1192 contract: ClassifierContractConfig::default()
1193 .with_prompt("Custom capability rubric."),
1194 ..test_config(TEST_THRESHOLD)
1195 },
1196 })?);
1197
1198 test_drive(router, classify_request(), recorder.serve()).await?;
1199
1200 let prompts = recorder.judge_system_prompts();
1201 assert_eq!(prompts.len(), 1);
1202 assert_eq!(prompts[0], "Custom capability rubric.");
1203 Ok(())
1204 }
1205
1206 #[tokio::test]
1207 async fn classifier_config_enables_session_affinity() -> Result<()> {
1208 let recorder = Arc::new(Recorder::default());
1209 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1210 judge_target: ModelId::from("judge"),
1211 efficient_target: ModelId::from("efficient"),
1212 capable_target: ModelId::from("capable"),
1213 config: TaskClassifierConfig {
1214 session_affinity: true,
1215 ..test_config(TEST_THRESHOLD)
1216 },
1217 })?);
1218
1219 let session_request = classify_session_request;
1220 test_drive(router.clone(), session_request(), recorder.serve()).await?;
1221 test_drive(router.clone(), session_request(), recorder.serve()).await?;
1222
1223 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1224 Ok(())
1225 }
1226
1227 #[tokio::test]
1228 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1229 let recorder = Arc::new(Recorder::default());
1230 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1231 judge_target: ModelId::from("judge"),
1232 efficient_target: ModelId::from("efficient"),
1233 capable_target: ModelId::from("capable"),
1234 config: TaskClassifierConfig {
1235 session_affinity: true,
1236 message_hash_fallback: true,
1237 recent_turn_window: None,
1238 ..test_config(TEST_THRESHOLD)
1239 },
1240 })?);
1241
1242 test_drive(router.clone(), classify_request(), recorder.serve()).await?;
1243 test_drive(
1244 router.clone(),
1245 classify_follow_up_request(),
1246 recorder.serve(),
1247 )
1248 .await?;
1249
1250 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1251 Ok(())
1252 }
1253
1254 #[test]
1255 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1256 let policy = policy();
1257 let at_threshold = verdict(0.5, "supported", "SUP-1");
1258 let below_threshold = verdict(0.49, "supported", "SUP-1");
1259 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1260 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1261 Ok(())
1262 }
1263
1264 #[test]
1265 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1266 let borderline = verdict(0.5, "supported", "SUP-1");
1267 let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1268 let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1269 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1270 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1271 Ok(())
1272 }
1273
1274 #[test]
1275 fn classifier_config_rejects_unknown_fields() {
1276 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1277 "base_threshold": 0.5,
1278 "classifier_magic": true,
1279 }))
1280 .expect_err("unknown classifier fields must be rejected");
1281
1282 assert!(
1283 error
1284 .to_string()
1285 .contains("unknown field `classifier_magic`"),
1286 "{error}"
1287 );
1288 }
1289
1290 #[test]
1291 fn invalid_classifier_config_is_rejected() -> Result<()> {
1292 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1293 assert!(
1294 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1295 judge_target: ModelId::from("judge"),
1296 efficient_target: ModelId::from("e"),
1297 capable_target: ModelId::from("c"),
1298 config: test_config(bad),
1299 })
1300 .is_err(),
1301 "base threshold {bad} should be rejected"
1302 );
1303 }
1304 for config in [
1305 TaskClassifierConfig {
1306 base_threshold: 0.5,
1307 threshold_step: -0.1,
1308 ..TaskClassifierConfig::default()
1309 },
1310 TaskClassifierConfig {
1311 base_threshold: 0.8,
1312 threshold_step: 0.11,
1313 ..TaskClassifierConfig::default()
1314 },
1315 TaskClassifierConfig {
1316 base_threshold: 0.5,
1317 message_hash_fallback: true,
1318 ..TaskClassifierConfig::default()
1319 },
1320 TaskClassifierConfig {
1321 base_threshold: 0.5,
1322 max_output_tokens: 0,
1323 ..TaskClassifierConfig::default()
1324 },
1325 ] {
1326 assert!(
1327 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1328 judge_target: ModelId::from("judge"),
1329 efficient_target: ModelId::from("e"),
1330 capable_target: ModelId::from("c"),
1331 config,
1332 })
1333 .is_err()
1334 );
1335 }
1336 for base_threshold in [0.0, 1.0] {
1337 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1338 judge_target: ModelId::from("judge"),
1339 efficient_target: ModelId::from("e"),
1340 capable_target: ModelId::from("c"),
1341 config: test_config(base_threshold),
1342 })?;
1343 }
1344 Ok(())
1345 }
1346
1347 #[test]
1348 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1349 let policy = policy();
1350 let inconsistent_rule = TaskClassifierVerdict {
1351 capability_boundary: "uncertain".to_string(),
1352 ..verdict(1.0, "supported", "SUP-1")
1353 };
1354 let empty_crux = TaskClassifierVerdict {
1355 crux: " ".to_string(),
1356 ..verdict(1.0, "supported", "SUP-1")
1357 };
1358 let unusable = [
1359 Some(verdict(1.1, "supported", "SUP-1")),
1360 Some(inconsistent_rule),
1361 Some(empty_crux),
1362 None,
1363 ];
1364 for verdict in unusable {
1365 let classification = policy.to_classification(verdict.as_ref());
1366 assert!(matches!(classification, Classification::Ambiguous(_)));
1367 assert!(classification.argmax(false)?.is_none());
1368 assert!(classification.argmax(true)?.is_none());
1369 }
1370 Ok(())
1371 }
1372
1373 #[test]
1374 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1375 let policy = TaskClassifierPolicy::new(
1376 "efficient",
1377 "capable",
1378 &TaskClassifierConfig {
1379 threshold_step: 0.1,
1380 ..test_config(0.4)
1381 },
1382 );
1383
1384 assert_eq!(
1385 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1386 "efficient"
1387 );
1388 assert_eq!(
1389 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1390 "capable"
1391 );
1392 assert_eq!(
1393 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1394 "efficient"
1395 );
1396 assert_eq!(
1397 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1398 "efficient"
1399 );
1400 assert_eq!(
1401 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1402 "capable"
1403 );
1404 assert_eq!(
1405 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1406 "efficient"
1407 );
1408 Ok(())
1409 }
1410
1411 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1414 Ok(StructuredJudge::new(
1415 TaskInput { recent_turn_window },
1416 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1417 SerdeDecoder::new(),
1418 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1419 ))
1420 }
1421
1422 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1423 let judge = capability_judge(Some(recent_turn_window))?;
1424 let request = Request {
1425 llm_request: LlmRequest {
1426 messages: vec![
1427 Message::text(Role::System, "client instructions"),
1428 Message::text(Role::User, "initial task"),
1429 Message::text(Role::Assistant, "old response"),
1430 Message::text(Role::User, "old follow-up"),
1431 Message::text(Role::Assistant, "recent 1"),
1432 Message::text(Role::User, "recent 2"),
1433 ],
1434 ..LlmRequest::default()
1435 },
1436 raw_request: None,
1437 metadata: None,
1438 };
1439 Ok(judge
1440 .build_request(&State::default(), &request)
1441 .llm_request
1442 .messages
1443 .iter()
1444 .filter_map(|message| message.text_content("\n"))
1445 .collect())
1446 }
1447
1448 #[test]
1449 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1450 let contents = judged_contents(2)?;
1452 assert!(contents.contains(&"client instructions".to_string()));
1453 assert!(contents.contains(&"initial task".to_string()));
1454 assert!(contents.contains(&"recent 1".to_string()));
1455 assert!(contents.contains(&"recent 2".to_string()));
1456 assert!(!contents.contains(&"old response".to_string()));
1457 Ok(())
1458 }
1459
1460 #[test]
1461 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1462 let contents = judged_contents(0)?;
1463 assert!(contents.contains(&"client instructions".to_string()));
1464 assert!(contents.contains(&"initial task".to_string()));
1465 assert!(!contents.contains(&"recent 2".to_string()));
1466 Ok(())
1467 }
1468
1469 fn tool_call(id: &str) -> Message {
1470 Message {
1471 role: Role::Assistant,
1472 content: vec![ContentBlock::ToolCall(ToolCall {
1473 id: id.to_string(),
1474 name: "search".to_string(),
1475 arguments: Value::Null,
1476 })],
1477 }
1478 }
1479
1480 fn tool_result(id: &str) -> Message {
1481 Message {
1482 role: Role::Tool,
1483 content: vec![ContentBlock::ToolResult(ToolResult {
1484 tool_call_id: id.to_string(),
1485 content: vec![ContentBlock::Text {
1486 text: "tool output".to_string(),
1487 }],
1488 is_error: None,
1489 })],
1490 }
1491 }
1492
1493 #[test]
1496 fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1497 let messages = vec![
1498 Message::text(Role::System, "client instructions"),
1499 Message::text(Role::User, "initial task"),
1500 Message::text(Role::Assistant, "old response"),
1501 tool_call("call-1"),
1502 tool_result("call-1"),
1503 Message::text(Role::Assistant, "recent 1"),
1504 Message::text(Role::User, "recent 2"),
1505 Message::text(Role::Assistant, "recent 3"),
1506 Message::text(Role::User, "recent 4"),
1507 ];
1508
1509 let kept = trim_messages(&messages, 5);
1511
1512 assert_eq!(
1513 kept,
1514 vec![
1515 Message::text(Role::System, "client instructions"),
1516 Message::text(Role::User, "initial task"),
1517 tool_call("call-1"),
1518 tool_result("call-1"),
1519 Message::text(Role::Assistant, "recent 1"),
1520 Message::text(Role::User, "recent 2"),
1521 Message::text(Role::Assistant, "recent 3"),
1522 Message::text(Role::User, "recent 4"),
1523 ]
1524 );
1525 }
1526
1527 #[test]
1530 fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1531 let messages = vec![
1532 Message::text(Role::System, "client instructions"),
1533 Message::text(Role::User, "initial task"),
1534 tool_call("x"),
1535 tool_result("x"),
1536 Message::text(Role::Assistant, "later"),
1537 tool_call("x"),
1538 tool_result("x"),
1539 ];
1540
1541 let kept = trim_messages(&messages, 4);
1543
1544 assert_eq!(
1545 kept,
1546 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 }
1557
1558 #[test]
1561 fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1562 let messages = vec![
1563 Message::text(Role::System, "client instructions"),
1564 tool_call("orphan"),
1565 Message::text(Role::User, "initial task"),
1566 Message::text(Role::Assistant, "old response"),
1567 tool_result("orphan"),
1568 Message::text(Role::Assistant, "recent 1"),
1569 Message::text(Role::User, "recent 2"),
1570 ];
1571
1572 let kept = trim_messages(&messages, 3);
1573
1574 assert_eq!(
1575 kept,
1576 vec![
1577 Message::text(Role::System, "client instructions"),
1578 Message::text(Role::User, "initial task"),
1579 tool_result("orphan"),
1580 Message::text(Role::Assistant, "recent 1"),
1581 Message::text(Role::User, "recent 2"),
1582 ]
1583 );
1584 }
1585
1586 #[test]
1587 fn capability_judge_builds_a_structured_request() -> Result<()> {
1588 let judge = capability_judge(None)?;
1589 let request = Request {
1590 llm_request: LlmRequest {
1591 model: Some("inbound".to_string()),
1592 messages: vec![
1593 Message::text(Role::System, "client instructions"),
1594 Message::text(Role::Developer, "client developer instructions"),
1595 Message::text(Role::User, "initial task"),
1596 Message::text(Role::Assistant, "old response"),
1597 Message::text(Role::User, "old follow-up"),
1598 Message::text(Role::Assistant, "recent 1"),
1599 Message::text(Role::User, "recent 2"),
1600 Message::text(Role::Assistant, "recent 3"),
1601 Message::text(Role::User, "recent 4"),
1602 Message::text(Role::Assistant, "recent 5"),
1603 ],
1604 ..LlmRequest::default()
1605 },
1606 raw_request: None,
1607 metadata: None,
1608 };
1609 let judge_request = judge.build_request(&State::default(), &request);
1610
1611 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1612 assert_eq!(judge_request.llm_request.instructions.len(), 1);
1613 assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
1614 assert_eq!(
1615 judge_request.llm_request.instructions[0].content,
1616 InstructionBlock {
1617 role: Role::System,
1618 content: Message::text(Role::System, judge.contract().system_prompt()).content,
1619 }
1620 .content,
1621 );
1622 assert_eq!(judge_request.llm_request.messages.len(), 2);
1623 let contents = judge_request
1624 .llm_request
1625 .messages
1626 .iter()
1627 .filter_map(|message| message.text_content("\n"))
1628 .collect::<Vec<_>>();
1629 assert!(contents.contains(&"recent 4".to_string()));
1630 assert!(contents.contains(&"initial task".to_string()));
1631 assert!(!contents.contains(&"recent 5".to_string()));
1632 assert!(!contents.contains(&"client instructions".to_string()));
1633 assert_eq!(
1634 judge_request.llm_request.output.response_format,
1635 Some(judge.contract().response_format().clone())
1636 );
1637 assert_eq!(
1638 judge_request.llm_request.output.max_output_tokens,
1639 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1640 );
1641 Ok(())
1642 }
1643
1644 fn sample_value(spec: &Value) -> Value {
1645 if let Some(first) = spec
1646 .get("enum")
1647 .and_then(Value::as_array)
1648 .and_then(|values| values.first())
1649 {
1650 return first.clone();
1651 }
1652 match spec.get("type").and_then(Value::as_str) {
1653 Some("number") => serde_json::json!(0.5),
1654 Some("boolean") => serde_json::json!(false),
1655 _ => serde_json::json!("sample"),
1656 }
1657 }
1658
1659 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1660 let properties = schema
1661 .pointer("/json_schema/schema/properties")
1662 .and_then(Value::as_object)
1663 .ok_or_else(|| LibsyError::AlgorithmError {
1664 message: "packaged schema declares no properties".to_string(),
1665 })?;
1666 Ok(Value::Object(
1667 properties
1668 .iter()
1669 .map(|(name, spec)| (name.clone(), sample_value(spec)))
1670 .collect(),
1671 )
1672 .to_string())
1673 }
1674
1675 #[test]
1678 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1679 let contract =
1680 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1681 let schema = contract.response_format();
1682 let reply = schema_shaped_verdict(schema)?;
1683 let judge: CapabilityJudge = StructuredJudge::new(
1684 TaskInput {
1685 recent_turn_window: None,
1686 },
1687 contract,
1688 SerdeDecoder::new(),
1689 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1690 );
1691
1692 let verdict = judge.parse(&text_response(None, reply))?;
1693
1694 assert!(verdict.is_valid());
1695 assert!((0.0..=1.0).contains(&verdict.p_solve));
1696 Ok(())
1697 }
1698
1699 #[test]
1700 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1701 let contract =
1702 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1703 let prompt = contract.system_prompt();
1704 let schema_name = contract
1705 .response_format()
1706 .pointer("/json_schema/name")
1707 .and_then(Value::as_str)
1708 .ok_or_else(|| LibsyError::AlgorithmError {
1709 message: "packaged response schema has no name".to_string(),
1710 })?;
1711 assert_eq!(schema_name, "CapabilityClassifierDecision");
1712 assert!(prompt.contains("SUP-1 [supported]"));
1713 assert!(prompt.contains("SUP-5 [supported]"));
1714 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1715 assert!(!prompt.contains("\"type\": \"object\""));
1716 assert!(!prompt.contains("\"json_schema\""));
1717 assert!(!prompt.contains(schema_name));
1718 let rule_values = contract
1719 .response_format()
1720 .pointer("/json_schema/schema/properties/primary_rule/enum")
1721 .and_then(Value::as_array)
1722 .ok_or_else(|| LibsyError::AlgorithmError {
1723 message: "rendered response schema has no primary rule enum".to_string(),
1724 })?;
1725 assert!(
1726 rule_values
1727 .iter()
1728 .any(|value| value.as_str() == Some("SUP-1"))
1729 );
1730 assert!(
1731 rule_values
1732 .iter()
1733 .any(|value| value.as_str() == Some("none"))
1734 );
1735 Ok(())
1736 }
1737
1738 use std::collections::VecDeque;
1741
1742 struct Queue(Mutex<VecDeque<String>>);
1744
1745 impl Queue {
1746 fn new(replies: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
1747 Arc::new(Self(Mutex::new(
1748 replies.into_iter().map(String::from).collect(),
1749 )))
1750 }
1751
1752 fn take(&self) -> String {
1753 self.0
1754 .lock()
1755 .pop_front()
1756 .unwrap_or_else(|| "unexpected call".to_string())
1757 }
1758 }
1759
1760 fn queued(model: Arc<Queue>, judge: Arc<Queue>) -> impl Serve {
1763 move |target: ModelId, request: Request| {
1764 let queue = if target == "judge" {
1765 Arc::clone(&judge)
1766 } else {
1767 Arc::clone(&model)
1768 };
1769 async move {
1770 Ok(Response {
1771 llm_response: LlmResponse::Agg(text_response(None, queue.take())),
1772 metadata: request.metadata,
1773 })
1774 }
1775 }
1776 }
1777
1778 fn streamed_then_error(error: LlmClientError) -> Response {
1780 Response {
1781 llm_response: LlmResponse::Stream(Box::pin(futures::stream::iter([
1782 Ok(LlmResponseChunk::TextDelta {
1783 index: 0,
1784 text: "partial".to_string(),
1785 }
1786 .into()),
1787 Err(error),
1788 ]))),
1789 metadata: None,
1790 }
1791 }
1792
1793 fn escalation_router() -> Result<Arc<LlmTaskClassifier>> {
1795 Ok(Arc::new(LlmTaskClassifier::new(
1796 LlmClassifierConfig::Escalation {
1797 judge_target: ModelId::from("judge"),
1798 efficient_target: ModelId::from("efficient"),
1799 capable_target: ModelId::from("capable"),
1800 contract: ClassifierContractConfig::default(),
1801 config: EscalationJudgeConfig {
1802 confirmations: 1,
1803 ..EscalationJudgeConfig::default()
1804 },
1805 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1806 },
1807 )?))
1808 }
1809
1810 #[tokio::test]
1811 async fn escalation_router_serves_efficient_when_judge_declines() -> Result<()> {
1812 let judge = Queue::new([r#"{"escalate":false,"reason":"progressing"}"#]);
1814 let model = Queue::new(["efficient answer"]);
1815 let router = escalation_router()?;
1816
1817 let (trace, response) =
1818 test_drive(router, classify_request(), queued(model, judge)).await?;
1819
1820 assert_eq!(
1822 trace.last().map(|d| d.selected_model_id().as_str()),
1823 Some("efficient")
1824 );
1825 assert_eq!(
1826 response.llm_response.as_agg().map(completion_text),
1827 Some("efficient answer".to_string())
1828 );
1829 Ok(())
1830 }
1831
1832 #[tokio::test]
1833 async fn escalation_config_overrides_the_packaged_prompt() -> Result<()> {
1834 let recorder = Arc::new(Recorder::default());
1835 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1836 judge_target: ModelId::from("judge"),
1837 efficient_target: ModelId::from("efficient"),
1838 capable_target: ModelId::from("capable"),
1839 contract: ClassifierContractConfig::default().with_prompt("Custom trajectory rubric."),
1840 config: EscalationJudgeConfig {
1841 confirmations: 1,
1842 ..EscalationJudgeConfig::default()
1843 },
1844 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1845 })?);
1846
1847 test_drive(router, classify_request(), recorder.serve()).await?;
1848
1849 let prompts = recorder.judge_system_prompts();
1850 assert_eq!(prompts.len(), 1);
1851 assert_eq!(prompts[0], "Custom trajectory rubric.");
1852 Ok(())
1853 }
1854
1855 #[tokio::test]
1856 async fn escalation_router_upgrades_to_capable_when_judge_escalates() -> Result<()> {
1857 let judge = Queue::new([r#"{"escalate":true,"reason":"stuck in a loop"}"#]);
1859 let model = Queue::new(["efficient draft", "capable answer"]);
1861 let router = escalation_router()?;
1862
1863 let (trace, response) =
1864 test_drive(router, classify_request(), queued(model, judge)).await?;
1865
1866 assert_eq!(
1867 trace.last().map(|d| d.selected_model_id().as_str()),
1868 Some("capable")
1869 );
1870 assert_eq!(
1871 response.llm_response.as_agg().map(completion_text),
1872 Some("capable answer".to_string())
1873 );
1874 Ok(())
1875 }
1876
1877 #[tokio::test]
1878 async fn escalation_router_stays_capable_after_latch() -> Result<()> {
1879 let judge = Queue::new([r#"{"escalate":true,"reason":"stuck"}"#]);
1882 let model = Queue::new(["efficient draft", "capable t1", "capable t2"]);
1883 let router = escalation_router()?;
1884
1885 let session_request = classify_session_request();
1886 test_drive(
1887 router.clone(),
1888 session_request.clone(),
1889 queued(Arc::clone(&model), Arc::clone(&judge)),
1890 )
1891 .await?;
1892 let (trace, _) = test_drive(router.clone(), session_request, queued(model, judge)).await?;
1893
1894 assert_eq!(
1895 trace.last().map(|d| d.selected_model_id().as_str()),
1896 Some("capable")
1897 );
1898 Ok(())
1899 }
1900
1901 #[tokio::test]
1902 async fn escalation_classifier_falls_back_to_capable_when_efficient_overflows() -> Result<()> {
1903 let router = escalation_router()?;
1907
1908 let serve = |target: ModelId, _request: Request| async move {
1910 match target.as_str() {
1911 "efficient" => Err(LlmClientError::ContextWindowExceeded {
1912 model: target,
1913 message: "prompt is too long".to_string(),
1914 }),
1915 "judge" => panic!("the judge must not be consulted when efficient overflows"),
1916 _ => Ok(reply("capable answer")),
1917 }
1918 };
1919
1920 let (trace, response) = test_drive(router, classify_request(), serve).await?;
1921
1922 assert_eq!(
1923 trace.last().map(|d| d.selected_model_id().as_str()),
1924 Some("capable")
1925 );
1926 assert_eq!(
1927 response.llm_response.as_agg().map(completion_text),
1928 Some("capable answer".to_string())
1929 );
1930 Ok(())
1931 }
1932
1933 #[tokio::test]
1935 async fn escalation_classifier_falls_back_when_efficient_stream_transport_fails() -> Result<()>
1936 {
1937 let router = escalation_router()?;
1938 let calls = Arc::new(Mutex::new(Vec::new()));
1939 let serve = {
1940 let calls = Arc::clone(&calls);
1941 move |model: ModelId, _request: Request| {
1942 let calls = Arc::clone(&calls);
1943 async move {
1944 let model = model.to_string();
1945 calls.lock().push(model.clone());
1946 match model.as_str() {
1947 "efficient" => Ok(streamed_then_error(LlmClientError::Transport {
1948 source: Box::new(std::io::Error::other("stream disconnected")),
1949 })),
1950 "judge" => {
1951 panic!("the judge must not be consulted after a transport failure")
1952 }
1953 _ => Ok(reply("capable answer")),
1954 }
1955 }
1956 }
1957 };
1958 let mut request = classify_request();
1959 request.llm_request.stream = true;
1960
1961 let result = test_drive(router, request, serve).await;
1962
1963 assert_eq!(&*calls.lock(), &["efficient", "capable"]);
1964 let (_, response) = result?;
1965 assert_eq!(
1966 response.llm_response.as_agg().map(completion_text),
1967 Some("capable answer".to_string())
1968 );
1969 Ok(())
1970 }
1971
1972 #[tokio::test]
1974 async fn escalation_classifier_preserves_non_transport_stream_errors() -> Result<()> {
1975 let router = escalation_router()?;
1976 let serve = |target: ModelId, _request: Request| async move {
1977 match target.as_str() {
1978 "efficient" => Ok(streamed_then_error(LlmClientError::InvalidResponse {
1979 source: Box::new(std::io::Error::other("invalid stream event")),
1980 })),
1981 other => panic!("unexpected call to {other}"),
1982 }
1983 };
1984 let mut request = classify_request();
1985 request.llm_request.stream = true;
1986
1987 match test_drive(router, request, serve).await {
1988 Err(LibsyError::ClientCall {
1989 target,
1990 source: LlmClientError::InvalidResponse { .. },
1991 }) => {
1992 assert_eq!(target, "efficient");
1993 Ok(())
1994 }
1995 Err(other) => panic!("expected InvalidResponse client error, got {other:?}"),
1996 Ok(_) => panic!("expected stream aggregation to fail"),
1997 }
1998 }
1999}