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#[non_exhaustive]
601pub enum LlmClassifierConfig {
602 Capability {
604 judge_target: ModelId,
606 efficient_target: ModelId,
608 capable_target: ModelId,
610 config: TaskClassifierConfig,
612 },
613 Escalation {
615 judge_target: ModelId,
617 efficient_target: ModelId,
619 capable_target: ModelId,
621 contract: ClassifierContractConfig,
623 config: EscalationJudgeConfig,
625 max_output_tokens: u64,
627 },
628 Custom {
630 judge_target: ModelId,
632 targets: Vec<(String, ModelId)>,
634 default_target: String,
636 config: CustomClassifierConfig,
638 },
639}
640
641impl LlmTaskClassifier {
642 pub fn new(config: LlmClassifierConfig) -> Result<Self> {
649 match config {
650 LlmClassifierConfig::Capability {
651 judge_target,
652 efficient_target,
653 capable_target,
654 config,
655 } => Self::build_capability(judge_target, efficient_target, capable_target, config),
656 LlmClassifierConfig::Escalation {
657 judge_target,
658 efficient_target,
659 capable_target,
660 contract,
661 config,
662 max_output_tokens,
663 } => Self::build_escalation(
664 judge_target,
665 efficient_target,
666 capable_target,
667 contract,
668 config,
669 max_output_tokens,
670 ),
671 LlmClassifierConfig::Custom {
672 judge_target,
673 targets,
674 default_target,
675 config,
676 } => Self::build_custom(judge_target, targets, default_target, config),
677 }
678 }
679
680 fn build_capability(
681 judge_target: ModelId,
682 efficient_target: ModelId,
683 capable_target: ModelId,
684 config: TaskClassifierConfig,
685 ) -> Result<Self> {
686 config.validate()?;
687 let contract = Self::load_capability_contract(&config.contract)?;
688 let targets = vec![efficient_target.clone(), capable_target.clone()];
689 let session_affinity = config.session_affinity;
690 let message_hash_fallback = config.message_hash_fallback;
691 let classifier = Arc::new(TaskClassifier {
692 classifier: JudgeClassifier::new(
693 StructuredJudge::new(
694 TaskInput {
695 recent_turn_window: config.recent_turn_window,
696 },
697 contract,
698 SerdeDecoder::new(),
699 JudgeRuntimeConfig::new(config.max_output_tokens)?,
700 ),
701 judge_target.clone(),
702 TaskClassifierPolicy::new(
703 efficient_target.clone(),
704 capable_target.clone(),
705 &config,
706 ),
707 ),
708 efficient_target: efficient_target.clone(),
709 capable_target: capable_target.clone(),
710 });
711 let inner: Arc<dyn Classifier<State>> = classifier.clone();
712 Self::from_classifier(
713 targets,
714 inner,
715 ClassifierRouteConfig {
716 default_target: classifier.capable_target.clone(),
717 session_affinity,
718 message_hash_fallback,
719 },
720 )
721 }
722
723 fn build_custom(
724 judge_target: ModelId,
725 targets: Vec<(String, ModelId)>,
726 default_target: String,
727 config: CustomClassifierConfig,
728 ) -> Result<Self> {
729 config.validate()?;
730 if targets.len() < 2 {
731 return Err(LibsyError::AlgorithmError {
732 message: "custom classifier requires at least two targets".to_string(),
733 });
734 }
735
736 let mut labels = BTreeSet::new();
737 let mut resolved_names = BTreeSet::new();
738 let mut target_map = BTreeMap::new();
739 let mut resolved_targets = Vec::with_capacity(targets.len());
740 for (label, target) in targets {
741 if label.trim().is_empty() || label.trim() != label {
742 return Err(LibsyError::AlgorithmError {
743 message: "custom classifier target labels must be non-empty and have no surrounding whitespace"
744 .to_string(),
745 });
746 }
747 if !labels.insert(label.clone()) {
748 return Err(LibsyError::AlgorithmError {
749 message: format!("custom classifier target label {label:?} is duplicated"),
750 });
751 }
752 if !resolved_names.insert(target.clone()) {
753 return Err(LibsyError::AlgorithmError {
754 message: format!("custom classifier resolved target {target:?} is duplicated"),
755 });
756 }
757 target_map.insert(label, target.clone());
758 resolved_targets.push(target);
759 }
760 let default_name =
761 target_map
762 .get(&default_target)
763 .cloned()
764 .ok_or_else(|| LibsyError::AlgorithmError {
765 message: format!(
766 "default_target {default_target:?} must be one of the configured targets"
767 ),
768 })?;
769
770 let CustomClassifierConfig {
771 prompt,
772 response_schema,
773 policy,
774 session_affinity,
775 message_hash_fallback,
776 recent_turn_window,
777 max_output_tokens,
778 } = config;
779 let contract = ClassifierContract::from_inner_schema(&prompt, response_schema)?;
780 let policy = match policy {
781 CustomClassifierPolicy::TargetSelector { selector } => {
782 CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(
783 selector, target_map,
784 )?)
785 }
786 };
787 let classifier: Arc<dyn Classifier<State>> = Arc::new(JudgeClassifier::new(
788 StructuredJudge::new(
789 TaskInput { recent_turn_window },
790 contract,
791 JsonSchemaDecoder::new(),
792 JudgeRuntimeConfig::new(max_output_tokens)?,
793 ),
794 judge_target,
795 policy,
796 ));
797
798 Self::from_classifier(
799 resolved_targets,
800 classifier,
801 ClassifierRouteConfig {
802 default_target: default_name,
803 session_affinity,
804 message_hash_fallback,
805 },
806 )
807 }
808
809 fn build_escalation(
810 judge_target: ModelId,
811 efficient_target: ModelId,
812 capable_target: ModelId,
813 contract_config: ClassifierContractConfig,
814 config: EscalationJudgeConfig,
815 max_output_tokens: u64,
816 ) -> Result<Self> {
817 let capable_name = capable_target.clone();
818 let efficient_name = efficient_target.clone();
819 let confirmations = config.confirmations;
820 let esc = Arc::new(EscalationClassifier {
821 judge: escalation::build_judge(
822 judge_target,
823 capable_name,
824 efficient_name,
825 &contract_config,
826 config,
827 max_output_tokens,
828 )?,
829 capable: capable_target.clone(),
830 efficient: efficient_target.clone(),
831 confirmations,
832 });
833 let inner: Arc<dyn Classifier<State>> = esc.clone();
834 let targets = vec![capable_target, efficient_target];
835 Ok(Self {
836 route: FallThrough::<State>::new_with_state(targets)
837 .with_name(ALGORITHM_NAME)
838 .with_classifier(esc),
839 inner,
840 })
841 }
842
843 fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
845 ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
846 }
847
848 fn from_classifier(
850 targets: Vec<ModelId>,
851 inner: Arc<dyn Classifier<State>>,
852 config: ClassifierRouteConfig,
853 ) -> Result<Self> {
854 algorithm::ensure_model_is_target(&targets, &config.default_target)?;
855 if config.message_hash_fallback && !config.session_affinity {
856 return Err(LibsyError::AlgorithmError {
857 message: "message_hash_fallback requires session_affinity".to_string(),
858 });
859 }
860 let mut route = FallThrough::<State>::new_with_state(targets).with_name(ALGORITHM_NAME);
864 if config.session_affinity {
865 let affinity = if config.message_hash_fallback {
866 AffinityRouter::new().with_message_hash_fallback()
867 } else {
868 AffinityRouter::new()
869 };
870 let affinity = Arc::new(affinity);
872 route = route
873 .with_processor(affinity.clone())
874 .with_classifier(affinity);
875 }
876 let fallback = DefaultTarget::new(config.default_target);
877 Ok(Self {
878 route: route
879 .with_classifier(inner.clone())
880 .with_classifier(Arc::new(fallback)),
881 inner,
882 })
883 }
884}
885
886#[async_trait]
887impl Classifier<State> for TaskClassifier {
888 fn routing_tier(&self, selected_model_id: &ModelId) -> Option<&'static str> {
889 if self.efficient_target == self.capable_target {
890 None
891 } else if *selected_model_id == self.efficient_target {
892 Some("weak")
893 } else if *selected_model_id == self.capable_target {
894 Some("strong")
895 } else {
896 None
897 }
898 }
899
900 async fn score(
901 &self,
902 state: &mut State,
903 request: &mut Request,
904 driver: Option<&Driver>,
905 ) -> Result<(Classification, Option<Response>)> {
906 self.classifier.score(state, request, driver).await
907 }
908}
909
910#[async_trait]
911impl Classifier<State> for LlmTaskClassifier {
912 fn routing_tier(&self, selected_model_id: &ModelId) -> Option<&'static str> {
913 self.inner.routing_tier(selected_model_id)
914 }
915
916 async fn score(
917 &self,
918 state: &mut State,
919 request: &mut Request,
920 driver: Option<&Driver>,
921 ) -> Result<(Classification, Option<Response>)> {
922 self.inner.score(state, request, driver).await
923 }
924}
925
926#[async_trait]
927impl Algorithm for LlmTaskClassifier {
928 fn name(&self) -> &str {
929 "llm_task_classifier"
930 }
931
932 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<Response> {
933 self.route.execute(driver, request).await
934 }
935}
936
937#[cfg(test)]
938mod tests {
939 use std::sync::Arc;
940
941 use parking_lot::Mutex;
942 use serde_json::Value;
943
944 use super::*;
945 use switchyard_protocol::{
946 ContentBlock, InstructionBlock, LlmClientError, LlmRequest, LlmResponseChunk, Metadata,
947 ToolCall, ToolResult, completion_text, text_request, text_response,
948 };
949
950 use crate::algorithms::util::llm_judge::Judge;
951 use crate::core::testing::{Serve, reply, test_drive};
952 use switchyard_protocol::{LlmResponse, Response};
953
954 const TEST_THRESHOLD: f64 = 0.5;
955
956 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
957 TaskClassifierConfig {
958 base_threshold,
959 ..TaskClassifierConfig::default()
960 }
961 }
962
963 fn policy() -> TaskClassifierPolicy {
964 TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
965 }
966
967 fn verdict(
968 p_solve: f64,
969 capability_boundary: &str,
970 primary_rule: &str,
971 ) -> TaskClassifierVerdict {
972 TaskClassifierVerdict {
973 crux: "test crux".to_string(),
974 primary_rule: primary_rule.to_string(),
975 capability_boundary: capability_boundary.to_string(),
976 p_solve,
977 }
978 }
979
980 fn selected(
981 policy: &TaskClassifierPolicy,
982 verdict: Option<&TaskClassifierVerdict>,
983 ) -> Result<ModelId> {
984 policy
985 .to_classification(verdict)
986 .argmax(false)?
987 .map(|score| score.target)
988 .ok_or_else(|| LibsyError::AlgorithmError {
989 message: "policy abstained".to_string(),
990 })
991 }
992
993 #[derive(Default)]
996 struct Recorder {
997 calls: Mutex<Vec<String>>,
998 call_roles: Mutex<Vec<(String, bool)>>,
999 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
1000 judge_system_prompts: Mutex<Vec<String>>,
1001 }
1002
1003 impl Recorder {
1004 fn calls(&self) -> Vec<String> {
1005 self.calls.lock().clone()
1006 }
1007
1008 fn call_roles(&self) -> Vec<(String, bool)> {
1009 self.call_roles.lock().clone()
1010 }
1011
1012 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
1013 self.judge_max_output_tokens.lock().clone()
1014 }
1015
1016 fn judge_system_prompts(&self) -> Vec<String> {
1017 self.judge_system_prompts.lock().clone()
1018 }
1019
1020 fn serve(self: &Arc<Self>) -> impl Serve {
1021 let recorder = Arc::clone(self);
1022 move |model: ModelId, request: Request| {
1023 let recorder = Arc::clone(&recorder);
1024 async move {
1025 let model = model.to_string();
1026 recorder.calls.lock().push(model.clone());
1027 recorder
1028 .call_roles
1029 .lock()
1030 .push((model.clone(), model != "judge"));
1031 let completion = if model == "judge" {
1032 recorder
1033 .judge_max_output_tokens
1034 .lock()
1035 .push(request.llm_request.output.max_output_tokens);
1036 recorder.judge_system_prompts.lock().extend(
1037 request
1038 .llm_request
1039 .instructions
1040 .first()
1041 .and_then(|instruction| {
1042 instruction.content.iter().find_map(|b| {
1043 if let ContentBlock::Text { text } = b {
1044 Some(text.clone())
1045 } else {
1046 None
1047 }
1048 })
1049 }),
1050 );
1051 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1052 } else {
1053 format!("answer from {model}")
1054 };
1055 Ok(Response {
1056 llm_response: LlmResponse::Agg(text_response(None, completion)),
1057 metadata: request.metadata,
1058 })
1059 }
1060 }
1061 }
1062 }
1063
1064 fn unreachable_judge() -> impl Serve {
1066 |model: ModelId, request: Request| async move {
1067 let model = model.to_string();
1068 if model == "judge" {
1069 return Err(LlmClientError::Timeout {
1070 source: Box::new(std::io::Error::other("judge unreachable")),
1071 });
1072 }
1073 Ok(Response {
1074 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1075 metadata: request.metadata,
1076 })
1077 }
1078 }
1079
1080 fn router() -> Result<Arc<LlmTaskClassifier>> {
1081 Ok(Arc::new(LlmTaskClassifier::new(
1082 LlmClassifierConfig::Capability {
1083 judge_target: ModelId::from("judge"),
1084 efficient_target: ModelId::from("efficient"),
1085 capable_target: ModelId::from("capable"),
1086 config: test_config(TEST_THRESHOLD),
1087 },
1088 )?))
1089 }
1090
1091 fn classify_request() -> Request {
1092 Request {
1093 llm_request: text_request(Some("auto".to_string()), "classify this task"),
1094 raw_request: None,
1095 metadata: None,
1096 }
1097 }
1098
1099 fn classify_session_request() -> Request {
1100 Request {
1101 metadata: Some(Metadata {
1102 session_id: Some("session-1".to_string()),
1103 ..Metadata::default()
1104 }),
1105 ..classify_request()
1106 }
1107 }
1108
1109 fn classify_follow_up_request() -> Request {
1110 let mut request = classify_request();
1111 request
1112 .llm_request
1113 .messages
1114 .push(Message::text(Role::Assistant, "I will add the test."));
1115 request.llm_request.messages.push(Message::text(
1116 Role::User,
1117 "Now run the test suite and report the result.",
1118 ));
1119 request
1120 }
1121
1122 #[tokio::test]
1123 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1124 let router = router()?;
1125
1126 let (trace, response) = test_drive(router, classify_request(), unreachable_judge()).await?;
1127
1128 assert_eq!(
1129 trace.last().map(|d| d.selected_model_id().as_str()),
1130 Some("capable")
1131 );
1132 assert_eq!(
1133 response.llm_response.as_agg().map(completion_text),
1134 Some("answer from capable".to_string())
1135 );
1136 Ok(())
1137 }
1138
1139 #[tokio::test]
1140 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1141 let recorder = Arc::new(Recorder::default());
1142 let router = router()?;
1143 let request = classify_request;
1144
1145 test_drive(router.clone(), request(), recorder.serve()).await?;
1146 test_drive(router.clone(), request(), recorder.serve()).await?;
1147
1148 assert_eq!(
1149 recorder.calls(),
1150 vec!["judge", "efficient", "judge", "efficient"]
1151 );
1152 assert_eq!(
1153 recorder.call_roles(),
1154 vec![
1155 ("judge".to_string(), false),
1156 ("efficient".to_string(), true),
1157 ("judge".to_string(), false),
1158 ("efficient".to_string(), true),
1159 ]
1160 );
1161 Ok(())
1162 }
1163
1164 #[tokio::test]
1165 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1166 let recorder = Arc::new(Recorder::default());
1167 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1168 judge_target: ModelId::from("judge"),
1169 efficient_target: ModelId::from("efficient"),
1170 capable_target: ModelId::from("capable"),
1171 config: TaskClassifierConfig {
1172 max_output_tokens: 512,
1173 ..test_config(TEST_THRESHOLD)
1174 },
1175 })?);
1176
1177 test_drive(router, classify_request(), recorder.serve()).await?;
1178
1179 assert_eq!(recorder.judge_max_output_tokens(), vec![Some(512)]);
1180 Ok(())
1181 }
1182
1183 #[tokio::test]
1184 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1185 let recorder = Arc::new(Recorder::default());
1186 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1187 judge_target: ModelId::from("judge"),
1188 efficient_target: ModelId::from("efficient"),
1189 capable_target: ModelId::from("capable"),
1190 config: TaskClassifierConfig {
1191 contract: ClassifierContractConfig::default()
1192 .with_prompt("Custom capability rubric."),
1193 ..test_config(TEST_THRESHOLD)
1194 },
1195 })?);
1196
1197 test_drive(router, classify_request(), recorder.serve()).await?;
1198
1199 let prompts = recorder.judge_system_prompts();
1200 assert_eq!(prompts.len(), 1);
1201 assert_eq!(prompts[0], "Custom capability rubric.");
1202 Ok(())
1203 }
1204
1205 #[tokio::test]
1206 async fn classifier_config_enables_session_affinity() -> Result<()> {
1207 let recorder = Arc::new(Recorder::default());
1208 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1209 judge_target: ModelId::from("judge"),
1210 efficient_target: ModelId::from("efficient"),
1211 capable_target: ModelId::from("capable"),
1212 config: TaskClassifierConfig {
1213 session_affinity: true,
1214 ..test_config(TEST_THRESHOLD)
1215 },
1216 })?);
1217
1218 let session_request = classify_session_request;
1219 test_drive(router.clone(), session_request(), recorder.serve()).await?;
1220 test_drive(router.clone(), session_request(), recorder.serve()).await?;
1221
1222 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1223 Ok(())
1224 }
1225
1226 #[tokio::test]
1227 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1228 let recorder = Arc::new(Recorder::default());
1229 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1230 judge_target: ModelId::from("judge"),
1231 efficient_target: ModelId::from("efficient"),
1232 capable_target: ModelId::from("capable"),
1233 config: TaskClassifierConfig {
1234 session_affinity: true,
1235 message_hash_fallback: true,
1236 recent_turn_window: None,
1237 ..test_config(TEST_THRESHOLD)
1238 },
1239 })?);
1240
1241 test_drive(router.clone(), classify_request(), recorder.serve()).await?;
1242 test_drive(
1243 router.clone(),
1244 classify_follow_up_request(),
1245 recorder.serve(),
1246 )
1247 .await?;
1248
1249 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1250 Ok(())
1251 }
1252
1253 #[test]
1254 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1255 let policy = policy();
1256 let at_threshold = verdict(0.5, "supported", "SUP-1");
1257 let below_threshold = verdict(0.49, "supported", "SUP-1");
1258 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1259 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1260 Ok(())
1261 }
1262
1263 #[test]
1264 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1265 let borderline = verdict(0.5, "supported", "SUP-1");
1266 let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1267 let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1268 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1269 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1270 Ok(())
1271 }
1272
1273 #[test]
1274 fn classifier_config_rejects_unknown_fields() {
1275 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1276 "base_threshold": 0.5,
1277 "classifier_magic": true,
1278 }))
1279 .expect_err("unknown classifier fields must be rejected");
1280
1281 assert!(
1282 error
1283 .to_string()
1284 .contains("unknown field `classifier_magic`"),
1285 "{error}"
1286 );
1287 }
1288
1289 #[test]
1290 fn invalid_classifier_config_is_rejected() -> Result<()> {
1291 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1292 assert!(
1293 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1294 judge_target: ModelId::from("judge"),
1295 efficient_target: ModelId::from("e"),
1296 capable_target: ModelId::from("c"),
1297 config: test_config(bad),
1298 })
1299 .is_err(),
1300 "base threshold {bad} should be rejected"
1301 );
1302 }
1303 for config in [
1304 TaskClassifierConfig {
1305 base_threshold: 0.5,
1306 threshold_step: -0.1,
1307 ..TaskClassifierConfig::default()
1308 },
1309 TaskClassifierConfig {
1310 base_threshold: 0.8,
1311 threshold_step: 0.11,
1312 ..TaskClassifierConfig::default()
1313 },
1314 TaskClassifierConfig {
1315 base_threshold: 0.5,
1316 message_hash_fallback: true,
1317 ..TaskClassifierConfig::default()
1318 },
1319 TaskClassifierConfig {
1320 base_threshold: 0.5,
1321 max_output_tokens: 0,
1322 ..TaskClassifierConfig::default()
1323 },
1324 ] {
1325 assert!(
1326 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1327 judge_target: ModelId::from("judge"),
1328 efficient_target: ModelId::from("e"),
1329 capable_target: ModelId::from("c"),
1330 config,
1331 })
1332 .is_err()
1333 );
1334 }
1335 for base_threshold in [0.0, 1.0] {
1336 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1337 judge_target: ModelId::from("judge"),
1338 efficient_target: ModelId::from("e"),
1339 capable_target: ModelId::from("c"),
1340 config: test_config(base_threshold),
1341 })?;
1342 }
1343 Ok(())
1344 }
1345
1346 #[test]
1347 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1348 let policy = policy();
1349 let inconsistent_rule = TaskClassifierVerdict {
1350 capability_boundary: "uncertain".to_string(),
1351 ..verdict(1.0, "supported", "SUP-1")
1352 };
1353 let empty_crux = TaskClassifierVerdict {
1354 crux: " ".to_string(),
1355 ..verdict(1.0, "supported", "SUP-1")
1356 };
1357 let unusable = [
1358 Some(verdict(1.1, "supported", "SUP-1")),
1359 Some(inconsistent_rule),
1360 Some(empty_crux),
1361 None,
1362 ];
1363 for verdict in unusable {
1364 let classification = policy.to_classification(verdict.as_ref());
1365 assert!(matches!(classification, Classification::Ambiguous(_)));
1366 assert!(classification.argmax(false)?.is_none());
1367 assert!(classification.argmax(true)?.is_none());
1368 }
1369 Ok(())
1370 }
1371
1372 #[test]
1373 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1374 let policy = TaskClassifierPolicy::new(
1375 "efficient",
1376 "capable",
1377 &TaskClassifierConfig {
1378 threshold_step: 0.1,
1379 ..test_config(0.4)
1380 },
1381 );
1382
1383 assert_eq!(
1384 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1385 "efficient"
1386 );
1387 assert_eq!(
1388 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1389 "capable"
1390 );
1391 assert_eq!(
1392 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1393 "efficient"
1394 );
1395 assert_eq!(
1396 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1397 "efficient"
1398 );
1399 assert_eq!(
1400 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1401 "capable"
1402 );
1403 assert_eq!(
1404 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1405 "efficient"
1406 );
1407 Ok(())
1408 }
1409
1410 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1413 Ok(StructuredJudge::new(
1414 TaskInput { recent_turn_window },
1415 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1416 SerdeDecoder::new(),
1417 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1418 ))
1419 }
1420
1421 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1422 let judge = capability_judge(Some(recent_turn_window))?;
1423 let request = Request {
1424 llm_request: LlmRequest {
1425 messages: vec![
1426 Message::text(Role::System, "client instructions"),
1427 Message::text(Role::User, "initial task"),
1428 Message::text(Role::Assistant, "old response"),
1429 Message::text(Role::User, "old follow-up"),
1430 Message::text(Role::Assistant, "recent 1"),
1431 Message::text(Role::User, "recent 2"),
1432 ],
1433 ..LlmRequest::default()
1434 },
1435 raw_request: None,
1436 metadata: None,
1437 };
1438 Ok(judge
1439 .build_request(&State::default(), &request)
1440 .llm_request
1441 .messages
1442 .iter()
1443 .filter_map(|message| message.text_content("\n"))
1444 .collect())
1445 }
1446
1447 #[test]
1448 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1449 let contents = judged_contents(2)?;
1451 assert!(contents.contains(&"client instructions".to_string()));
1452 assert!(contents.contains(&"initial task".to_string()));
1453 assert!(contents.contains(&"recent 1".to_string()));
1454 assert!(contents.contains(&"recent 2".to_string()));
1455 assert!(!contents.contains(&"old response".to_string()));
1456 Ok(())
1457 }
1458
1459 #[test]
1460 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1461 let contents = judged_contents(0)?;
1462 assert!(contents.contains(&"client instructions".to_string()));
1463 assert!(contents.contains(&"initial task".to_string()));
1464 assert!(!contents.contains(&"recent 2".to_string()));
1465 Ok(())
1466 }
1467
1468 fn tool_call(id: &str) -> Message {
1469 Message {
1470 role: Role::Assistant,
1471 content: vec![ContentBlock::ToolCall(ToolCall {
1472 id: id.to_string(),
1473 name: "search".to_string(),
1474 arguments: Value::Null,
1475 })],
1476 }
1477 }
1478
1479 fn tool_result(id: &str) -> Message {
1480 Message {
1481 role: Role::Tool,
1482 content: vec![ContentBlock::ToolResult(ToolResult {
1483 tool_call_id: id.to_string(),
1484 content: vec![ContentBlock::Text {
1485 text: "tool output".to_string(),
1486 }],
1487 is_error: None,
1488 })],
1489 }
1490 }
1491
1492 #[test]
1495 fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1496 let messages = vec![
1497 Message::text(Role::System, "client instructions"),
1498 Message::text(Role::User, "initial task"),
1499 Message::text(Role::Assistant, "old response"),
1500 tool_call("call-1"),
1501 tool_result("call-1"),
1502 Message::text(Role::Assistant, "recent 1"),
1503 Message::text(Role::User, "recent 2"),
1504 Message::text(Role::Assistant, "recent 3"),
1505 Message::text(Role::User, "recent 4"),
1506 ];
1507
1508 let kept = trim_messages(&messages, 5);
1510
1511 assert_eq!(
1512 kept,
1513 vec![
1514 Message::text(Role::System, "client instructions"),
1515 Message::text(Role::User, "initial task"),
1516 tool_call("call-1"),
1517 tool_result("call-1"),
1518 Message::text(Role::Assistant, "recent 1"),
1519 Message::text(Role::User, "recent 2"),
1520 Message::text(Role::Assistant, "recent 3"),
1521 Message::text(Role::User, "recent 4"),
1522 ]
1523 );
1524 }
1525
1526 #[test]
1529 fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1530 let messages = vec![
1531 Message::text(Role::System, "client instructions"),
1532 Message::text(Role::User, "initial task"),
1533 tool_call("x"),
1534 tool_result("x"),
1535 Message::text(Role::Assistant, "later"),
1536 tool_call("x"),
1537 tool_result("x"),
1538 ];
1539
1540 let kept = trim_messages(&messages, 4);
1542
1543 assert_eq!(
1544 kept,
1545 vec![
1546 Message::text(Role::System, "client instructions"),
1547 Message::text(Role::User, "initial task"),
1548 tool_call("x"),
1549 tool_result("x"),
1550 Message::text(Role::Assistant, "later"),
1551 tool_call("x"),
1552 tool_result("x"),
1553 ]
1554 );
1555 }
1556
1557 #[test]
1560 fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1561 let messages = vec![
1562 Message::text(Role::System, "client instructions"),
1563 tool_call("orphan"),
1564 Message::text(Role::User, "initial task"),
1565 Message::text(Role::Assistant, "old response"),
1566 tool_result("orphan"),
1567 Message::text(Role::Assistant, "recent 1"),
1568 Message::text(Role::User, "recent 2"),
1569 ];
1570
1571 let kept = trim_messages(&messages, 3);
1572
1573 assert_eq!(
1574 kept,
1575 vec![
1576 Message::text(Role::System, "client instructions"),
1577 Message::text(Role::User, "initial task"),
1578 tool_result("orphan"),
1579 Message::text(Role::Assistant, "recent 1"),
1580 Message::text(Role::User, "recent 2"),
1581 ]
1582 );
1583 }
1584
1585 #[test]
1586 fn capability_judge_builds_a_structured_request() -> Result<()> {
1587 let judge = capability_judge(None)?;
1588 let request = Request {
1589 llm_request: LlmRequest {
1590 model: Some("inbound".to_string()),
1591 messages: vec![
1592 Message::text(Role::System, "client instructions"),
1593 Message::text(Role::Developer, "client developer instructions"),
1594 Message::text(Role::User, "initial task"),
1595 Message::text(Role::Assistant, "old response"),
1596 Message::text(Role::User, "old follow-up"),
1597 Message::text(Role::Assistant, "recent 1"),
1598 Message::text(Role::User, "recent 2"),
1599 Message::text(Role::Assistant, "recent 3"),
1600 Message::text(Role::User, "recent 4"),
1601 Message::text(Role::Assistant, "recent 5"),
1602 ],
1603 ..LlmRequest::default()
1604 },
1605 raw_request: None,
1606 metadata: None,
1607 };
1608 let judge_request = judge.build_request(&State::default(), &request);
1609
1610 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1611 assert_eq!(judge_request.llm_request.instructions.len(), 1);
1612 assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
1613 assert_eq!(
1614 judge_request.llm_request.instructions[0].content,
1615 InstructionBlock {
1616 role: Role::System,
1617 content: Message::text(Role::System, judge.contract().system_prompt()).content,
1618 }
1619 .content,
1620 );
1621 assert_eq!(judge_request.llm_request.messages.len(), 2);
1622 let contents = judge_request
1623 .llm_request
1624 .messages
1625 .iter()
1626 .filter_map(|message| message.text_content("\n"))
1627 .collect::<Vec<_>>();
1628 assert!(contents.contains(&"recent 4".to_string()));
1629 assert!(contents.contains(&"initial task".to_string()));
1630 assert!(!contents.contains(&"recent 5".to_string()));
1631 assert!(!contents.contains(&"client instructions".to_string()));
1632 assert_eq!(
1633 judge_request.llm_request.output.response_format,
1634 Some(judge.contract().response_format().clone())
1635 );
1636 assert_eq!(
1637 judge_request.llm_request.output.max_output_tokens,
1638 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1639 );
1640 Ok(())
1641 }
1642
1643 fn sample_value(spec: &Value) -> Value {
1644 if let Some(first) = spec
1645 .get("enum")
1646 .and_then(Value::as_array)
1647 .and_then(|values| values.first())
1648 {
1649 return first.clone();
1650 }
1651 match spec.get("type").and_then(Value::as_str) {
1652 Some("number") => serde_json::json!(0.5),
1653 Some("boolean") => serde_json::json!(false),
1654 _ => serde_json::json!("sample"),
1655 }
1656 }
1657
1658 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1659 let properties = schema
1660 .pointer("/json_schema/schema/properties")
1661 .and_then(Value::as_object)
1662 .ok_or_else(|| LibsyError::AlgorithmError {
1663 message: "packaged schema declares no properties".to_string(),
1664 })?;
1665 Ok(Value::Object(
1666 properties
1667 .iter()
1668 .map(|(name, spec)| (name.clone(), sample_value(spec)))
1669 .collect(),
1670 )
1671 .to_string())
1672 }
1673
1674 #[test]
1677 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1678 let contract =
1679 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1680 let schema = contract.response_format();
1681 let reply = schema_shaped_verdict(schema)?;
1682 let judge: CapabilityJudge = StructuredJudge::new(
1683 TaskInput {
1684 recent_turn_window: None,
1685 },
1686 contract,
1687 SerdeDecoder::new(),
1688 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1689 );
1690
1691 let verdict = judge.parse(&text_response(None, reply))?;
1692
1693 assert!(verdict.is_valid());
1694 assert!((0.0..=1.0).contains(&verdict.p_solve));
1695 Ok(())
1696 }
1697
1698 #[test]
1699 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1700 let contract =
1701 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1702 let prompt = contract.system_prompt();
1703 let schema_name = contract
1704 .response_format()
1705 .pointer("/json_schema/name")
1706 .and_then(Value::as_str)
1707 .ok_or_else(|| LibsyError::AlgorithmError {
1708 message: "packaged response schema has no name".to_string(),
1709 })?;
1710 assert_eq!(schema_name, "CapabilityClassifierDecision");
1711 assert!(prompt.contains("SUP-1 [supported]"));
1712 assert!(prompt.contains("SUP-5 [supported]"));
1713 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1714 assert!(!prompt.contains("\"type\": \"object\""));
1715 assert!(!prompt.contains("\"json_schema\""));
1716 assert!(!prompt.contains(schema_name));
1717 let rule_values = contract
1718 .response_format()
1719 .pointer("/json_schema/schema/properties/primary_rule/enum")
1720 .and_then(Value::as_array)
1721 .ok_or_else(|| LibsyError::AlgorithmError {
1722 message: "rendered response schema has no primary rule enum".to_string(),
1723 })?;
1724 assert!(
1725 rule_values
1726 .iter()
1727 .any(|value| value.as_str() == Some("SUP-1"))
1728 );
1729 assert!(
1730 rule_values
1731 .iter()
1732 .any(|value| value.as_str() == Some("none"))
1733 );
1734 Ok(())
1735 }
1736
1737 use std::collections::VecDeque;
1740
1741 struct Queue(Mutex<VecDeque<String>>);
1743
1744 impl Queue {
1745 fn new(replies: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
1746 Arc::new(Self(Mutex::new(
1747 replies.into_iter().map(String::from).collect(),
1748 )))
1749 }
1750
1751 fn take(&self) -> String {
1752 self.0
1753 .lock()
1754 .pop_front()
1755 .unwrap_or_else(|| "unexpected call".to_string())
1756 }
1757 }
1758
1759 fn queued(model: Arc<Queue>, judge: Arc<Queue>) -> impl Serve {
1762 move |target: ModelId, request: Request| {
1763 let queue = if target == "judge" {
1764 Arc::clone(&judge)
1765 } else {
1766 Arc::clone(&model)
1767 };
1768 async move {
1769 Ok(Response {
1770 llm_response: LlmResponse::Agg(text_response(None, queue.take())),
1771 metadata: request.metadata,
1772 })
1773 }
1774 }
1775 }
1776
1777 fn streamed_then_error(error: LlmClientError) -> Response {
1779 Response {
1780 llm_response: LlmResponse::Stream(Box::pin(futures::stream::iter([
1781 Ok(LlmResponseChunk::TextDelta {
1782 index: 0,
1783 text: "partial".to_string(),
1784 }
1785 .into()),
1786 Err(error),
1787 ]))),
1788 metadata: None,
1789 }
1790 }
1791
1792 fn escalation_router() -> Result<Arc<LlmTaskClassifier>> {
1794 Ok(Arc::new(LlmTaskClassifier::new(
1795 LlmClassifierConfig::Escalation {
1796 judge_target: ModelId::from("judge"),
1797 efficient_target: ModelId::from("efficient"),
1798 capable_target: ModelId::from("capable"),
1799 contract: ClassifierContractConfig::default(),
1800 config: EscalationJudgeConfig {
1801 confirmations: 1,
1802 ..EscalationJudgeConfig::default()
1803 },
1804 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1805 },
1806 )?))
1807 }
1808
1809 #[tokio::test]
1810 async fn escalation_router_serves_efficient_when_judge_declines() -> Result<()> {
1811 let judge = Queue::new([r#"{"escalate":false,"reason":"progressing"}"#]);
1813 let model = Queue::new(["efficient answer"]);
1814 let router = escalation_router()?;
1815
1816 let (trace, response) =
1817 test_drive(router, classify_request(), queued(model, judge)).await?;
1818
1819 assert_eq!(
1821 trace.last().map(|d| d.selected_model_id().as_str()),
1822 Some("efficient")
1823 );
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 (trace, response) =
1863 test_drive(router, classify_request(), queued(model, judge)).await?;
1864
1865 assert_eq!(
1866 trace.last().map(|d| d.selected_model_id().as_str()),
1867 Some("capable")
1868 );
1869 assert_eq!(
1870 response.llm_response.as_agg().map(completion_text),
1871 Some("capable answer".to_string())
1872 );
1873 Ok(())
1874 }
1875
1876 #[tokio::test]
1877 async fn escalation_router_stays_capable_after_latch() -> Result<()> {
1878 let judge = Queue::new([r#"{"escalate":true,"reason":"stuck"}"#]);
1881 let model = Queue::new(["efficient draft", "capable t1", "capable t2"]);
1882 let router = escalation_router()?;
1883
1884 let session_request = classify_session_request();
1885 test_drive(
1886 router.clone(),
1887 session_request.clone(),
1888 queued(Arc::clone(&model), Arc::clone(&judge)),
1889 )
1890 .await?;
1891 let (trace, _) = test_drive(router.clone(), session_request, queued(model, judge)).await?;
1892
1893 assert_eq!(
1894 trace.last().map(|d| d.selected_model_id().as_str()),
1895 Some("capable")
1896 );
1897 Ok(())
1898 }
1899
1900 #[tokio::test]
1901 async fn escalation_classifier_falls_back_to_capable_when_efficient_overflows() -> Result<()> {
1902 let router = escalation_router()?;
1906
1907 let serve = |target: ModelId, _request: Request| async move {
1909 match target.as_str() {
1910 "efficient" => Err(LlmClientError::ContextWindowExceeded {
1911 model: target,
1912 message: "prompt is too long".to_string(),
1913 }),
1914 "judge" => panic!("the judge must not be consulted when efficient overflows"),
1915 _ => Ok(reply("capable answer")),
1916 }
1917 };
1918
1919 let (trace, response) = test_drive(router, classify_request(), serve).await?;
1920
1921 assert_eq!(
1922 trace.last().map(|d| d.selected_model_id().as_str()),
1923 Some("capable")
1924 );
1925 assert_eq!(
1926 response.llm_response.as_agg().map(completion_text),
1927 Some("capable answer".to_string())
1928 );
1929 Ok(())
1930 }
1931
1932 #[tokio::test]
1934 async fn escalation_classifier_falls_back_when_efficient_stream_transport_fails() -> Result<()>
1935 {
1936 let router = escalation_router()?;
1937 let calls = Arc::new(Mutex::new(Vec::new()));
1938 let serve = {
1939 let calls = Arc::clone(&calls);
1940 move |model: ModelId, _request: Request| {
1941 let calls = Arc::clone(&calls);
1942 async move {
1943 let model = model.to_string();
1944 calls.lock().push(model.clone());
1945 match model.as_str() {
1946 "efficient" => Ok(streamed_then_error(LlmClientError::Transport {
1947 source: Box::new(std::io::Error::other("stream disconnected")),
1948 })),
1949 "judge" => {
1950 panic!("the judge must not be consulted after a transport failure")
1951 }
1952 _ => Ok(reply("capable answer")),
1953 }
1954 }
1955 }
1956 };
1957 let mut request = classify_request();
1958 request.llm_request.stream = true;
1959
1960 let result = test_drive(router, request, serve).await;
1961
1962 assert_eq!(&*calls.lock(), &["efficient", "capable"]);
1963 let (_, response) = result?;
1964 assert_eq!(
1965 response.llm_response.as_agg().map(completion_text),
1966 Some("capable answer".to_string())
1967 );
1968 Ok(())
1969 }
1970
1971 #[tokio::test]
1973 async fn escalation_classifier_preserves_non_transport_stream_errors() -> Result<()> {
1974 let router = escalation_router()?;
1975 let serve = |target: ModelId, _request: Request| async move {
1976 match target.as_str() {
1977 "efficient" => Ok(streamed_then_error(LlmClientError::InvalidResponse {
1978 source: Box::new(std::io::Error::other("invalid stream event")),
1979 })),
1980 other => panic!("unexpected call to {other}"),
1981 }
1982 };
1983 let mut request = classify_request();
1984 request.llm_request.stream = true;
1985
1986 match test_drive(router, request, serve).await {
1987 Err(LibsyError::ClientCall {
1988 target,
1989 source: LlmClientError::InvalidResponse { .. },
1990 }) => {
1991 assert_eq!(target, "efficient");
1992 Ok(())
1993 }
1994 Err(other) => panic!("expected InvalidResponse client error, got {other:?}"),
1995 Ok(_) => panic!("expected stream aggregation to fail"),
1996 }
1997 }
1998}