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