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