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, ClassifyTrigger};
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::{DEFAULT_JUDGE_MAX_OUTPUT_TOKENS, decisive};
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 classify_trigger: ClassifyTrigger,
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 classify_trigger: ClassifyTrigger,
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 classify_trigger: wire.classify_trigger,
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 classify_trigger: ClassifyTrigger::default(),
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.classify_trigger != ClassifyTrigger::NewSession {
346 return Err(LibsyError::AlgorithmError {
347 message: "message_hash_fallback requires classify_trigger = new_session"
348 .to_string(),
349 });
350 }
351 Ok(())
352 }
353}
354
355#[derive(Clone, Debug)]
357pub enum CustomClassifierPolicy {
358 TargetSelector {
360 selector: String,
362 },
363}
364
365impl CustomClassifierPolicy {
366 pub fn target_selector(selector: impl Into<String>) -> Self {
368 Self::TargetSelector {
369 selector: selector.into(),
370 }
371 }
372}
373
374#[derive(Clone, Debug)]
376pub struct CustomClassifierConfig {
377 pub prompt: String,
379 pub response_schema: Value,
381 pub policy: CustomClassifierPolicy,
383 pub classify_trigger: ClassifyTrigger,
385 pub message_hash_fallback: bool,
387 pub recent_turn_window: Option<usize>,
389 pub max_output_tokens: u64,
391}
392
393impl CustomClassifierConfig {
394 pub fn new(
396 prompt: impl Into<String>,
397 response_schema: Value,
398 policy: CustomClassifierPolicy,
399 ) -> Self {
400 Self {
401 prompt: prompt.into(),
402 response_schema,
403 policy,
404 classify_trigger: ClassifyTrigger::default(),
405 message_hash_fallback: false,
406 recent_turn_window: None,
407 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
408 }
409 }
410
411 fn validate(&self) -> Result<()> {
412 if self.max_output_tokens == 0 {
413 return Err(LibsyError::AlgorithmError {
414 message: "max_output_tokens must be at least 1".to_string(),
415 });
416 }
417 if self.message_hash_fallback && self.classify_trigger != ClassifyTrigger::NewSession {
418 return Err(LibsyError::AlgorithmError {
419 message: "message_hash_fallback requires classify_trigger = new_session"
420 .to_string(),
421 });
422 }
423 Ok(())
424 }
425}
426
427enum CustomPolicyRuntime {
428 TargetSelector(TargetSelectorPolicy),
429}
430
431impl JudgePolicy for CustomPolicyRuntime {
432 type Verdict = Value;
433
434 fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
435 match self {
436 Self::TargetSelector(policy) => policy.to_classification(verdict),
437 }
438 }
439}
440
441struct TaskClassifier {
442 classifier: JudgeClassifier<CapabilityJudge, TaskClassifierPolicy>,
443 efficient_target: ModelId,
444 capable_target: ModelId,
445}
446
447const STREAK_KEY: &str = "escalation_streak";
451
452fn streak(state: &State) -> u32 {
453 match state.extra.get(STREAK_KEY) {
454 Some(StateValue::Count(n)) => *n,
455 _ => 0,
456 }
457}
458
459fn assistant_message(response: &AggLlmResponse) -> Message {
460 Message {
461 role: Role::Assistant,
462 content: response
463 .first_output()
464 .map(|output| output.content.clone())
465 .unwrap_or_default(),
466 }
467}
468
469struct EscalationClassifier {
473 judge: JudgeClassifier<EscalationJudge, EscalationPolicy>,
474 capable: ModelId,
475 efficient: ModelId,
476 confirmations: u32,
478}
479
480#[async_trait]
481impl Classifier<State> for EscalationClassifier {
482 fn routing_tier(&self, selected_model_id: &ModelId) -> Option<&'static str> {
483 if self.capable == self.efficient {
484 None
485 } else if *selected_model_id == self.capable {
486 Some("strong")
487 } else if *selected_model_id == self.efficient {
488 Some("weak")
489 } else {
490 None
491 }
492 }
493
494 async fn score(
495 &self,
496 state: &mut State,
497 request: &mut Request,
498 driver: Option<&Driver>,
499 ) -> Result<(Classification, Option<Response>)> {
500 let Some(driver) = driver else {
501 return Err(LibsyError::AlgorithmError {
502 message: "escalation classifier requires a driver".into(),
503 });
504 };
505
506 if streak(state) >= self.confirmations {
508 return Ok((decisive(&self.capable), None));
509 }
510
511 tracing::info!(
516 target = %self.efficient,
517 "escalation classifier selected efficient tier"
518 );
519 let efficient_response = match driver
520 .call_model(request.clone(), vec![self.efficient.clone()])
521 .await
522 {
523 Ok(r) => r,
524 Err(LibsyError::ClientCall {
525 source: LlmClientError::ContextWindowExceeded { .. },
526 ..
527 }) => return Ok((decisive(&self.capable), None)),
528 Err(e) => return Err(e),
529 };
530 let agg = match efficient_response.llm_response.into_agg().await {
533 Ok(agg) => agg,
534 Err(LlmClientError::Transport { .. }) => {
535 return Ok((decisive(&self.capable), None));
536 }
537 Err(source) => {
538 return Err(LibsyError::client_call(self.efficient.clone(), source));
539 }
540 };
541 let mut judge_request = request.clone();
543 judge_request
544 .llm_request
545 .messages
546 .push(assistant_message(&agg));
547 let efficient_response = Response {
548 llm_response: if request.llm_request.stream {
549 LlmResponse::Stream(agg.into_stream())
550 } else {
551 LlmResponse::Agg(agg)
552 },
553 metadata: efficient_response.metadata,
554 };
555
556 let (classification, _) = self
557 .judge
558 .score(state, &mut judge_request, Some(driver))
559 .await?;
560
561 let held = streak(state);
562 let best = classification.argmax(false)?;
563 let (escalate, pending) = match &best {
564 Some(score) if score.target == self.capable => (true, held + 1),
565 Some(_) => (false, 0),
566 None => (false, held),
567 };
568 state
569 .extra
570 .insert(STREAK_KEY.to_string(), StateValue::Count(pending));
571
572 if escalate && pending >= self.confirmations {
573 return Ok((decisive(&self.capable), None));
575 }
576
577 Ok((decisive(&self.efficient), Some(efficient_response)))
578 }
579}
580
581fn affinity_router(
583 trigger: ClassifyTrigger,
584 message_hash_fallback: bool,
585) -> Option<Arc<AffinityRouter>> {
586 let router = match trigger {
587 ClassifyTrigger::EveryRequest => return None,
588 ClassifyTrigger::NewSession => AffinityRouter::new(),
589 ClassifyTrigger::UserTurn => AffinityRouter::new().with_release_on_user_turn(),
590 };
591 let router = if message_hash_fallback {
592 router.with_message_hash_fallback()
593 } else {
594 router
595 };
596 Some(Arc::new(router))
597}
598
599pub struct LlmTaskClassifier {
601 route: FallThrough<State>,
602 inner: Arc<dyn Classifier<State>>,
604}
605
606struct ClassifierRouteConfig {
607 default_target: ModelId,
608 classify_trigger: ClassifyTrigger,
609 message_hash_fallback: bool,
610}
611
612#[derive(Clone)]
614#[non_exhaustive]
615pub enum LlmClassifierConfig {
616 Capability {
618 judge_target: ModelId,
620 efficient_target: ModelId,
622 capable_target: ModelId,
624 config: TaskClassifierConfig,
626 },
627 Escalation {
629 judge_target: ModelId,
631 efficient_target: ModelId,
633 capable_target: ModelId,
635 contract: ClassifierContractConfig,
637 config: EscalationJudgeConfig,
639 max_output_tokens: u64,
641 },
642 Custom {
644 judge_target: ModelId,
646 targets: Vec<(String, ModelId)>,
648 default_target: String,
650 config: CustomClassifierConfig,
652 },
653}
654
655impl LlmTaskClassifier {
656 pub fn new(config: LlmClassifierConfig) -> Result<Self> {
663 match config {
664 LlmClassifierConfig::Capability {
665 judge_target,
666 efficient_target,
667 capable_target,
668 config,
669 } => Self::build_capability(judge_target, efficient_target, capable_target, config),
670 LlmClassifierConfig::Escalation {
671 judge_target,
672 efficient_target,
673 capable_target,
674 contract,
675 config,
676 max_output_tokens,
677 } => Self::build_escalation(
678 judge_target,
679 efficient_target,
680 capable_target,
681 contract,
682 config,
683 max_output_tokens,
684 ),
685 LlmClassifierConfig::Custom {
686 judge_target,
687 targets,
688 default_target,
689 config,
690 } => Self::build_custom(judge_target, targets, default_target, config),
691 }
692 }
693
694 fn build_capability(
695 judge_target: ModelId,
696 efficient_target: ModelId,
697 capable_target: ModelId,
698 config: TaskClassifierConfig,
699 ) -> Result<Self> {
700 config.validate()?;
701 let contract = Self::load_capability_contract(&config.contract)?;
702 let targets = vec![efficient_target.clone(), capable_target.clone()];
703 let classify_trigger = config.classify_trigger;
704 let message_hash_fallback = config.message_hash_fallback;
705 let classifier = Arc::new(TaskClassifier {
706 classifier: JudgeClassifier::new(
707 StructuredJudge::new(
708 TaskInput {
709 recent_turn_window: config.recent_turn_window,
710 },
711 contract,
712 SerdeDecoder::new(),
713 JudgeRuntimeConfig::new(config.max_output_tokens)?,
714 ),
715 judge_target.clone(),
716 TaskClassifierPolicy::new(
717 efficient_target.clone(),
718 capable_target.clone(),
719 &config,
720 ),
721 ),
722 efficient_target: efficient_target.clone(),
723 capable_target: capable_target.clone(),
724 });
725 let inner: Arc<dyn Classifier<State>> = classifier.clone();
726 Self::from_classifier(
727 targets,
728 inner,
729 ClassifierRouteConfig {
730 default_target: classifier.capable_target.clone(),
731 classify_trigger,
732 message_hash_fallback,
733 },
734 )
735 }
736
737 fn build_custom(
738 judge_target: ModelId,
739 targets: Vec<(String, ModelId)>,
740 default_target: String,
741 config: CustomClassifierConfig,
742 ) -> Result<Self> {
743 config.validate()?;
744 if targets.len() < 2 {
745 return Err(LibsyError::AlgorithmError {
746 message: "custom classifier requires at least two targets".to_string(),
747 });
748 }
749
750 let mut labels = BTreeSet::new();
751 let mut resolved_names = BTreeSet::new();
752 let mut target_map = BTreeMap::new();
753 let mut resolved_targets = Vec::with_capacity(targets.len());
754 for (label, target) in targets {
755 if label.trim().is_empty() || label.trim() != label {
756 return Err(LibsyError::AlgorithmError {
757 message: "custom classifier target labels must be non-empty and have no surrounding whitespace"
758 .to_string(),
759 });
760 }
761 if !labels.insert(label.clone()) {
762 return Err(LibsyError::AlgorithmError {
763 message: format!("custom classifier target label {label:?} is duplicated"),
764 });
765 }
766 if !resolved_names.insert(target.clone()) {
767 return Err(LibsyError::AlgorithmError {
768 message: format!("custom classifier resolved target {target:?} is duplicated"),
769 });
770 }
771 target_map.insert(label, target.clone());
772 resolved_targets.push(target);
773 }
774 let default_name =
775 target_map
776 .get(&default_target)
777 .cloned()
778 .ok_or_else(|| LibsyError::AlgorithmError {
779 message: format!(
780 "default_target {default_target:?} must be one of the configured targets"
781 ),
782 })?;
783
784 let CustomClassifierConfig {
785 prompt,
786 response_schema,
787 policy,
788 classify_trigger,
789 message_hash_fallback,
790 recent_turn_window,
791 max_output_tokens,
792 } = config;
793 let contract = ClassifierContract::from_inner_schema(&prompt, response_schema)?;
794 let policy = match policy {
795 CustomClassifierPolicy::TargetSelector { selector } => {
796 CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(
797 selector, target_map,
798 )?)
799 }
800 };
801 let classifier: Arc<dyn Classifier<State>> = Arc::new(JudgeClassifier::new(
802 StructuredJudge::new(
803 TaskInput { recent_turn_window },
804 contract,
805 JsonSchemaDecoder::new(),
806 JudgeRuntimeConfig::new(max_output_tokens)?,
807 ),
808 judge_target,
809 policy,
810 ));
811
812 Self::from_classifier(
813 resolved_targets,
814 classifier,
815 ClassifierRouteConfig {
816 default_target: default_name,
817 classify_trigger,
818 message_hash_fallback,
819 },
820 )
821 }
822
823 fn build_escalation(
824 judge_target: ModelId,
825 efficient_target: ModelId,
826 capable_target: ModelId,
827 contract_config: ClassifierContractConfig,
828 config: EscalationJudgeConfig,
829 max_output_tokens: u64,
830 ) -> Result<Self> {
831 let capable_name = capable_target.clone();
832 let efficient_name = efficient_target.clone();
833 let confirmations = config.confirmations;
834 let esc = Arc::new(EscalationClassifier {
835 judge: escalation::build_judge(
836 judge_target,
837 capable_name,
838 efficient_name,
839 &contract_config,
840 config,
841 max_output_tokens,
842 )?,
843 capable: capable_target.clone(),
844 efficient: efficient_target.clone(),
845 confirmations,
846 });
847 let inner: Arc<dyn Classifier<State>> = esc.clone();
848 let targets = vec![capable_target, efficient_target];
849 Ok(Self {
850 route: FallThrough::<State>::new_with_state(targets)
851 .with_name(ALGORITHM_NAME)
852 .with_classifier(esc),
853 inner,
854 })
855 }
856
857 fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
859 ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
860 }
861
862 fn from_classifier(
864 targets: Vec<ModelId>,
865 inner: Arc<dyn Classifier<State>>,
866 config: ClassifierRouteConfig,
867 ) -> Result<Self> {
868 algorithm::ensure_model_is_target(&targets, &config.default_target)?;
869 if config.message_hash_fallback && config.classify_trigger != ClassifyTrigger::NewSession {
870 return Err(LibsyError::AlgorithmError {
871 message: "message_hash_fallback requires classify_trigger = new_session"
872 .to_string(),
873 });
874 }
875 let mut route = FallThrough::<State>::new_with_state(targets).with_name(ALGORITHM_NAME);
877 if let Some(affinity) =
878 affinity_router(config.classify_trigger, config.message_hash_fallback).as_ref()
879 {
880 route = route
882 .with_processor(affinity.clone())
883 .with_classifier(affinity.clone());
884 }
885 let fallback = DefaultTarget::new(config.default_target);
886 Ok(Self {
887 route: route
888 .with_classifier(inner.clone())
889 .with_classifier(Arc::new(fallback)),
890 inner,
891 })
892 }
893}
894
895#[async_trait]
896impl Classifier<State> for TaskClassifier {
897 fn routing_tier(&self, selected_model_id: &ModelId) -> Option<&'static str> {
898 if self.efficient_target == self.capable_target {
899 None
900 } else if *selected_model_id == self.efficient_target {
901 Some("weak")
902 } else if *selected_model_id == self.capable_target {
903 Some("strong")
904 } else {
905 None
906 }
907 }
908
909 async fn score(
910 &self,
911 state: &mut State,
912 request: &mut Request,
913 driver: Option<&Driver>,
914 ) -> Result<(Classification, Option<Response>)> {
915 self.classifier.score(state, request, driver).await
916 }
917}
918
919#[async_trait]
920impl Classifier<State> for LlmTaskClassifier {
921 fn routing_tier(&self, selected_model_id: &ModelId) -> Option<&'static str> {
922 self.inner.routing_tier(selected_model_id)
923 }
924
925 async fn score(
926 &self,
927 state: &mut State,
928 request: &mut Request,
929 driver: Option<&Driver>,
930 ) -> Result<(Classification, Option<Response>)> {
931 self.inner.score(state, request, driver).await
932 }
933}
934
935#[async_trait]
936impl Algorithm for LlmTaskClassifier {
937 fn name(&self) -> &str {
938 "llm_task_classifier"
939 }
940
941 async fn route(
942 self: Arc<Self>,
943 driver: Driver,
944 request: Request,
945 ) -> Result<crate::RoutingOutcome> {
946 self.route.execute(driver, request).await
947 }
948}
949
950#[cfg(test)]
951mod tests {
952 use std::sync::Arc;
953
954 use parking_lot::Mutex;
955 use serde_json::Value;
956
957 use super::*;
958 use switchyard_protocol::{
959 ContentBlock, InstructionBlock, LlmClientError, LlmRequest, LlmResponseChunk, Metadata,
960 ToolCall, ToolResult, completion_text, text_request, text_response,
961 };
962
963 use crate::algorithms::util::llm_judge::Judge;
964 use crate::core::testing::{Serve, reply, test_drive};
965 use switchyard_protocol::{LlmResponse, Response};
966
967 const TEST_THRESHOLD: f64 = 0.5;
968
969 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
970 TaskClassifierConfig {
971 base_threshold,
972 ..TaskClassifierConfig::default()
973 }
974 }
975
976 fn policy() -> TaskClassifierPolicy {
977 TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
978 }
979
980 fn verdict(
981 p_solve: f64,
982 capability_boundary: &str,
983 primary_rule: &str,
984 ) -> TaskClassifierVerdict {
985 TaskClassifierVerdict {
986 crux: "test crux".to_string(),
987 primary_rule: primary_rule.to_string(),
988 capability_boundary: capability_boundary.to_string(),
989 p_solve,
990 }
991 }
992
993 fn selected(
994 policy: &TaskClassifierPolicy,
995 verdict: Option<&TaskClassifierVerdict>,
996 ) -> Result<ModelId> {
997 policy
998 .to_classification(verdict)
999 .argmax(false)?
1000 .map(|score| score.target)
1001 .ok_or_else(|| LibsyError::AlgorithmError {
1002 message: "policy abstained".to_string(),
1003 })
1004 }
1005
1006 #[derive(Default)]
1009 struct Recorder {
1010 calls: Mutex<Vec<String>>,
1011 call_roles: Mutex<Vec<(String, bool)>>,
1012 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
1013 judge_system_prompts: Mutex<Vec<String>>,
1014 }
1015
1016 impl Recorder {
1017 fn calls(&self) -> Vec<String> {
1018 self.calls.lock().clone()
1019 }
1020
1021 fn call_roles(&self) -> Vec<(String, bool)> {
1022 self.call_roles.lock().clone()
1023 }
1024
1025 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
1026 self.judge_max_output_tokens.lock().clone()
1027 }
1028
1029 fn judge_system_prompts(&self) -> Vec<String> {
1030 self.judge_system_prompts.lock().clone()
1031 }
1032
1033 fn serve(self: &Arc<Self>) -> impl Serve {
1034 let recorder = Arc::clone(self);
1035 move |model: ModelId, request: Request| {
1036 let recorder = Arc::clone(&recorder);
1037 async move {
1038 let model = model.to_string();
1039 recorder.calls.lock().push(model.clone());
1040 recorder
1041 .call_roles
1042 .lock()
1043 .push((model.clone(), model != "judge"));
1044 let completion = if model == "judge" {
1045 recorder
1046 .judge_max_output_tokens
1047 .lock()
1048 .push(request.llm_request.output.max_output_tokens);
1049 recorder.judge_system_prompts.lock().extend(
1050 request
1051 .llm_request
1052 .instructions
1053 .first()
1054 .and_then(|instruction| {
1055 instruction.content.iter().find_map(|b| {
1056 if let ContentBlock::Text { text } = b {
1057 Some(text.clone())
1058 } else {
1059 None
1060 }
1061 })
1062 }),
1063 );
1064 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1065 } else {
1066 format!("answer from {model}")
1067 };
1068 Ok(Response {
1069 llm_response: LlmResponse::Agg(text_response(None, completion)),
1070 metadata: request.metadata,
1071 })
1072 }
1073 }
1074 }
1075 }
1076
1077 fn unreachable_judge() -> impl Serve {
1079 |model: ModelId, request: Request| async move {
1080 let model = model.to_string();
1081 if model == "judge" {
1082 return Err(LlmClientError::Timeout {
1083 source: Box::new(std::io::Error::other("judge unreachable")),
1084 });
1085 }
1086 Ok(Response {
1087 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1088 metadata: request.metadata,
1089 })
1090 }
1091 }
1092
1093 fn router() -> Result<Arc<LlmTaskClassifier>> {
1094 Ok(Arc::new(LlmTaskClassifier::new(
1095 LlmClassifierConfig::Capability {
1096 judge_target: ModelId::from("judge"),
1097 efficient_target: ModelId::from("efficient"),
1098 capable_target: ModelId::from("capable"),
1099 config: test_config(TEST_THRESHOLD),
1100 },
1101 )?))
1102 }
1103
1104 fn classify_request() -> Request {
1105 Request {
1106 llm_request: text_request(Some("auto".to_string()), "classify this task"),
1107 raw_request: None,
1108 metadata: None,
1109 }
1110 }
1111
1112 fn classify_session_request() -> Request {
1113 Request {
1114 metadata: Some(Metadata {
1115 session_id: Some("session-1".to_string()),
1116 ..Metadata::default()
1117 }),
1118 ..classify_request()
1119 }
1120 }
1121
1122 fn classify_follow_up_request() -> Request {
1123 let mut request = classify_request();
1124 request
1125 .llm_request
1126 .messages
1127 .push(Message::text(Role::Assistant, "I will add the test."));
1128 request.llm_request.messages.push(Message::text(
1129 Role::User,
1130 "Now run the test suite and report the result.",
1131 ));
1132 request
1133 }
1134
1135 #[tokio::test]
1136 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1137 let router = router()?;
1138
1139 let (selected_model, response) =
1140 test_drive(router, classify_request(), unreachable_judge()).await?;
1141
1142 assert_eq!(selected_model, "capable");
1143 assert_eq!(
1144 response.llm_response.as_agg().map(completion_text),
1145 Some("answer from capable".to_string())
1146 );
1147 Ok(())
1148 }
1149
1150 #[tokio::test]
1151 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1152 let recorder = Arc::new(Recorder::default());
1153 let router = router()?;
1154 let request = classify_request;
1155
1156 test_drive(router.clone(), request(), recorder.serve()).await?;
1157 test_drive(router.clone(), request(), recorder.serve()).await?;
1158
1159 assert_eq!(
1160 recorder.calls(),
1161 vec!["judge", "efficient", "judge", "efficient"]
1162 );
1163 assert_eq!(
1164 recorder.call_roles(),
1165 vec![
1166 ("judge".to_string(), false),
1167 ("efficient".to_string(), true),
1168 ("judge".to_string(), false),
1169 ("efficient".to_string(), true),
1170 ]
1171 );
1172 Ok(())
1173 }
1174
1175 #[tokio::test]
1176 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1177 let recorder = Arc::new(Recorder::default());
1178 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1179 judge_target: ModelId::from("judge"),
1180 efficient_target: ModelId::from("efficient"),
1181 capable_target: ModelId::from("capable"),
1182 config: TaskClassifierConfig {
1183 max_output_tokens: 512,
1184 ..test_config(TEST_THRESHOLD)
1185 },
1186 })?);
1187
1188 test_drive(router, classify_request(), recorder.serve()).await?;
1189
1190 assert_eq!(recorder.judge_max_output_tokens(), vec![Some(512)]);
1191 Ok(())
1192 }
1193
1194 #[tokio::test]
1195 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1196 let recorder = Arc::new(Recorder::default());
1197 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1198 judge_target: ModelId::from("judge"),
1199 efficient_target: ModelId::from("efficient"),
1200 capable_target: ModelId::from("capable"),
1201 config: TaskClassifierConfig {
1202 contract: ClassifierContractConfig::default()
1203 .with_prompt("Custom capability rubric."),
1204 ..test_config(TEST_THRESHOLD)
1205 },
1206 })?);
1207
1208 test_drive(router, classify_request(), recorder.serve()).await?;
1209
1210 let prompts = recorder.judge_system_prompts();
1211 assert_eq!(prompts.len(), 1);
1212 assert_eq!(prompts[0], "Custom capability rubric.");
1213 Ok(())
1214 }
1215
1216 #[tokio::test]
1217 async fn classifier_config_enables_new_session_trigger() -> Result<()> {
1218 let recorder = Arc::new(Recorder::default());
1219 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1220 judge_target: ModelId::from("judge"),
1221 efficient_target: ModelId::from("efficient"),
1222 capable_target: ModelId::from("capable"),
1223 config: TaskClassifierConfig {
1224 classify_trigger: ClassifyTrigger::NewSession,
1225 ..test_config(TEST_THRESHOLD)
1226 },
1227 })?);
1228
1229 let session_request = classify_session_request;
1230 test_drive(router.clone(), session_request(), recorder.serve()).await?;
1231 test_drive(router.clone(), session_request(), recorder.serve()).await?;
1232
1233 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1234 Ok(())
1235 }
1236
1237 #[tokio::test]
1238 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1239 let recorder = Arc::new(Recorder::default());
1240 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1241 judge_target: ModelId::from("judge"),
1242 efficient_target: ModelId::from("efficient"),
1243 capable_target: ModelId::from("capable"),
1244 config: TaskClassifierConfig {
1245 classify_trigger: ClassifyTrigger::NewSession,
1246 message_hash_fallback: true,
1247 recent_turn_window: None,
1248 ..test_config(TEST_THRESHOLD)
1249 },
1250 })?);
1251
1252 test_drive(router.clone(), classify_request(), recorder.serve()).await?;
1253 test_drive(
1254 router.clone(),
1255 classify_follow_up_request(),
1256 recorder.serve(),
1257 )
1258 .await?;
1259
1260 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1261 Ok(())
1262 }
1263
1264 #[test]
1265 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1266 let policy = policy();
1267 let at_threshold = verdict(0.5, "supported", "SUP-1");
1268 let below_threshold = verdict(0.49, "supported", "SUP-1");
1269 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1270 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1271 Ok(())
1272 }
1273
1274 #[test]
1275 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1276 let borderline = verdict(0.5, "supported", "SUP-1");
1277 let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1278 let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1279 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1280 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1281 Ok(())
1282 }
1283
1284 #[test]
1285 fn classifier_config_rejects_unknown_fields() {
1286 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1287 "base_threshold": 0.5,
1288 "classifier_magic": true,
1289 }))
1290 .expect_err("unknown classifier fields must be rejected");
1291
1292 assert!(
1293 error
1294 .to_string()
1295 .contains("unknown field `classifier_magic`"),
1296 "{error}"
1297 );
1298 }
1299
1300 #[test]
1301 fn invalid_classifier_config_is_rejected() -> Result<()> {
1302 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1303 assert!(
1304 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1305 judge_target: ModelId::from("judge"),
1306 efficient_target: ModelId::from("e"),
1307 capable_target: ModelId::from("c"),
1308 config: test_config(bad),
1309 })
1310 .is_err(),
1311 "base threshold {bad} should be rejected"
1312 );
1313 }
1314 for config in [
1315 TaskClassifierConfig {
1316 base_threshold: 0.5,
1317 threshold_step: -0.1,
1318 ..TaskClassifierConfig::default()
1319 },
1320 TaskClassifierConfig {
1321 base_threshold: 0.8,
1322 threshold_step: 0.11,
1323 ..TaskClassifierConfig::default()
1324 },
1325 TaskClassifierConfig {
1326 base_threshold: 0.5,
1327 message_hash_fallback: true,
1328 ..TaskClassifierConfig::default()
1329 },
1330 TaskClassifierConfig {
1331 base_threshold: 0.5,
1332 max_output_tokens: 0,
1333 ..TaskClassifierConfig::default()
1334 },
1335 ] {
1336 assert!(
1337 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1338 judge_target: ModelId::from("judge"),
1339 efficient_target: ModelId::from("e"),
1340 capable_target: ModelId::from("c"),
1341 config,
1342 })
1343 .is_err()
1344 );
1345 }
1346 for base_threshold in [0.0, 1.0] {
1347 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1348 judge_target: ModelId::from("judge"),
1349 efficient_target: ModelId::from("e"),
1350 capable_target: ModelId::from("c"),
1351 config: test_config(base_threshold),
1352 })?;
1353 }
1354 Ok(())
1355 }
1356
1357 #[test]
1358 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1359 let policy = policy();
1360 let inconsistent_rule = TaskClassifierVerdict {
1361 capability_boundary: "uncertain".to_string(),
1362 ..verdict(1.0, "supported", "SUP-1")
1363 };
1364 let empty_crux = TaskClassifierVerdict {
1365 crux: " ".to_string(),
1366 ..verdict(1.0, "supported", "SUP-1")
1367 };
1368 let unusable = [
1369 Some(verdict(1.1, "supported", "SUP-1")),
1370 Some(inconsistent_rule),
1371 Some(empty_crux),
1372 None,
1373 ];
1374 for verdict in unusable {
1375 let classification = policy.to_classification(verdict.as_ref());
1376 assert!(matches!(classification, Classification::Ambiguous(_)));
1377 assert!(classification.argmax(false)?.is_none());
1378 assert!(classification.argmax(true)?.is_none());
1379 }
1380 Ok(())
1381 }
1382
1383 #[test]
1384 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1385 let policy = TaskClassifierPolicy::new(
1386 "efficient",
1387 "capable",
1388 &TaskClassifierConfig {
1389 threshold_step: 0.1,
1390 ..test_config(0.4)
1391 },
1392 );
1393
1394 assert_eq!(
1395 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1396 "efficient"
1397 );
1398 assert_eq!(
1399 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1400 "capable"
1401 );
1402 assert_eq!(
1403 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1404 "efficient"
1405 );
1406 assert_eq!(
1407 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1408 "efficient"
1409 );
1410 assert_eq!(
1411 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1412 "capable"
1413 );
1414 assert_eq!(
1415 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1416 "efficient"
1417 );
1418 Ok(())
1419 }
1420
1421 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1424 Ok(StructuredJudge::new(
1425 TaskInput { recent_turn_window },
1426 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1427 SerdeDecoder::new(),
1428 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1429 ))
1430 }
1431
1432 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1433 let judge = capability_judge(Some(recent_turn_window))?;
1434 let request = Request {
1435 llm_request: LlmRequest {
1436 messages: vec![
1437 Message::text(Role::System, "client instructions"),
1438 Message::text(Role::User, "initial task"),
1439 Message::text(Role::Assistant, "old response"),
1440 Message::text(Role::User, "old follow-up"),
1441 Message::text(Role::Assistant, "recent 1"),
1442 Message::text(Role::User, "recent 2"),
1443 ],
1444 ..LlmRequest::default()
1445 },
1446 raw_request: None,
1447 metadata: None,
1448 };
1449 Ok(judge
1450 .build_request(&State::default(), &request)
1451 .llm_request
1452 .messages
1453 .iter()
1454 .filter_map(|message| message.text_content("\n"))
1455 .collect())
1456 }
1457
1458 #[test]
1459 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1460 let contents = judged_contents(2)?;
1462 assert!(contents.contains(&"client instructions".to_string()));
1463 assert!(contents.contains(&"initial task".to_string()));
1464 assert!(contents.contains(&"recent 1".to_string()));
1465 assert!(contents.contains(&"recent 2".to_string()));
1466 assert!(!contents.contains(&"old response".to_string()));
1467 Ok(())
1468 }
1469
1470 #[test]
1471 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1472 let contents = judged_contents(0)?;
1473 assert!(contents.contains(&"client instructions".to_string()));
1474 assert!(contents.contains(&"initial task".to_string()));
1475 assert!(!contents.contains(&"recent 2".to_string()));
1476 Ok(())
1477 }
1478
1479 fn tool_call(id: &str) -> Message {
1480 Message {
1481 role: Role::Assistant,
1482 content: vec![ContentBlock::ToolCall(ToolCall {
1483 id: id.to_string(),
1484 name: "search".to_string(),
1485 arguments: Value::Null,
1486 })],
1487 }
1488 }
1489
1490 fn tool_result(id: &str) -> Message {
1491 Message {
1492 role: Role::Tool,
1493 content: vec![ContentBlock::ToolResult(ToolResult {
1494 tool_call_id: id.to_string(),
1495 content: vec![ContentBlock::Text {
1496 text: "tool output".to_string(),
1497 }],
1498 is_error: None,
1499 })],
1500 }
1501 }
1502
1503 #[test]
1506 fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1507 let messages = vec![
1508 Message::text(Role::System, "client instructions"),
1509 Message::text(Role::User, "initial task"),
1510 Message::text(Role::Assistant, "old response"),
1511 tool_call("call-1"),
1512 tool_result("call-1"),
1513 Message::text(Role::Assistant, "recent 1"),
1514 Message::text(Role::User, "recent 2"),
1515 Message::text(Role::Assistant, "recent 3"),
1516 Message::text(Role::User, "recent 4"),
1517 ];
1518
1519 let kept = trim_messages(&messages, 5);
1521
1522 assert_eq!(
1523 kept,
1524 vec![
1525 Message::text(Role::System, "client instructions"),
1526 Message::text(Role::User, "initial task"),
1527 tool_call("call-1"),
1528 tool_result("call-1"),
1529 Message::text(Role::Assistant, "recent 1"),
1530 Message::text(Role::User, "recent 2"),
1531 Message::text(Role::Assistant, "recent 3"),
1532 Message::text(Role::User, "recent 4"),
1533 ]
1534 );
1535 }
1536
1537 #[test]
1540 fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1541 let messages = vec![
1542 Message::text(Role::System, "client instructions"),
1543 Message::text(Role::User, "initial task"),
1544 tool_call("x"),
1545 tool_result("x"),
1546 Message::text(Role::Assistant, "later"),
1547 tool_call("x"),
1548 tool_result("x"),
1549 ];
1550
1551 let kept = trim_messages(&messages, 4);
1553
1554 assert_eq!(
1555 kept,
1556 vec![
1557 Message::text(Role::System, "client instructions"),
1558 Message::text(Role::User, "initial task"),
1559 tool_call("x"),
1560 tool_result("x"),
1561 Message::text(Role::Assistant, "later"),
1562 tool_call("x"),
1563 tool_result("x"),
1564 ]
1565 );
1566 }
1567
1568 #[test]
1571 fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1572 let messages = vec![
1573 Message::text(Role::System, "client instructions"),
1574 tool_call("orphan"),
1575 Message::text(Role::User, "initial task"),
1576 Message::text(Role::Assistant, "old response"),
1577 tool_result("orphan"),
1578 Message::text(Role::Assistant, "recent 1"),
1579 Message::text(Role::User, "recent 2"),
1580 ];
1581
1582 let kept = trim_messages(&messages, 3);
1583
1584 assert_eq!(
1585 kept,
1586 vec![
1587 Message::text(Role::System, "client instructions"),
1588 Message::text(Role::User, "initial task"),
1589 tool_result("orphan"),
1590 Message::text(Role::Assistant, "recent 1"),
1591 Message::text(Role::User, "recent 2"),
1592 ]
1593 );
1594 }
1595
1596 #[test]
1597 fn capability_judge_builds_a_structured_request() -> Result<()> {
1598 let judge = capability_judge(None)?;
1599 let request = Request {
1600 llm_request: LlmRequest {
1601 model: Some("inbound".to_string()),
1602 messages: vec![
1603 Message::text(Role::System, "client instructions"),
1604 Message::text(Role::Developer, "client developer instructions"),
1605 Message::text(Role::User, "initial task"),
1606 Message::text(Role::Assistant, "old response"),
1607 Message::text(Role::User, "old follow-up"),
1608 Message::text(Role::Assistant, "recent 1"),
1609 Message::text(Role::User, "recent 2"),
1610 Message::text(Role::Assistant, "recent 3"),
1611 Message::text(Role::User, "recent 4"),
1612 Message::text(Role::Assistant, "recent 5"),
1613 ],
1614 ..LlmRequest::default()
1615 },
1616 raw_request: None,
1617 metadata: None,
1618 };
1619 let judge_request = judge.build_request(&State::default(), &request);
1620
1621 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1622 assert_eq!(judge_request.llm_request.instructions.len(), 1);
1623 assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
1624 assert_eq!(
1625 judge_request.llm_request.instructions[0].content,
1626 InstructionBlock {
1627 role: Role::System,
1628 content: Message::text(Role::System, judge.contract().system_prompt()).content,
1629 }
1630 .content,
1631 );
1632 assert_eq!(judge_request.llm_request.messages.len(), 2);
1633 let contents = judge_request
1634 .llm_request
1635 .messages
1636 .iter()
1637 .filter_map(|message| message.text_content("\n"))
1638 .collect::<Vec<_>>();
1639 assert!(contents.contains(&"recent 4".to_string()));
1640 assert!(contents.contains(&"initial task".to_string()));
1641 assert!(!contents.contains(&"recent 5".to_string()));
1642 assert!(!contents.contains(&"client instructions".to_string()));
1643 assert_eq!(
1644 judge_request.llm_request.output.response_format,
1645 Some(judge.contract().response_format().clone())
1646 );
1647 assert_eq!(
1648 judge_request.llm_request.output.max_output_tokens,
1649 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1650 );
1651 Ok(())
1652 }
1653
1654 fn sample_value(spec: &Value) -> Value {
1655 if let Some(first) = spec
1656 .get("enum")
1657 .and_then(Value::as_array)
1658 .and_then(|values| values.first())
1659 {
1660 return first.clone();
1661 }
1662 match spec.get("type").and_then(Value::as_str) {
1663 Some("number") => serde_json::json!(0.5),
1664 Some("boolean") => serde_json::json!(false),
1665 _ => serde_json::json!("sample"),
1666 }
1667 }
1668
1669 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1670 let properties = schema
1671 .pointer("/json_schema/schema/properties")
1672 .and_then(Value::as_object)
1673 .ok_or_else(|| LibsyError::AlgorithmError {
1674 message: "packaged schema declares no properties".to_string(),
1675 })?;
1676 Ok(Value::Object(
1677 properties
1678 .iter()
1679 .map(|(name, spec)| (name.clone(), sample_value(spec)))
1680 .collect(),
1681 )
1682 .to_string())
1683 }
1684
1685 #[test]
1688 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1689 let contract =
1690 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1691 let schema = contract.response_format();
1692 let reply = schema_shaped_verdict(schema)?;
1693 let judge: CapabilityJudge = StructuredJudge::new(
1694 TaskInput {
1695 recent_turn_window: None,
1696 },
1697 contract,
1698 SerdeDecoder::new(),
1699 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1700 );
1701
1702 let verdict = judge.parse(&text_response(None, reply))?;
1703
1704 assert!(verdict.is_valid());
1705 assert!((0.0..=1.0).contains(&verdict.p_solve));
1706 Ok(())
1707 }
1708
1709 #[test]
1710 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1711 let contract =
1712 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1713 let prompt = contract.system_prompt();
1714 let schema_name = contract
1715 .response_format()
1716 .pointer("/json_schema/name")
1717 .and_then(Value::as_str)
1718 .ok_or_else(|| LibsyError::AlgorithmError {
1719 message: "packaged response schema has no name".to_string(),
1720 })?;
1721 assert_eq!(schema_name, "CapabilityClassifierDecision");
1722 assert!(prompt.contains("SUP-1 [supported]"));
1723 assert!(prompt.contains("SUP-5 [supported]"));
1724 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1725 assert!(!prompt.contains("\"type\": \"object\""));
1726 assert!(!prompt.contains("\"json_schema\""));
1727 assert!(!prompt.contains(schema_name));
1728 let rule_values = contract
1729 .response_format()
1730 .pointer("/json_schema/schema/properties/primary_rule/enum")
1731 .and_then(Value::as_array)
1732 .ok_or_else(|| LibsyError::AlgorithmError {
1733 message: "rendered response schema has no primary rule enum".to_string(),
1734 })?;
1735 assert!(
1736 rule_values
1737 .iter()
1738 .any(|value| value.as_str() == Some("SUP-1"))
1739 );
1740 assert!(
1741 rule_values
1742 .iter()
1743 .any(|value| value.as_str() == Some("none"))
1744 );
1745 Ok(())
1746 }
1747
1748 use std::collections::VecDeque;
1751
1752 struct Queue(Mutex<VecDeque<String>>);
1754
1755 impl Queue {
1756 fn new(replies: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
1757 Arc::new(Self(Mutex::new(
1758 replies.into_iter().map(String::from).collect(),
1759 )))
1760 }
1761
1762 fn take(&self) -> String {
1763 self.0
1764 .lock()
1765 .pop_front()
1766 .unwrap_or_else(|| "unexpected call".to_string())
1767 }
1768 }
1769
1770 fn queued(model: Arc<Queue>, judge: Arc<Queue>) -> impl Serve {
1773 move |target: ModelId, request: Request| {
1774 let queue = if target == "judge" {
1775 Arc::clone(&judge)
1776 } else {
1777 Arc::clone(&model)
1778 };
1779 async move {
1780 Ok(Response {
1781 llm_response: LlmResponse::Agg(text_response(None, queue.take())),
1782 metadata: request.metadata,
1783 })
1784 }
1785 }
1786 }
1787
1788 fn streamed_then_error(error: LlmClientError) -> Response {
1790 Response {
1791 llm_response: LlmResponse::Stream(Box::pin(futures::stream::iter([
1792 Ok(LlmResponseChunk::TextDelta {
1793 index: 0,
1794 text: "partial".to_string(),
1795 }
1796 .into()),
1797 Err(error),
1798 ]))),
1799 metadata: None,
1800 }
1801 }
1802
1803 fn escalation_router() -> Result<Arc<LlmTaskClassifier>> {
1805 Ok(Arc::new(LlmTaskClassifier::new(
1806 LlmClassifierConfig::Escalation {
1807 judge_target: ModelId::from("judge"),
1808 efficient_target: ModelId::from("efficient"),
1809 capable_target: ModelId::from("capable"),
1810 contract: ClassifierContractConfig::default(),
1811 config: EscalationJudgeConfig {
1812 confirmations: 1,
1813 ..EscalationJudgeConfig::default()
1814 },
1815 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1816 },
1817 )?))
1818 }
1819
1820 #[tokio::test]
1821 async fn escalation_router_serves_efficient_when_judge_declines() -> Result<()> {
1822 let judge = Queue::new([r#"{"escalate":false,"reason":"progressing"}"#]);
1824 let model = Queue::new(["efficient answer"]);
1825 let router = escalation_router()?;
1826
1827 let (selected_model, response) =
1828 test_drive(router, classify_request(), queued(model, judge)).await?;
1829
1830 assert_eq!(selected_model, "efficient");
1832 assert_eq!(
1833 response.llm_response.as_agg().map(completion_text),
1834 Some("efficient answer".to_string())
1835 );
1836 Ok(())
1837 }
1838
1839 #[tokio::test]
1840 async fn escalation_config_overrides_the_packaged_prompt() -> Result<()> {
1841 let recorder = Arc::new(Recorder::default());
1842 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1843 judge_target: ModelId::from("judge"),
1844 efficient_target: ModelId::from("efficient"),
1845 capable_target: ModelId::from("capable"),
1846 contract: ClassifierContractConfig::default().with_prompt("Custom trajectory rubric."),
1847 config: EscalationJudgeConfig {
1848 confirmations: 1,
1849 ..EscalationJudgeConfig::default()
1850 },
1851 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1852 })?);
1853
1854 test_drive(router, classify_request(), recorder.serve()).await?;
1855
1856 let prompts = recorder.judge_system_prompts();
1857 assert_eq!(prompts.len(), 1);
1858 assert_eq!(prompts[0], "Custom trajectory rubric.");
1859 Ok(())
1860 }
1861
1862 #[tokio::test]
1863 async fn escalation_router_upgrades_to_capable_when_judge_escalates() -> Result<()> {
1864 let judge = Queue::new([r#"{"escalate":true,"reason":"stuck in a loop"}"#]);
1866 let model = Queue::new(["efficient draft", "capable answer"]);
1868 let router = escalation_router()?;
1869
1870 let (selected_model, response) =
1871 test_drive(router, classify_request(), queued(model, judge)).await?;
1872
1873 assert_eq!(selected_model, "capable");
1874 assert_eq!(
1875 response.llm_response.as_agg().map(completion_text),
1876 Some("capable answer".to_string())
1877 );
1878 Ok(())
1879 }
1880
1881 #[tokio::test]
1882 async fn escalation_router_stays_capable_after_latch() -> Result<()> {
1883 let judge = Queue::new([r#"{"escalate":true,"reason":"stuck"}"#]);
1886 let model = Queue::new(["efficient draft", "capable t1", "capable t2"]);
1887 let router = escalation_router()?;
1888
1889 let session_request = classify_session_request();
1890 test_drive(
1891 router.clone(),
1892 session_request.clone(),
1893 queued(Arc::clone(&model), Arc::clone(&judge)),
1894 )
1895 .await?;
1896 let (selected_model, _) =
1897 test_drive(router.clone(), session_request, queued(model, judge)).await?;
1898
1899 assert_eq!(selected_model, "capable");
1900 Ok(())
1901 }
1902
1903 #[tokio::test]
1904 async fn escalation_classifier_falls_back_to_capable_when_efficient_overflows() -> Result<()> {
1905 let router = escalation_router()?;
1909
1910 let serve = |target: ModelId, _request: Request| async move {
1912 match target.as_str() {
1913 "efficient" => Err(LlmClientError::ContextWindowExceeded {
1914 model: target,
1915 message: "prompt is too long".to_string(),
1916 }),
1917 "judge" => panic!("the judge must not be consulted when efficient overflows"),
1918 _ => Ok(reply("capable answer")),
1919 }
1920 };
1921
1922 let (selected_model, response) = test_drive(router, classify_request(), serve).await?;
1923
1924 assert_eq!(selected_model, "capable");
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}