1use std::collections::HashSet;
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use serde::{Deserialize, Deserializer};
11use serde_json::Value;
12use switchyard_protocol::{Category, ContentBlock, Message, Role};
13
14mod decision;
15pub use decision::{DecisionChoice, DecisionJudgeConfig, DirectClassifierConfig};
16use decision::{DecisionClassifier, DirectClassifier};
17
18use super::escalation;
19use super::fall_through::FallThrough;
20use super::util::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS;
21use super::util::affinity::{AffinityRouter, ClassifyTrigger, is_user_turn};
22use super::util::classifier_contract::{
23 ClassifierContract, ClassifierContractConfig, ClassifierResponseFormat,
24};
25use super::util::escalation::EscalationJudgeConfig;
26use super::util::llm_judge::{
27 ClassifierInput, JsonSchemaDecoder, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig,
28 SerdeDecoder, StructuredJudge,
29};
30use super::util::target_selector::TargetSelectorPolicy;
31use crate::core::algorithm::{Algorithm, Driver};
32use crate::core::classifier::{Classification, Classifier, Score};
33use crate::core::state::State;
34use crate::{LibsyError, Result};
35use switchyard_protocol::{Request, Response};
36
37const PROMPT_TEMPLATE: &str = include_str!("../prompts/capability-classifier/prompt.md");
38const SCHEMA_TEMPLATE: &str = include_str!("../prompts/capability-classifier/schema.json");
39const ALGORITHM_NAME: &str = "llm_task_classifier";
41
42const TRAILING_ROUTING_INSTRUCTION: &str =
51 "Route the conversation above. Output ONLY the routing JSON object, nothing else.";
52
53#[derive(Deserialize)]
54#[serde(deny_unknown_fields)]
55struct TaskClassifierVerdict {
56 crux: String,
57 primary_rule: String,
58 capability_boundary: String,
59 p_solve: f64,
60}
61
62impl TaskClassifierVerdict {
63 fn is_valid(&self) -> bool {
65 (0.0..=1.0).contains(&self.p_solve)
66 && !self.crux.trim().is_empty()
67 && matches!(
68 (
69 self.primary_rule.as_str(),
70 self.capability_boundary.as_str()
71 ),
72 ("SUP-1" | "SUP-2" | "SUP-3" | "SUP-4" | "SUP-5", "supported")
73 | ("UNC-1" | "UNC-2", "uncertain")
74 | ("LIM-1" | "LIM-2", "unsupported")
75 | ("none", "unmatched")
76 )
77 }
78
79 fn boundary_steps(&self, unmatched_steps: u8) -> Option<u8> {
85 match self.capability_boundary.as_str() {
86 "supported" => Some(0),
87 "uncertain" => Some(1),
88 "unmatched" => Some(unmatched_steps),
89 "unsupported" => Some(2),
90 _ => None,
91 }
92 }
93}
94
95fn trim_messages(messages: &[Message], recent_turn_window: usize) -> Vec<Message> {
105 let is_instruction = |message: &Message| matches!(message.role, Role::System | Role::Developer);
106 let mut kept: Vec<&Message> = messages.iter().filter(|m| is_instruction(m)).collect();
107 let Some(task) = messages.iter().position(is_user_turn) else {
108 return kept.into_iter().cloned().collect();
109 };
110 kept.push(&messages[task]);
111
112 let tail: Vec<&Message> = messages[task + 1..]
113 .iter()
114 .filter(|m| !is_instruction(m))
115 .collect();
116 kept.extend(&tail[window_start(&tail, recent_turn_window)..]);
117 kept.into_iter().cloned().collect()
118}
119
120fn window_start(tail: &[&Message], recent_turn_window: usize) -> usize {
133 let counted = tail.len().saturating_sub(recent_turn_window);
134 if counted == tail.len() {
136 return counted;
137 }
138 let mut unpaired: HashSet<&str> = HashSet::new();
139 for (start, message) in tail.iter().enumerate().rev() {
140 for block in message.content.iter().rev() {
143 match block {
144 ContentBlock::ToolResult(result) => {
145 unpaired.insert(result.tool_call_id.as_str());
146 }
147 ContentBlock::ToolCall(call) => {
148 unpaired.remove(call.id.as_str());
149 }
150 _ => {}
151 }
152 }
153 if start <= counted && unpaired.is_empty() {
154 return start;
155 }
156 }
157 counted
158}
159
160fn task_messages(messages: &[Message]) -> Vec<Message> {
162 let is_task_content = |block: &ContentBlock| {
165 !matches!(
166 block,
167 ContentBlock::ToolCall(_)
168 | ContentBlock::ToolResult(_)
169 | ContentBlock::Reasoning { .. }
170 )
171 };
172 let mut user_messages = messages
173 .iter()
174 .filter(|message| is_user_turn(message) && message.content.iter().any(is_task_content));
175 let Some(opening_task) = user_messages.next() else {
176 return Vec::new();
177 };
178 [Some(opening_task), user_messages.next_back()]
179 .into_iter()
180 .flatten()
181 .map(|message| {
182 Message::new(
183 Role::User,
184 message
185 .content
186 .iter()
187 .filter(|block| is_task_content(block))
188 .cloned()
189 .collect(),
190 )
191 })
192 .collect()
193}
194
195struct TaskInput {
197 recent_turn_window: Option<usize>,
198}
199
200impl TaskInput {
201 fn messages(&self, request: &Request) -> Vec<Message> {
203 let mut messages = match self.recent_turn_window {
206 Some(window) => trim_messages(&request.llm_request.messages, window),
207 None => task_messages(&request.llm_request.messages),
208 };
209 for message in &mut messages {
217 message.content.retain(|block| match block {
218 ContentBlock::Reasoning { .. } => false,
219 ContentBlock::Text { text } => !text.is_empty(),
220 _ => true,
221 });
222 }
223 messages.retain(|message| !message.content.is_empty());
224 messages
225 }
226}
227
228impl ClassifierInput for TaskInput {
229 fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
230 let mut messages = self.messages(request);
231 if self.recent_turn_window.is_some() {
235 messages.push(Message::text(
236 Role::User,
237 TRAILING_ROUTING_INSTRUCTION.to_string(),
238 ));
239 }
240 messages
241 }
242}
243
244struct TaskClassifierPolicy {
245 base_threshold: f64,
246 threshold_step: f64,
247 unmatched_steps: u8,
248}
249
250impl TaskClassifierPolicy {
251 fn new(config: &LlmCapabilityConfig) -> Self {
252 Self {
253 base_threshold: config.base_threshold,
254 threshold_step: config.threshold_step,
255 unmatched_steps: config.unmatched_steps,
256 }
257 }
258
259 fn threshold(&self, verdict: &TaskClassifierVerdict) -> Option<f64> {
261 let steps = verdict.boundary_steps(self.unmatched_steps)?;
262 Some(self.base_threshold + f64::from(steps) * self.threshold_step)
263 }
264}
265
266impl JudgePolicy for TaskClassifierPolicy {
267 type Verdict = TaskClassifierVerdict;
268
269 fn to_classification(
270 &self,
271 verdict: Option<&Self::Verdict>,
272 driver: &Driver,
273 ) -> Result<Classification> {
274 let Some(verdict) = verdict.filter(|verdict| verdict.is_valid()) else {
277 return Ok(Classification::Ambiguous(vec![]));
278 };
279 let Some(threshold) = self.threshold(verdict) else {
282 return Ok(Classification::Ambiguous(vec![]));
283 };
284 let category = if verdict.p_solve >= threshold
285 || (threshold - verdict.p_solve).abs() <= f64::EPSILON
286 {
287 Category::Efficient
288 } else {
289 Category::Capable
290 };
291 let Some(target) = driver.models_for(&category).first().cloned() else {
294 return Ok(Classification::Ambiguous(vec![]));
295 };
296 Ok(Classification::Scores(vec![Score {
297 target,
298 confidence: 1.0,
299 category: Some(category),
300 }]))
301 }
302}
303
304fn capability_evidence(
306 policy: &TaskClassifierPolicy,
307 verdict: Option<&TaskClassifierVerdict>,
308) -> Option<Value> {
309 let verdict = verdict?;
310 let Some(threshold) = verdict
311 .is_valid()
312 .then(|| policy.threshold(verdict))
313 .flatten()
314 else {
315 return Some(serde_json::json!({
316 "source": "fail_open",
317 "reason_code": "invalid_verdict",
318 }));
319 };
320 Some(serde_json::json!({
321 "source": "llm-classifier",
322 "score": verdict.p_solve,
323 "threshold": threshold,
324 }))
325}
326
327#[derive(Clone, Debug)]
329pub struct TaskClassifierConfig {
330 pub judge: CapabilityJudgeConfig,
332 pub fail_open: bool,
334 pub classify_trigger: ClassifyTrigger,
336 pub message_hash_fallback: bool,
338 pub recent_turn_window: Option<usize>,
341}
342
343#[derive(Clone, Debug)]
345pub enum CapabilityJudgeConfig {
346 Llm(LlmCapabilityConfig),
348 Decision(DecisionJudgeConfig),
350}
351
352impl Default for CapabilityJudgeConfig {
353 fn default() -> Self {
354 Self::Llm(LlmCapabilityConfig::default())
355 }
356}
357
358#[derive(Clone, Debug)]
360pub struct LlmCapabilityConfig {
361 pub base_threshold: f64,
363 pub threshold_step: f64,
366 pub unmatched_steps: u8,
370 pub contract: ClassifierContractConfig,
372 pub max_output_tokens: u64,
374 pub judge_deadline_ms: Option<u64>,
378}
379
380impl Default for LlmCapabilityConfig {
381 fn default() -> Self {
382 Self {
383 base_threshold: 0.0,
384 threshold_step: 0.0,
385 unmatched_steps: DEFAULT_UNMATCHED_STEPS,
386 contract: ClassifierContractConfig::default(),
387 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
388 judge_deadline_ms: None,
389 }
390 }
391}
392
393#[derive(Deserialize)]
395#[serde(deny_unknown_fields)]
396struct TaskClassifierConfigWire {
397 base_threshold: f64,
398 #[serde(default = "default_fail_open")]
399 fail_open: bool,
400 #[serde(default)]
401 threshold_step: f64,
402 #[serde(default = "default_unmatched_steps")]
403 unmatched_steps: u8,
404 #[serde(default)]
405 classify_trigger: ClassifyTrigger,
406 #[serde(default)]
407 message_hash_fallback: bool,
408 #[serde(default)]
409 recent_turn_window: Option<usize>,
410 #[serde(default)]
411 prompt: Option<String>,
412 #[serde(default)]
413 response_format_type: ClassifierResponseFormat,
414 #[serde(default = "default_judge_max_output_tokens")]
415 max_output_tokens: u64,
416 #[serde(default)]
417 judge_deadline_ms: Option<u64>,
418}
419
420impl<'de> Deserialize<'de> for TaskClassifierConfig {
421 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
422 where
423 D: Deserializer<'de>,
424 {
425 let wire = TaskClassifierConfigWire::deserialize(deserializer)?;
426 let mut contract = ClassifierContractConfig::default();
427 if let Some(prompt) = wire.prompt {
428 contract = contract.with_prompt(prompt);
429 }
430 contract = contract.with_response_format_type(wire.response_format_type);
431 Ok(Self {
432 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
433 base_threshold: wire.base_threshold,
434 threshold_step: wire.threshold_step,
435 unmatched_steps: wire.unmatched_steps,
436 contract,
437 max_output_tokens: wire.max_output_tokens,
438 judge_deadline_ms: wire.judge_deadline_ms,
439 }),
440 fail_open: wire.fail_open,
441 classify_trigger: wire.classify_trigger,
442 message_hash_fallback: wire.message_hash_fallback,
443 recent_turn_window: wire.recent_turn_window,
444 })
445 }
446}
447
448const fn default_fail_open() -> bool {
449 true
450}
451
452pub const DEFAULT_UNMATCHED_STEPS: u8 = 1;
454
455const fn default_unmatched_steps() -> u8 {
456 DEFAULT_UNMATCHED_STEPS
457}
458
459const fn default_judge_max_output_tokens() -> u64 {
460 DEFAULT_JUDGE_MAX_OUTPUT_TOKENS
461}
462
463impl Default for TaskClassifierConfig {
464 fn default() -> Self {
465 Self {
466 judge: CapabilityJudgeConfig::default(),
467 fail_open: default_fail_open(),
468 classify_trigger: ClassifyTrigger::default(),
469 message_hash_fallback: false,
470 recent_turn_window: None,
471 }
472 }
473}
474
475impl LlmCapabilityConfig {
476 fn validate(&self) -> Result<()> {
477 if !(0.0..=1.0).contains(&self.base_threshold) {
478 return Err(LibsyError::AlgorithmError {
479 message: format!(
480 "base_threshold must be between 0 and 1, got {}",
481 self.base_threshold
482 ),
483 });
484 }
485 if !self.threshold_step.is_finite() || self.threshold_step < 0.0 {
486 return Err(LibsyError::AlgorithmError {
487 message: format!(
488 "threshold_step must be finite and greater than or equal to 0, got {}",
489 self.threshold_step
490 ),
491 });
492 }
493 if self.unmatched_steps > 2 {
494 return Err(LibsyError::AlgorithmError {
495 message: format!(
496 "unmatched_steps must be 0, 1, or 2, got {}",
497 self.unmatched_steps
498 ),
499 });
500 }
501 let unsupported_threshold = self.base_threshold + 2.0 * self.threshold_step;
502 if unsupported_threshold > 1.0 && unsupported_threshold - 1.0 > f64::EPSILON {
503 return Err(LibsyError::AlgorithmError {
504 message: format!(
505 "base_threshold + 2 * threshold_step must be at most 1, got {unsupported_threshold}"
506 ),
507 });
508 }
509 if self.max_output_tokens == 0 {
510 return Err(LibsyError::AlgorithmError {
511 message: "max_output_tokens must be at least 1".to_string(),
512 });
513 }
514 Ok(())
515 }
516}
517
518impl TaskClassifierConfig {
519 fn validate(&self) -> Result<()> {
520 match &self.judge {
521 CapabilityJudgeConfig::Llm(config) => config.validate()?,
522 CapabilityJudgeConfig::Decision(config) => config.validate()?,
523 }
524 if self.message_hash_fallback && self.classify_trigger == ClassifyTrigger::EveryRequest {
526 return Err(LibsyError::AlgorithmError {
527 message:
528 "message_hash_fallback requires classify_trigger = new_session or user_turn"
529 .to_string(),
530 });
531 }
532 Ok(())
533 }
534}
535
536#[derive(Clone, Debug)]
538pub enum CustomClassifierPolicy {
539 TargetSelector {
541 selector: String,
543 },
544}
545
546impl CustomClassifierPolicy {
547 pub fn target_selector(selector: impl Into<String>) -> Self {
549 Self::TargetSelector {
550 selector: selector.into(),
551 }
552 }
553}
554
555#[derive(Clone, Debug)]
557pub struct CustomClassifierConfig {
558 pub prompt: String,
560 pub response_schema: Value,
562 pub policy: CustomClassifierPolicy,
564 pub classify_trigger: ClassifyTrigger,
566 pub message_hash_fallback: bool,
568 pub recent_turn_window: Option<usize>,
570 pub max_output_tokens: u64,
572 pub judge_deadline_ms: Option<u64>,
574}
575
576impl CustomClassifierConfig {
577 pub fn new(
579 prompt: impl Into<String>,
580 response_schema: Value,
581 policy: CustomClassifierPolicy,
582 ) -> Self {
583 Self {
584 prompt: prompt.into(),
585 response_schema,
586 policy,
587 classify_trigger: ClassifyTrigger::default(),
588 message_hash_fallback: false,
589 recent_turn_window: None,
590 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
591 judge_deadline_ms: None,
592 }
593 }
594
595 fn validate(&self) -> Result<()> {
596 if self.max_output_tokens == 0 {
597 return Err(LibsyError::AlgorithmError {
598 message: "max_output_tokens must be at least 1".to_string(),
599 });
600 }
601 if self.message_hash_fallback && self.classify_trigger == ClassifyTrigger::EveryRequest {
603 return Err(LibsyError::AlgorithmError {
604 message:
605 "message_hash_fallback requires classify_trigger = new_session or user_turn"
606 .to_string(),
607 });
608 }
609 Ok(())
610 }
611}
612
613enum CustomPolicyRuntime {
614 TargetSelector(TargetSelectorPolicy),
615}
616
617impl JudgePolicy for CustomPolicyRuntime {
618 type Verdict = Value;
619
620 fn to_classification(
621 &self,
622 verdict: Option<&Self::Verdict>,
623 driver: &Driver,
624 ) -> Result<Classification> {
625 match self {
626 Self::TargetSelector(policy) => policy.to_classification(verdict, driver),
627 }
628 }
629}
630
631fn affinity_router(
633 trigger: ClassifyTrigger,
634 message_hash_fallback: bool,
635) -> Option<Arc<AffinityRouter>> {
636 let router = match trigger {
637 ClassifyTrigger::EveryRequest => return None,
638 ClassifyTrigger::NewSession => AffinityRouter::new(),
639 ClassifyTrigger::UserTurn => AffinityRouter::new().with_release_on_user_turn(),
640 };
641 let router = if message_hash_fallback {
642 router.with_message_hash_fallback()
643 } else {
644 router
645 };
646 Some(Arc::new(router))
647}
648
649pub struct LlmTaskClassifier {
651 route: FallThrough,
652 inner: Arc<dyn Classifier>,
654}
655
656struct ClassifierRouteConfig {
657 default_target: Category,
658 classify_trigger: ClassifyTrigger,
659 message_hash_fallback: bool,
660}
661
662pub struct DefaultCategoryClassifier(pub Category);
665
666#[async_trait]
667impl Classifier for DefaultCategoryClassifier {
668 async fn score(
669 &self,
670 _state: &mut State,
671 _request: &mut Request,
672 driver: &Driver,
673 ) -> Result<(Classification, Option<Response>)> {
674 let target = driver.first_model_for(&self.0)?;
675 driver.set_evidence_if_empty(serde_json::json!({"source": "fall_open"}));
676 Ok((
677 Classification::Scores(vec![Score {
678 target: target.clone(),
679 confidence: 0.0,
680 category: Some(self.0.clone()),
681 }]),
682 None,
683 ))
684 }
685}
686
687#[derive(Clone)]
689#[non_exhaustive]
690pub enum LlmClassifierConfig {
691 Capability {
693 config: TaskClassifierConfig,
695 },
696 Escalation {
698 contract: ClassifierContractConfig,
700 config: EscalationJudgeConfig,
702 max_output_tokens: u64,
704 judge_deadline_ms: Option<u64>,
706 },
707 Custom {
709 default_target: Category,
711 config: CustomClassifierConfig,
713 },
714 Direct {
716 default_target: Category,
718 config: DirectClassifierConfig,
720 },
721}
722
723impl LlmTaskClassifier {
724 pub fn new(config: LlmClassifierConfig) -> Result<Self> {
731 match config {
732 LlmClassifierConfig::Capability { config } => Self::build_capability(config),
733 LlmClassifierConfig::Escalation {
734 contract,
735 config,
736 max_output_tokens,
737 judge_deadline_ms,
738 } => Self::build_escalation(contract, config, max_output_tokens, judge_deadline_ms),
739 LlmClassifierConfig::Custom {
740 default_target,
741 config,
742 } => Self::build_custom(default_target, config),
743 LlmClassifierConfig::Direct {
744 default_target,
745 config,
746 } => Self::build_direct(default_target, config),
747 }
748 }
749
750 fn build_capability(config: TaskClassifierConfig) -> Result<Self> {
751 config.validate()?;
752 let classify_trigger = config.classify_trigger;
753 let message_hash_fallback = config.message_hash_fallback;
754 let input = TaskInput {
755 recent_turn_window: config.recent_turn_window,
756 };
757 let classifier: Arc<dyn Classifier> = match config.judge {
758 CapabilityJudgeConfig::Llm(judge) => Arc::new(
759 JudgeClassifier::new(
760 StructuredJudge::new(
761 input,
762 Self::load_capability_contract(&judge.contract)?,
763 SerdeDecoder::new(),
764 JudgeRuntimeConfig::new(judge.max_output_tokens)?
765 .with_deadline_ms(judge.judge_deadline_ms)?,
766 ),
767 TaskClassifierPolicy::new(&judge),
768 )
769 .with_error_recovery(config.fail_open)
770 .with_evidence(capability_evidence),
771 ),
772 CapabilityJudgeConfig::Decision(judge) => {
773 Arc::new(DecisionClassifier::new(judge, input, config.fail_open)?)
774 }
775 };
776 Self::from_classifier(
777 classifier,
778 ClassifierRouteConfig {
779 default_target: Category::Capable,
780 classify_trigger,
781 message_hash_fallback,
782 },
783 )
784 }
785
786 fn build_custom(default_target: Category, config: CustomClassifierConfig) -> Result<Self> {
787 Self::from_classifier(
788 Self::custom_classifier(&config, false)?,
789 ClassifierRouteConfig {
790 default_target,
791 classify_trigger: config.classify_trigger,
792 message_hash_fallback: config.message_hash_fallback,
793 },
794 )
795 }
796
797 fn build_direct(default_target: Category, config: DirectClassifierConfig) -> Result<Self> {
798 if !config.options.contains(&default_target) {
799 return Err(LibsyError::AlgorithmError {
800 message: "direct default_target must be one of the options".into(),
801 });
802 }
803 let route = ClassifierRouteConfig {
804 default_target,
805 classify_trigger: config.classify_trigger,
806 message_hash_fallback: config.message_hash_fallback,
807 };
808 Self::from_classifier(Arc::new(DirectClassifier::new(config)?), route)
809 }
810
811 pub(crate) fn custom_classifier(
813 config: &CustomClassifierConfig,
814 fail_open: bool,
815 ) -> Result<Arc<dyn Classifier>> {
816 config.validate()?;
817 let contract =
818 ClassifierContract::from_inner_schema(&config.prompt, config.response_schema.clone())?;
819 let policy = match &config.policy {
820 CustomClassifierPolicy::TargetSelector { selector } => {
821 CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(selector)?)
822 }
823 };
824 let classifier = JudgeClassifier::new(
825 StructuredJudge::new(
826 TaskInput {
827 recent_turn_window: config.recent_turn_window,
828 },
829 contract,
830 JsonSchemaDecoder::new(),
831 JudgeRuntimeConfig::new(config.max_output_tokens)?
832 .with_deadline_ms(config.judge_deadline_ms)?,
833 ),
834 policy,
835 )
836 .with_error_recovery(fail_open);
837 let classifier = if fail_open {
838 classifier.with_evidence(|_, verdict| {
839 verdict.map(|_| serde_json::json!({"source": "llm-classifier"}))
840 })
841 } else {
842 classifier
843 };
844 Ok(Arc::new(classifier))
845 }
846
847 fn build_escalation(
848 contract_config: ClassifierContractConfig,
849 config: EscalationJudgeConfig,
850 max_output_tokens: u64,
851 judge_deadline_ms: Option<u64>,
852 ) -> Result<Self> {
853 let inner = escalation::build_classifier(
854 contract_config,
855 config,
856 max_output_tokens,
857 judge_deadline_ms,
858 )?;
859 Ok(Self {
860 route: FallThrough::new_with_state()
861 .with_name(ALGORITHM_NAME)
862 .with_classifier(Arc::clone(&inner)),
863 inner,
864 })
865 }
866
867 fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
869 ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
870 }
871
872 fn from_classifier(inner: Arc<dyn Classifier>, config: ClassifierRouteConfig) -> Result<Self> {
874 if config.message_hash_fallback && config.classify_trigger == ClassifyTrigger::EveryRequest
876 {
877 return Err(LibsyError::AlgorithmError {
878 message:
879 "message_hash_fallback requires classify_trigger = new_session or user_turn"
880 .to_string(),
881 });
882 }
883 let mut route = FallThrough::new_with_state().with_name(ALGORITHM_NAME);
885 if let Some(affinity) =
886 affinity_router(config.classify_trigger, config.message_hash_fallback).as_ref()
887 {
888 route = route
890 .with_processor(affinity.clone())
891 .with_classifier(affinity.clone());
892 }
893 let fallback = DefaultCategoryClassifier(config.default_target);
894 Ok(Self {
895 route: route
896 .with_classifier(inner.clone())
897 .with_classifier(Arc::new(fallback)),
898 inner,
899 })
900 }
901}
902
903#[async_trait]
904impl Classifier for LlmTaskClassifier {
905 async fn score(
906 &self,
907 state: &mut State,
908 request: &mut Request,
909 driver: &Driver,
910 ) -> Result<(Classification, Option<Response>)> {
911 self.inner.score(state, request, driver).await
912 }
913}
914
915#[async_trait]
916impl Algorithm for LlmTaskClassifier {
917 fn name(&self) -> &str {
918 "llm_task_classifier"
919 }
920
921 async fn route(
922 self: Arc<Self>,
923 driver: Driver,
924 request: Request,
925 ) -> Result<crate::RoutingOutcome> {
926 self.route.execute(driver, request).await
927 }
928}
929
930#[cfg(test)]
931mod tests {
932 use std::collections::HashMap;
933 use std::sync::Arc;
934
935 use parking_lot::Mutex;
936 use serde_json::Value;
937
938 use super::*;
939 use switchyard_protocol::{
940 ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, ModelId, ToolCall,
941 ToolResult, completion_text, text_request, text_response,
942 };
943
944 use crate::algorithms::util::llm_judge::Judge;
945 use crate::core::testing::{Serve, test_drive_with_models};
946 use switchyard_protocol::{LlmResponse, Response};
947
948 const TEST_THRESHOLD: f64 = 0.5;
949
950 type CapabilityJudge = StructuredJudge<TaskInput, SerdeDecoder<TaskClassifierVerdict>>;
951
952 fn llm_config(base_threshold: f64) -> LlmCapabilityConfig {
953 LlmCapabilityConfig {
954 base_threshold,
955 ..LlmCapabilityConfig::default()
956 }
957 }
958
959 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
960 TaskClassifierConfig {
961 judge: CapabilityJudgeConfig::Llm(llm_config(base_threshold)),
962 ..TaskClassifierConfig::default()
963 }
964 }
965
966 fn policy() -> TaskClassifierPolicy {
967 TaskClassifierPolicy::new(&llm_config(TEST_THRESHOLD))
968 }
969
970 fn runtime_models() -> HashMap<Category, Vec<ModelId>> {
971 [
972 (Category::Judge, vec![ModelId::from("judge")]),
973 (Category::Efficient, vec![ModelId::from("efficient")]),
974 (Category::Capable, vec![ModelId::from("capable")]),
975 (
976 Category::Any,
977 vec![ModelId::from("efficient"), ModelId::from("capable")],
978 ),
979 ]
980 .into()
981 }
982
983 fn policy_driver() -> Driver {
984 Driver::new("test", Arc::new(runtime_models().into())).0
985 }
986
987 fn verdict(
988 p_solve: f64,
989 capability_boundary: &str,
990 primary_rule: &str,
991 ) -> TaskClassifierVerdict {
992 TaskClassifierVerdict {
993 crux: "test crux".to_string(),
994 primary_rule: primary_rule.to_string(),
995 capability_boundary: capability_boundary.to_string(),
996 p_solve,
997 }
998 }
999
1000 fn selected(
1001 policy: &TaskClassifierPolicy,
1002 verdict: Option<&TaskClassifierVerdict>,
1003 ) -> Result<ModelId> {
1004 policy
1005 .to_classification(verdict, &policy_driver())?
1006 .argmax(false)?
1007 .map(|score| score.target)
1008 .ok_or_else(|| LibsyError::AlgorithmError {
1009 message: "policy abstained".to_string(),
1010 })
1011 }
1012
1013 #[derive(Default)]
1016 struct Recorder {
1017 calls: Mutex<Vec<String>>,
1018 call_roles: Mutex<Vec<(String, bool)>>,
1019 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
1020 judge_system_prompts: Mutex<Vec<String>>,
1021 }
1022
1023 impl Recorder {
1024 fn calls(&self) -> Vec<String> {
1025 self.calls.lock().clone()
1026 }
1027
1028 fn call_roles(&self) -> Vec<(String, bool)> {
1029 self.call_roles.lock().clone()
1030 }
1031
1032 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
1033 self.judge_max_output_tokens.lock().clone()
1034 }
1035
1036 fn judge_system_prompts(&self) -> Vec<String> {
1037 self.judge_system_prompts.lock().clone()
1038 }
1039
1040 fn serve(self: &Arc<Self>) -> impl Serve {
1041 let recorder = Arc::clone(self);
1042 move |model: ModelId, request: Request| {
1043 let recorder = Arc::clone(&recorder);
1044 async move {
1045 let model = model.to_string();
1046 recorder.calls.lock().push(model.clone());
1047 recorder
1048 .call_roles
1049 .lock()
1050 .push((model.clone(), model != "judge"));
1051 let completion = if model == "judge" {
1052 recorder
1053 .judge_max_output_tokens
1054 .lock()
1055 .push(request.llm_request.output.max_output_tokens);
1056 recorder.judge_system_prompts.lock().extend(
1057 request
1058 .llm_request
1059 .instructions
1060 .first()
1061 .and_then(|instruction| {
1062 instruction.content.iter().find_map(|b| {
1063 if let ContentBlock::Text { text } = b {
1064 Some(text.clone())
1065 } else {
1066 None
1067 }
1068 })
1069 }),
1070 );
1071 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1072 } else {
1073 format!("answer from {model}")
1074 };
1075 Ok(Response {
1076 llm_response: LlmResponse::Agg(text_response(None, completion)),
1077 metadata: request.metadata,
1078 upstream_headers: http::HeaderMap::new(),
1079 })
1080 }
1081 }
1082 }
1083 }
1084
1085 fn unreachable_judge() -> impl Serve {
1087 |model: ModelId, request: Request| async move {
1088 let model = model.to_string();
1089 if model == "judge" {
1090 return Err(LlmClientError::Timeout {
1091 source: Box::new(std::io::Error::other("judge unreachable")),
1092 });
1093 }
1094 Ok(Response {
1095 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1096 metadata: request.metadata,
1097 upstream_headers: http::HeaderMap::new(),
1098 })
1099 }
1100 }
1101
1102 fn slow_judge(delay: std::time::Duration) -> impl Serve {
1104 move |model: ModelId, request: Request| {
1105 let model = model.to_string();
1106 let slow = model == "judge";
1107 async move {
1108 if slow {
1109 tokio::time::sleep(delay).await;
1110 }
1111 Ok(Response {
1112 llm_response: LlmResponse::Agg(text_response(
1113 None,
1114 format!("answer from {model}"),
1115 )),
1116 metadata: request.metadata,
1117 upstream_headers: http::HeaderMap::new(),
1118 })
1119 }
1120 }
1121 }
1122
1123 fn stalled_judge_stream() -> impl Serve {
1125 use futures::StreamExt;
1126 |model: ModelId, request: Request| async move {
1127 let model = model.to_string();
1128 let llm_response = if model == "judge" {
1129 LlmResponse::Stream(futures::stream::pending().boxed())
1130 } else {
1131 LlmResponse::Agg(text_response(None, format!("answer from {model}")))
1132 };
1133 Ok(Response {
1134 llm_response,
1135 metadata: request.metadata,
1136 upstream_headers: http::HeaderMap::new(),
1137 })
1138 }
1139 }
1140
1141 fn deadline_router(fail_open: bool) -> Result<Arc<LlmTaskClassifier>> {
1142 Ok(Arc::new(LlmTaskClassifier::new(
1143 LlmClassifierConfig::Capability {
1144 config: TaskClassifierConfig {
1145 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1146 judge_deadline_ms: Some(10),
1147 ..llm_config(TEST_THRESHOLD)
1148 }),
1149 fail_open,
1150 ..TaskClassifierConfig::default()
1151 },
1152 },
1153 )?))
1154 }
1155
1156 fn router() -> Result<Arc<LlmTaskClassifier>> {
1157 Ok(Arc::new(LlmTaskClassifier::new(
1158 LlmClassifierConfig::Capability {
1159 config: test_config(TEST_THRESHOLD),
1160 },
1161 )?))
1162 }
1163
1164 fn classify_request() -> Request {
1165 Request {
1166 llm_request: text_request(Some("auto".to_string()), "classify this task"),
1167 raw_request: None,
1168 metadata: None,
1169 }
1170 }
1171
1172 fn classify_session_request() -> Request {
1173 Request {
1174 metadata: Some(Metadata {
1175 session_id: Some("session-1".to_string()),
1176 ..Metadata::default()
1177 }),
1178 ..classify_request()
1179 }
1180 }
1181
1182 fn classify_follow_up_request() -> Request {
1183 let mut request = classify_request();
1184 request
1185 .llm_request
1186 .messages
1187 .push(Message::text(Role::Assistant, "I will add the test."));
1188 request.llm_request.messages.push(Message::text(
1189 Role::User,
1190 "Now run the test suite and report the result.",
1191 ));
1192 request
1193 }
1194
1195 #[tokio::test]
1196 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1197 let router = router()?;
1198
1199 let (selected_model, response) = test_drive_with_models(
1200 router,
1201 classify_request(),
1202 runtime_models(),
1203 unreachable_judge(),
1204 )
1205 .await?;
1206
1207 assert_eq!(selected_model, "capable");
1208 assert_eq!(
1209 response.llm_response.as_agg().map(completion_text),
1210 Some("answer from capable".to_string())
1211 );
1212 Ok(())
1213 }
1214
1215 #[tokio::test]
1216 async fn a_judge_past_its_deadline_routes_capable() -> Result<()> {
1217 let (selected_model, response) = test_drive_with_models(
1218 deadline_router(true)?,
1219 classify_request(),
1220 runtime_models(),
1221 slow_judge(std::time::Duration::from_millis(500)),
1222 )
1223 .await?;
1224
1225 assert_eq!(selected_model, "capable");
1226 assert_eq!(
1227 response.llm_response.as_agg().map(completion_text),
1228 Some("answer from capable".to_string())
1229 );
1230 Ok(())
1231 }
1232
1233 #[tokio::test]
1234 async fn a_stalled_judge_stream_is_cut_by_the_deadline() -> Result<()> {
1235 let (selected_model, _) = test_drive_with_models(
1238 deadline_router(true)?,
1239 classify_request(),
1240 runtime_models(),
1241 stalled_judge_stream(),
1242 )
1243 .await?;
1244
1245 assert_eq!(selected_model, "capable");
1246 Ok(())
1247 }
1248
1249 #[tokio::test]
1250 async fn a_judge_past_its_deadline_stops_when_not_failing_open() -> Result<()> {
1251 let outcome = test_drive_with_models(
1252 deadline_router(false)?,
1253 classify_request(),
1254 runtime_models(),
1255 slow_judge(std::time::Duration::from_millis(500)),
1256 )
1257 .await;
1258 let error = outcome
1259 .err()
1260 .expect("a deadline expiry with fail_open = false must stop the request");
1261
1262 assert!(
1263 matches!(
1264 error,
1265 LibsyError::ClientCall {
1266 source: LlmClientError::Timeout { .. },
1267 ..
1268 }
1269 ),
1270 "expected a client timeout, got {error:?}"
1271 );
1272 Ok(())
1273 }
1274
1275 #[test]
1276 fn a_zero_judge_deadline_is_rejected_in_every_mode() {
1277 let capability = LlmClassifierConfig::Capability {
1278 config: TaskClassifierConfig {
1279 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1280 judge_deadline_ms: Some(0),
1281 ..llm_config(TEST_THRESHOLD)
1282 }),
1283 ..TaskClassifierConfig::default()
1284 },
1285 };
1286 assert!(
1287 LlmTaskClassifier::new(capability).is_err(),
1288 "capability mode must reject judge_deadline_ms = 0"
1289 );
1290
1291 let mut custom = CustomClassifierConfig::new(
1292 "Route by topic.",
1293 serde_json::json!({"type": "object"}),
1294 CustomClassifierPolicy::target_selector("/target"),
1295 );
1296 custom.judge_deadline_ms = Some(0);
1297 assert!(
1298 LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1299 default_target: Category::Capable,
1300 config: custom,
1301 })
1302 .is_err(),
1303 "custom mode must reject judge_deadline_ms = 0"
1304 );
1305
1306 let escalation = LlmClassifierConfig::Escalation {
1307 contract: ClassifierContractConfig::default(),
1308 config: EscalationJudgeConfig::default(),
1309 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1310 judge_deadline_ms: Some(0),
1311 };
1312 assert!(
1313 LlmTaskClassifier::new(escalation).is_err(),
1314 "escalation mode must reject judge_deadline_ms = 0"
1315 );
1316 }
1317
1318 #[test]
1319 fn classifier_config_parses_a_judge_deadline() {
1320 let config: TaskClassifierConfig = serde_json::from_value(serde_json::json!({
1321 "base_threshold": 0.5,
1322 "judge_deadline_ms": 250,
1323 }))
1324 .expect("a configured judge deadline should parse");
1325 let CapabilityJudgeConfig::Llm(judge) = config.judge else {
1326 panic!("expected the LLM judge variant");
1327 };
1328 assert_eq!(judge.judge_deadline_ms, Some(250));
1329
1330 let config: TaskClassifierConfig = serde_json::from_value(serde_json::json!({
1331 "base_threshold": 0.5,
1332 }))
1333 .expect("an unset judge deadline should parse");
1334 let CapabilityJudgeConfig::Llm(judge) = config.judge else {
1335 panic!("expected the LLM judge variant");
1336 };
1337 assert_eq!(judge.judge_deadline_ms, None);
1338 }
1339
1340 #[tokio::test]
1341 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1342 let recorder = Arc::new(Recorder::default());
1343 let router = router()?;
1344 let request = classify_request();
1345 let models = runtime_models();
1346
1347 test_drive_with_models(
1348 router.clone(),
1349 request.clone(),
1350 models.clone(),
1351 recorder.serve(),
1352 )
1353 .await?;
1354 test_drive_with_models(router, request, models, recorder.serve()).await?;
1355
1356 assert_eq!(
1357 recorder.calls(),
1358 vec!["judge", "efficient", "judge", "efficient"]
1359 );
1360 assert_eq!(
1361 recorder.call_roles(),
1362 vec![
1363 ("judge".to_string(), false),
1364 ("efficient".to_string(), true),
1365 ("judge".to_string(), false),
1366 ("efficient".to_string(), true),
1367 ]
1368 );
1369 Ok(())
1370 }
1371
1372 #[tokio::test]
1373 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1374 let recorder = Arc::new(Recorder::default());
1375 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1376 config: TaskClassifierConfig {
1377 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1378 max_output_tokens: 512,
1379 ..llm_config(TEST_THRESHOLD)
1380 }),
1381 ..test_config(TEST_THRESHOLD)
1382 },
1383 })?);
1384
1385 test_drive_with_models(
1386 router,
1387 classify_request(),
1388 runtime_models(),
1389 recorder.serve(),
1390 )
1391 .await?;
1392
1393 assert_eq!(recorder.judge_max_output_tokens(), vec![Some(512)]);
1394 Ok(())
1395 }
1396
1397 #[tokio::test]
1398 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1399 let recorder = Arc::new(Recorder::default());
1400 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1401 config: TaskClassifierConfig {
1402 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1403 contract: ClassifierContractConfig::default()
1404 .with_prompt("Custom capability rubric."),
1405 ..llm_config(TEST_THRESHOLD)
1406 }),
1407 ..test_config(TEST_THRESHOLD)
1408 },
1409 })?);
1410
1411 test_drive_with_models(
1412 router,
1413 classify_request(),
1414 runtime_models(),
1415 recorder.serve(),
1416 )
1417 .await?;
1418
1419 let prompts = recorder.judge_system_prompts();
1420 assert_eq!(prompts.len(), 1);
1421 assert_eq!(prompts[0], "Custom capability rubric.");
1422 Ok(())
1423 }
1424
1425 #[tokio::test]
1426 async fn classifier_config_enables_new_session_trigger() -> Result<()> {
1427 let recorder = Arc::new(Recorder::default());
1428 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1429 config: TaskClassifierConfig {
1430 classify_trigger: ClassifyTrigger::NewSession,
1431 ..test_config(TEST_THRESHOLD)
1432 },
1433 })?);
1434
1435 let request = classify_session_request();
1436 let models = runtime_models();
1437 test_drive_with_models(
1438 router.clone(),
1439 request.clone(),
1440 models.clone(),
1441 recorder.serve(),
1442 )
1443 .await?;
1444 test_drive_with_models(router, request, models, recorder.serve()).await?;
1445
1446 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1447 Ok(())
1448 }
1449
1450 #[tokio::test]
1451 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1452 let recorder = Arc::new(Recorder::default());
1453 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1454 config: TaskClassifierConfig {
1455 classify_trigger: ClassifyTrigger::NewSession,
1456 message_hash_fallback: true,
1457 recent_turn_window: None,
1458 ..test_config(TEST_THRESHOLD)
1459 },
1460 })?);
1461
1462 let models = runtime_models();
1463 test_drive_with_models(
1464 router.clone(),
1465 classify_request(),
1466 models.clone(),
1467 recorder.serve(),
1468 )
1469 .await?;
1470 test_drive_with_models(
1471 router,
1472 classify_follow_up_request(),
1473 models,
1474 recorder.serve(),
1475 )
1476 .await?;
1477
1478 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1479 Ok(())
1480 }
1481
1482 #[tokio::test]
1483 async fn one_classifier_uses_each_requests_runtime_models() -> Result<()> {
1484 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1485 config: TaskClassifierConfig {
1486 classify_trigger: ClassifyTrigger::NewSession,
1487 ..test_config(TEST_THRESHOLD)
1488 },
1489 })?);
1490 let calls = Arc::new(Mutex::new(Vec::new()));
1491 let serve = |calls: Arc<Mutex<Vec<String>>>| {
1492 move |model: ModelId, _request: Request| {
1493 let calls = Arc::clone(&calls);
1494 async move {
1495 calls.lock().push(model.to_string());
1496 let text = if model.as_str().starts_with("judge-") {
1497 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1498 } else {
1499 model.to_string()
1500 };
1501 Ok(Response {
1502 llm_response: LlmResponse::Agg(text_response(None, text)),
1503 metadata: None,
1504 upstream_headers: Default::default(),
1505 })
1506 }
1507 }
1508 };
1509 let models = |suffix: &str| -> HashMap<Category, Vec<ModelId>> {
1510 [
1511 (
1512 Category::Judge,
1513 vec![ModelId::from(format!("judge-{suffix}"))],
1514 ),
1515 (
1516 Category::Efficient,
1517 vec![ModelId::from(format!("efficient-{suffix}"))],
1518 ),
1519 (
1520 Category::Capable,
1521 vec![ModelId::from(format!("capable-{suffix}"))],
1522 ),
1523 (
1524 Category::Any,
1525 vec![
1526 ModelId::from(format!("efficient-{suffix}")),
1527 ModelId::from(format!("capable-{suffix}")),
1528 ],
1529 ),
1530 ]
1531 .into()
1532 };
1533 let request = classify_session_request();
1534
1535 let (first, _) = test_drive_with_models(
1536 router.clone(),
1537 request.clone(),
1538 models("a"),
1539 serve(Arc::clone(&calls)),
1540 )
1541 .await?;
1542 let (second, _) =
1543 test_drive_with_models(router, request, models("b"), serve(Arc::clone(&calls))).await?;
1544
1545 assert_eq!(first, "efficient-a");
1546 assert_eq!(second, "efficient-b");
1547 assert_eq!(
1548 &*calls.lock(),
1549 &["judge-a", "efficient-a", "judge-b", "efficient-b"]
1550 );
1551 Ok(())
1552 }
1553
1554 #[tokio::test]
1555 async fn decision_judge_routes_on_capable_probability() -> Result<()> {
1556 use crate::{Call, RuntimeModels, drive};
1557 use serde_json::json;
1558 use std::collections::BTreeMap;
1559 use std::sync::atomic::{AtomicUsize, Ordering};
1560 use switchyard_protocol::{
1561 BooleanEstimate, DecisionAnswer, DecisionResponse, DecisionValue, Probability,
1562 ProviderConfidence,
1563 };
1564
1565 let settings = DecisionJudgeConfig {
1566 cutoff: Some(0.4),
1567 ..DecisionJudgeConfig::default()
1568 };
1569 let mut request = classify_session_request();
1570 request.llm_request.messages.push(Message::new(
1571 Role::Assistant,
1572 vec![
1573 ContentBlock::Reasoning {
1574 text: "private reasoning".into(),
1575 signature: None,
1576 details: vec![],
1577 },
1578 ContentBlock::Text {
1579 text: "visible progress".into(),
1580 },
1581 ],
1582 ));
1583 request
1584 .llm_request
1585 .messages
1586 .push(Message::text(Role::User, "latest follow-up"));
1587 let original_messages = request.llm_request.messages.clone();
1588 let expected_task = json!([
1589 original_messages[0].clone(),
1590 Message::text(Role::Assistant, "visible progress"),
1591 Message::text(Role::User, "latest follow-up"),
1592 ]);
1593
1594 for (name, score, fail_open, expected) in [
1595 ("above", 0.45, true, Some("capable")),
1596 ("equal", 0.4, true, Some("efficient")),
1597 ("below", 0.39, false, Some("efficient")),
1598 ("missing answer", 0.0, true, Some("capable")),
1599 ("no distribution", 0.0, true, Some("capable")),
1600 ("wrong type", 0.0, true, Some("capable")),
1601 ("out of range", 1.1, true, Some("capable")),
1602 ("nonfinite", f64::NAN, true, Some("capable")),
1603 ("missing probability", 0.0, true, Some("capable")),
1604 ("missing answer", 0.0, false, None),
1605 ("no distribution", 0.0, false, None),
1606 ("wrong type", 0.0, false, None),
1607 ("out of range", 1.1, false, None),
1608 ("negative", -0.1, false, None),
1609 ("nonfinite", f64::NAN, false, None),
1610 ("infinite", f64::INFINITY, false, None),
1611 ("missing probability", 0.0, false, None),
1612 ("provider error", 0.0, true, Some("capable")),
1613 ("provider error", 0.0, false, None),
1614 ("dropped reply", 0.0, true, Some("capable")),
1615 ("custom choices", 0.45, false, Some("capable")),
1616 ] {
1617 let mut config = settings.clone();
1618 if name == "below" {
1619 config.instructions = Some("Choose capable when the task needs it.".into());
1620 }
1621 if name == "custom choices" {
1622 config.choices[0].id = "complex".into();
1623 config.choices[1].id = "simple".into();
1624 config.apply_cutoff_to = "complex".into();
1625 }
1626 let override_instructions = config.instructions.clone();
1627 let choice_ids = [config.choices[0].id.clone(), config.choices[1].id.clone()];
1628 let router: Arc<dyn Algorithm> =
1629 Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1630 config: TaskClassifierConfig {
1631 judge: CapabilityJudgeConfig::Decision(config),
1632 fail_open,
1633 recent_turn_window: None,
1634 classify_trigger: ClassifyTrigger::NewSession,
1635 ..TaskClassifierConfig::default()
1636 },
1637 })?);
1638 let calls = AtomicUsize::new(0);
1639 let serve = |call| {
1640 let calls = &calls;
1641 let expected_task = &expected_task;
1642 let override_instructions = &override_instructions;
1643 let [capable_id, efficient_id] = choice_ids.clone();
1644 async move {
1645 let Call::Decision(call) = call else {
1646 panic!("judge must use the decision step")
1647 };
1648 calls.fetch_add(1, Ordering::SeqCst);
1649 assert_eq!(call.model, "judge");
1650 assert_eq!(call.request.model, Some("judge".into()));
1651 assert_eq!(
1652 serde_json::to_value(&call.request.input).unwrap(),
1653 *expected_task
1654 );
1655 assert_eq!(call.request.questions.len(), 1);
1656 let question = &call.request.questions["route"];
1657 let switchyard_protocol::DecisionKind::Choice { options } = &question.kind
1658 else {
1659 panic!("expected a choice")
1660 };
1661 assert_eq!(
1662 options
1663 .iter()
1664 .map(|option| option.id.as_str())
1665 .collect::<Vec<_>>(),
1666 [capable_id.as_str(), efficient_id.as_str()]
1667 );
1668 if let Some(instructions) = override_instructions {
1669 assert_eq!(&question.instructions, instructions);
1670 } else {
1671 assert!(
1672 question
1673 .instructions
1674 .as_str()
1675 .is_some_and(|text| !text.is_empty())
1676 );
1677 }
1678 if name == "provider error" {
1679 return call
1680 .respond(std::future::ready(Err(LibsyError::client_call(
1681 "judge",
1682 LlmClientError::General("private provider body".into()),
1683 ))))
1684 .await;
1685 }
1686 if name == "dropped reply" {
1687 drop(call);
1688 return Ok(());
1689 }
1690 let value = if name == "wrong type" {
1691 DecisionValue::Boolean(BooleanEstimate::Value(true))
1692 } else {
1693 DecisionValue::Choice {
1694 selected: efficient_id.clone(),
1695 probabilities: (name != "no distribution").then(|| {
1696 BTreeMap::from([
1697 (
1698 if name == "missing probability" {
1699 "other".into()
1700 } else {
1701 capable_id.clone()
1702 },
1703 Probability(score),
1704 ),
1705 (efficient_id.clone(), Probability(1.0 - score)),
1706 ])
1707 }),
1708 }
1709 };
1710 let answers = if name == "missing answer" {
1711 BTreeMap::new()
1712 } else {
1713 BTreeMap::from([(
1714 "route".into(),
1715 DecisionAnswer {
1716 value,
1717 provider_confidence: Some(ProviderConfidence(0.99)),
1718 },
1719 )])
1720 };
1721 call.respond(std::future::ready(Ok(DecisionResponse {
1722 id: None,
1723 model: Some("provider-judge".into()),
1724 answers,
1725 usage: Default::default(),
1726 })))
1727 .await
1728 }
1729 };
1730 let models = Arc::new(RuntimeModels::new(runtime_models()));
1731 let result = drive(router.clone(), request.clone(), models.clone(), &serve).await;
1732 assert_eq!(calls.load(Ordering::SeqCst), 1, "{name}");
1733 let Some(expected) = expected else {
1734 if name == "provider error" {
1735 assert!(
1736 matches!(result, Err(LibsyError::ClientCall { .. })),
1737 "{name}"
1738 );
1739 } else {
1740 assert!(
1741 matches!(result, Err(LibsyError::InvalidDecision { target, .. }) if target == "judge"),
1742 "expected invalid decision error: {name}"
1743 );
1744 }
1745 continue;
1746 };
1747 let outcome = result?;
1748 assert_eq!(outcome.selected_model_id()?, expected, "{name}");
1749 assert!(outcome.response.is_none());
1750 assert_eq!(outcome.request.llm_request.messages, original_messages);
1751 let metadata = outcome.metadata.expect("routing metadata");
1752 assert_eq!(metadata.judgments.len(), 1);
1753 let evidence = metadata.evidence.expect("routing evidence");
1754 if matches!(name, "above" | "equal" | "below" | "custom choices") {
1755 assert_eq!(metadata.judgments[0].target.as_deref(), Some(expected));
1756 assert_eq!(evidence["source"], "decision_classifier");
1757 assert_eq!(evidence["verdict"], expected);
1758 assert_eq!(evidence["capable_threshold"], 0.4);
1759 assert_eq!(evidence["capable_score"], score);
1760 } else {
1761 let reason = match name {
1762 "provider error" => "client_error",
1763 "dropped reply" => "call_error",
1764 _ => "invalid_verdict",
1765 };
1766 assert_eq!(
1767 evidence,
1768 json!({"source": "fail_open", "reason_code": reason})
1769 );
1770 }
1771 let retained = drive(router, request.clone(), models, &serve).await?;
1772 assert_eq!(retained.selected_model_id()?, expected);
1773 assert!(
1774 retained
1775 .metadata
1776 .expect("retained metadata")
1777 .judgments
1778 .is_empty()
1779 );
1780 assert_eq!(
1781 calls.load(Ordering::SeqCst),
1782 1,
1783 "affinity should skip the judge: {name}"
1784 );
1785 }
1786 for cutoff in [-0.1, 1.1, f64::NAN] {
1787 let mut config = settings.clone();
1788 config.cutoff = Some(cutoff);
1789 assert!(
1790 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1791 config: TaskClassifierConfig {
1792 judge: CapabilityJudgeConfig::Decision(config),
1793 ..TaskClassifierConfig::default()
1794 },
1795 })
1796 .is_err()
1797 );
1798 }
1799 Ok(())
1800 }
1801
1802 #[tokio::test]
1803 async fn direct_judge_falls_back_to_the_default_target() -> Result<()> {
1804 use crate::{Call, RuntimeModels, drive};
1805 use std::collections::BTreeMap;
1806 use switchyard_protocol::{DecisionAnswer, DecisionResponse, DecisionValue};
1807
1808 for (reply, fail_open, expected) in [
1809 (Some("unknown"), true, Some("efficient")),
1810 (Some("unknown"), false, None),
1811 (None, true, Some("efficient")),
1812 (None, false, None),
1813 ] {
1814 let router: Arc<dyn Algorithm> =
1815 Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Direct {
1816 default_target: Category::Efficient,
1817 config: DirectClassifierConfig {
1818 options: vec![Category::Efficient, Category::Capable],
1819 fail_open,
1820 classify_trigger: ClassifyTrigger::EveryRequest,
1821 message_hash_fallback: false,
1822 recent_turn_window: None,
1823 },
1824 })?);
1825 let serve = |call| async move {
1826 let Call::Decision(call) = call else {
1827 panic!("judge must use the decision step")
1828 };
1829 let response = match reply {
1830 Some(selected) => Ok(DecisionResponse {
1831 id: None,
1832 model: None,
1833 answers: BTreeMap::from([(
1834 "route".into(),
1835 DecisionAnswer {
1836 value: DecisionValue::Choice {
1837 selected: selected.into(),
1838 probabilities: None,
1839 },
1840 provider_confidence: None,
1841 },
1842 )]),
1843 usage: Default::default(),
1844 }),
1845 None => Err(LibsyError::client_call(
1846 "judge",
1847 LlmClientError::General("unavailable".into()),
1848 )),
1849 };
1850 call.respond(std::future::ready(response)).await
1851 };
1852 let models = Arc::new(RuntimeModels::new(runtime_models()));
1853 let result = drive(router, classify_request(), models, &serve).await;
1854 match expected {
1855 Some(expected) => assert_eq!(result?.selected_model_id()?, expected),
1856 None => assert!(result.is_err()),
1857 }
1858 }
1859 Ok(())
1860 }
1861
1862 #[test]
1863 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1864 let policy = policy();
1865 let at_threshold = verdict(0.5, "supported", "SUP-1");
1866 let below_threshold = verdict(0.49, "supported", "SUP-1");
1867 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1868 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1869 Ok(())
1870 }
1871
1872 #[test]
1873 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1874 let borderline = verdict(0.5, "supported", "SUP-1");
1875 let strict = TaskClassifierPolicy::new(&llm_config(0.9));
1876 let lenient = TaskClassifierPolicy::new(&llm_config(0.1));
1877 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1878 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1879 Ok(())
1880 }
1881
1882 #[test]
1883 fn classifier_config_rejects_unknown_fields() {
1884 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1885 "base_threshold": 0.5,
1886 "classifier_magic": true,
1887 }))
1888 .expect_err("unknown classifier fields must be rejected");
1889
1890 assert!(
1891 error
1892 .to_string()
1893 .contains("unknown field `classifier_magic`"),
1894 "{error}"
1895 );
1896 }
1897
1898 #[test]
1899 fn invalid_classifier_config_is_rejected() -> Result<()> {
1900 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1901 assert!(
1902 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1903 config: test_config(bad),
1904 })
1905 .is_err(),
1906 "base threshold {bad} should be rejected"
1907 );
1908 }
1909 for config in [
1910 TaskClassifierConfig {
1911 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1912 base_threshold: 0.5,
1913 threshold_step: -0.1,
1914 ..LlmCapabilityConfig::default()
1915 }),
1916 ..TaskClassifierConfig::default()
1917 },
1918 TaskClassifierConfig {
1919 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1920 base_threshold: 0.8,
1921 threshold_step: 0.11,
1922 ..LlmCapabilityConfig::default()
1923 }),
1924 ..TaskClassifierConfig::default()
1925 },
1926 TaskClassifierConfig {
1927 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1928 base_threshold: 0.5,
1929 ..LlmCapabilityConfig::default()
1930 }),
1931 message_hash_fallback: true,
1932 ..TaskClassifierConfig::default()
1933 },
1934 TaskClassifierConfig {
1935 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1936 base_threshold: 0.5,
1937 max_output_tokens: 0,
1938 ..LlmCapabilityConfig::default()
1939 }),
1940 ..TaskClassifierConfig::default()
1941 },
1942 ] {
1943 assert!(LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).is_err());
1944 }
1945 for base_threshold in [0.0, 1.0] {
1946 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1947 config: test_config(base_threshold),
1948 })?;
1949 }
1950 Ok(())
1951 }
1952
1953 #[test]
1954 fn message_hash_fallback_accepts_retaining_triggers() -> Result<()> {
1955 for trigger in [ClassifyTrigger::NewSession, ClassifyTrigger::UserTurn] {
1959 let config = TaskClassifierConfig {
1960 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1961 base_threshold: 0.5,
1962 ..LlmCapabilityConfig::default()
1963 }),
1964 classify_trigger: trigger,
1965 message_hash_fallback: true,
1966 ..TaskClassifierConfig::default()
1967 };
1968 LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).map_err(
1969 |error| LibsyError::AlgorithmError {
1970 message: format!("{trigger:?} with message_hash_fallback rejected: {error}"),
1971 },
1972 )?;
1973 }
1974 let every_request = TaskClassifierConfig {
1975 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1976 base_threshold: 0.5,
1977 ..LlmCapabilityConfig::default()
1978 }),
1979 classify_trigger: ClassifyTrigger::EveryRequest,
1980 message_hash_fallback: true,
1981 ..TaskClassifierConfig::default()
1982 };
1983 assert!(
1984 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1985 config: every_request
1986 })
1987 .is_err(),
1988 "every_request with message_hash_fallback should stay rejected"
1989 );
1990 Ok(())
1991 }
1992
1993 #[test]
1994 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1995 let policy = policy();
1996 let inconsistent_rule = TaskClassifierVerdict {
1997 capability_boundary: "uncertain".to_string(),
1998 ..verdict(1.0, "supported", "SUP-1")
1999 };
2000 let empty_crux = TaskClassifierVerdict {
2001 crux: " ".to_string(),
2002 ..verdict(1.0, "supported", "SUP-1")
2003 };
2004 let unusable = [
2005 Some(verdict(1.1, "supported", "SUP-1")),
2006 Some(inconsistent_rule),
2007 Some(empty_crux),
2008 None,
2009 ];
2010 for verdict in unusable {
2011 let classification = policy.to_classification(verdict.as_ref(), &policy_driver())?;
2012 assert!(matches!(classification, Classification::Ambiguous(_)));
2013 assert!(classification.argmax(false)?.is_none());
2014 assert!(classification.argmax(true)?.is_none());
2015 }
2016 Ok(())
2017 }
2018
2019 #[test]
2020 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
2021 let policy = TaskClassifierPolicy::new(&LlmCapabilityConfig {
2022 threshold_step: 0.1,
2023 ..llm_config(0.4)
2024 });
2025
2026 assert_eq!(
2027 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
2028 "efficient"
2029 );
2030 assert_eq!(
2031 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
2032 "capable"
2033 );
2034 assert_eq!(
2035 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
2036 "efficient"
2037 );
2038 assert_eq!(
2039 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
2040 "efficient"
2041 );
2042 assert_eq!(
2043 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
2044 "capable"
2045 );
2046 assert_eq!(
2047 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
2048 "efficient"
2049 );
2050 Ok(())
2051 }
2052
2053 #[test]
2054 fn unmatched_steps_raise_only_the_unmatched_threshold() -> Result<()> {
2055 let policy = TaskClassifierPolicy::new(&LlmCapabilityConfig {
2056 threshold_step: 0.1,
2057 unmatched_steps: 2,
2058 ..llm_config(0.75)
2059 });
2060
2061 assert_eq!(
2063 selected(&policy, Some(&verdict(0.85, "unmatched", "none")))?,
2064 "capable"
2065 );
2066 assert_eq!(
2067 selected(&policy, Some(&verdict(0.95, "unmatched", "none")))?,
2068 "efficient"
2069 );
2070 assert_eq!(
2072 selected(&policy, Some(&verdict(0.85, "uncertain", "UNC-1")))?,
2073 "efficient"
2074 );
2075 assert_eq!(
2076 selected(&policy, Some(&verdict(0.75, "supported", "SUP-2")))?,
2077 "efficient"
2078 );
2079 Ok(())
2080 }
2081
2082 #[test]
2083 fn unmatched_steps_default_to_one_and_reject_values_above_two() {
2084 let parsed: TaskClassifierConfig =
2085 serde_json::from_value(serde_json::json!({ "base_threshold": 0.5 }))
2086 .expect("config without unmatched_steps parses");
2087 let CapabilityJudgeConfig::Llm(judge) = &parsed.judge else {
2088 panic!("a flat capability config builds an LLM judge");
2089 };
2090 assert_eq!(judge.unmatched_steps, 1);
2091
2092 let invalid = TaskClassifierConfig {
2093 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
2094 unmatched_steps: 3,
2095 ..llm_config(0.5)
2096 }),
2097 ..TaskClassifierConfig::default()
2098 };
2099 assert!(invalid.validate().is_err());
2100 }
2101
2102 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
2105 Ok(StructuredJudge::new(
2106 TaskInput { recent_turn_window },
2107 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
2108 SerdeDecoder::new(),
2109 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
2110 ))
2111 }
2112
2113 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
2114 let judge = capability_judge(Some(recent_turn_window))?;
2115 let request = Request {
2116 llm_request: LlmRequest {
2117 messages: vec![
2118 Message::text(Role::System, "client instructions"),
2119 Message::text(
2120 Role::User,
2121 "<environment_context>setup</environment_context>",
2122 ),
2123 Message::text(Role::User, "initial task"),
2124 Message::text(Role::Assistant, "old response"),
2125 Message::text(Role::User, "old follow-up"),
2126 Message::text(Role::Assistant, "recent 1"),
2127 Message::text(Role::User, "recent 2"),
2128 ],
2129 ..LlmRequest::default()
2130 },
2131 raw_request: None,
2132 metadata: None,
2133 };
2134 Ok(judge
2135 .build_request(&State::default(), &request)
2136 .llm_request
2137 .messages
2138 .iter()
2139 .filter_map(|message| message.text_content("\n"))
2140 .collect())
2141 }
2142
2143 #[test]
2144 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
2145 let contents = judged_contents(2)?;
2147 assert!(contents.contains(&"client instructions".to_string()));
2148 assert!(contents.contains(&"initial task".to_string()));
2149 assert!(contents.contains(&"recent 1".to_string()));
2150 assert!(contents.contains(&"recent 2".to_string()));
2151 assert!(!contents.contains(&"old response".to_string()));
2152 Ok(())
2153 }
2154
2155 #[test]
2156 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
2157 let contents = judged_contents(0)?;
2158 assert!(contents.contains(&"client instructions".to_string()));
2159 assert!(contents.contains(&"initial task".to_string()));
2160 assert!(!contents.contains(&"recent 2".to_string()));
2161 Ok(())
2162 }
2163
2164 fn tool_call(id: &str) -> Message {
2165 Message::new(
2166 Role::Assistant,
2167 vec![ContentBlock::ToolCall(ToolCall {
2168 id: id.to_string(),
2169 name: "search".to_string(),
2170 arguments: Value::Null,
2171 })],
2172 )
2173 }
2174
2175 fn tool_result(id: &str) -> Message {
2176 Message::new(
2177 Role::Tool,
2178 vec![ContentBlock::ToolResult(ToolResult {
2179 tool_call_id: id.to_string(),
2180 content: vec![ContentBlock::Text {
2181 text: "tool output".to_string(),
2182 }],
2183 is_error: None,
2184 })],
2185 )
2186 }
2187
2188 #[test]
2189 fn default_task_input_keeps_user_content_around_tool_results() {
2190 let context = Message::text(
2191 Role::User,
2192 "<environment_context><current_date>2026-10-31</current_date></environment_context>",
2193 );
2194 let mut result = tool_result("call-1");
2195 result.role = Role::User;
2196 let mut mixed = result.clone();
2197 mixed.content.push(ContentBlock::Text {
2198 text: "latest follow-up".to_string(),
2199 });
2200 let input = TaskInput {
2201 recent_turn_window: None,
2202 };
2203 let mut request = Request {
2204 llm_request: LlmRequest {
2205 messages: vec![
2206 context.clone(),
2207 result.clone(),
2208 Message::text(Role::User, "initial task"),
2209 tool_call("call-1"),
2210 mixed,
2211 result.clone(),
2212 context,
2213 ],
2214 ..LlmRequest::default()
2215 },
2216 ..Request::default()
2217 };
2218 assert_eq!(
2219 input.build_messages(&State::default(), &request),
2220 vec![
2221 Message::text(Role::User, "initial task"),
2222 Message::text(Role::User, "latest follow-up"),
2223 ]
2224 );
2225 request.llm_request.messages = vec![result];
2226 assert!(input.build_messages(&State::default(), &request).is_empty());
2227 }
2228
2229 #[test]
2232 fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
2233 let messages = vec![
2234 Message::text(Role::System, "client instructions"),
2235 Message::text(Role::User, "initial task"),
2236 Message::text(Role::Assistant, "old response"),
2237 tool_call("call-1"),
2238 tool_result("call-1"),
2239 Message::text(Role::Assistant, "recent 1"),
2240 Message::text(Role::User, "recent 2"),
2241 Message::text(Role::Assistant, "recent 3"),
2242 Message::text(Role::User, "recent 4"),
2243 ];
2244
2245 let kept = trim_messages(&messages, 5);
2247
2248 assert_eq!(
2249 kept,
2250 vec![
2251 Message::text(Role::System, "client instructions"),
2252 Message::text(Role::User, "initial task"),
2253 tool_call("call-1"),
2254 tool_result("call-1"),
2255 Message::text(Role::Assistant, "recent 1"),
2256 Message::text(Role::User, "recent 2"),
2257 Message::text(Role::Assistant, "recent 3"),
2258 Message::text(Role::User, "recent 4"),
2259 ]
2260 );
2261 }
2262
2263 #[test]
2266 fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
2267 let messages = vec![
2268 Message::text(Role::System, "client instructions"),
2269 Message::text(Role::User, "initial task"),
2270 tool_call("x"),
2271 tool_result("x"),
2272 Message::text(Role::Assistant, "later"),
2273 tool_call("x"),
2274 tool_result("x"),
2275 ];
2276
2277 let kept = trim_messages(&messages, 4);
2279
2280 assert_eq!(
2281 kept,
2282 vec![
2283 Message::text(Role::System, "client instructions"),
2284 Message::text(Role::User, "initial task"),
2285 tool_call("x"),
2286 tool_result("x"),
2287 Message::text(Role::Assistant, "later"),
2288 tool_call("x"),
2289 tool_result("x"),
2290 ]
2291 );
2292 }
2293
2294 #[test]
2297 fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
2298 let messages = vec![
2299 Message::text(Role::System, "client instructions"),
2300 tool_call("orphan"),
2301 Message::text(Role::User, "initial task"),
2302 Message::text(Role::Assistant, "old response"),
2303 tool_result("orphan"),
2304 Message::text(Role::Assistant, "recent 1"),
2305 Message::text(Role::User, "recent 2"),
2306 ];
2307
2308 let kept = trim_messages(&messages, 3);
2309
2310 assert_eq!(
2311 kept,
2312 vec![
2313 Message::text(Role::System, "client instructions"),
2314 Message::text(Role::User, "initial task"),
2315 tool_result("orphan"),
2316 Message::text(Role::Assistant, "recent 1"),
2317 Message::text(Role::User, "recent 2"),
2318 ]
2319 );
2320 }
2321
2322 #[test]
2323 fn a_window_restates_the_routing_instruction_last() -> Result<()> {
2324 let contents = judged_contents(2)?;
2325
2326 assert_eq!(
2329 contents.last().map(String::as_str),
2330 Some(TRAILING_ROUTING_INSTRUCTION)
2331 );
2332 Ok(())
2333 }
2334
2335 #[test]
2340 fn a_window_drops_reasoning_but_keeps_visible_text_and_tool_pairs() {
2341 let messages = vec![
2342 Message::text(Role::System, "client instructions"),
2343 Message::text(Role::User, "initial task"),
2344 Message::new(
2345 Role::Assistant,
2346 vec![
2347 ContentBlock::Reasoning {
2348 text: "private chain of thought".to_string(),
2349 signature: None,
2350 details: Vec::new(),
2351 },
2352 ContentBlock::Text {
2353 text: "visible answer".to_string(),
2354 },
2355 ],
2356 ),
2357 tool_call("call-1"),
2358 tool_result("call-1"),
2359 Message::new(
2360 Role::Assistant,
2361 vec![ContentBlock::Reasoning {
2362 text: "reasoning-only turn".to_string(),
2363 signature: None,
2364 details: Vec::new(),
2365 }],
2366 ),
2367 Message::text(Role::User, "follow-up"),
2368 ];
2369 let request = Request {
2370 llm_request: LlmRequest {
2371 messages,
2372 ..LlmRequest::default()
2373 },
2374 raw_request: None,
2375 metadata: None,
2376 };
2377
2378 let built = TaskInput {
2379 recent_turn_window: Some(10),
2380 }
2381 .build_messages(&State::default(), &request);
2382
2383 assert!(
2384 !built
2385 .iter()
2386 .flat_map(|message| &message.content)
2387 .any(|block| matches!(block, ContentBlock::Reasoning { .. })),
2388 "{built:?}"
2389 );
2390 assert!(
2391 built
2392 .iter()
2393 .any(|message| message.text_content("\n").as_deref() == Some("visible answer"))
2394 );
2395 assert!(built.contains(&tool_call("call-1")));
2396 assert!(built.contains(&tool_result("call-1")));
2397 assert_eq!(built.len(), 7);
2400 assert!(built.iter().all(|message| !message.content.is_empty()));
2401 }
2402
2403 #[test]
2404 fn a_window_drops_a_turn_left_empty_once_its_reasoning_is_removed() {
2405 let body = serde_json::json!({
2410 "model": "qa/windowed",
2411 "messages": [
2412 {"role": "user", "content": "Reply with the number four."},
2413 {"role": "assistant", "reasoning_content": "private chain of thought"},
2414 {"role": "user", "content": "This is still a simple number lookup."}
2415 ]
2416 });
2417 let request = Request {
2418 llm_request: switchyard_translation::decode_request(
2419 switchyard_translation::WireFormat::OpenAiChat,
2420 &body,
2421 )
2422 .expect("client request decodes"),
2423 ..Request::default()
2424 };
2425
2426 let built = TaskInput {
2427 recent_turn_window: Some(8),
2428 }
2429 .build_messages(&State::default(), &request);
2430
2431 assert_eq!(
2432 built,
2433 vec![
2434 Message::text(Role::User, "Reply with the number four."),
2435 Message::text(Role::User, "This is still a simple number lookup."),
2436 Message::text(Role::User, TRAILING_ROUTING_INSTRUCTION),
2437 ]
2438 );
2439 let judge_request = LlmRequest {
2440 messages: built,
2441 ..LlmRequest::default()
2442 };
2443 let encoded = switchyard_translation::encode_request(
2444 &judge_request,
2445 switchyard_translation::WireFormat::AnthropicMessages,
2446 )
2447 .expect("judge request encodes");
2448 let has_empty_text = encoded["messages"]
2449 .as_array()
2450 .expect("messages")
2451 .iter()
2452 .flat_map(|message| match &message["content"] {
2453 Value::Array(blocks) => blocks.iter().map(|block| &block["text"]).collect(),
2454 content => vec![content],
2455 })
2456 .any(|text| text.as_str().is_some_and(str::is_empty));
2457 assert!(!has_empty_text, "{encoded}");
2458 }
2459
2460 #[test]
2461 fn the_default_path_is_left_unchanged() -> Result<()> {
2462 let judge = capability_judge(None)?;
2465 let request = Request {
2466 llm_request: LlmRequest {
2467 messages: vec![Message::text(Role::User, "the task")],
2468 ..LlmRequest::default()
2469 },
2470 raw_request: None,
2471 metadata: None,
2472 };
2473
2474 let built = judge.build_request(&State::default(), &request);
2475
2476 assert_eq!(built.llm_request.messages.len(), 1);
2479 assert!(!built.llm_request.instructions.is_empty());
2480 assert!(
2481 !built
2482 .llm_request
2483 .messages
2484 .iter()
2485 .filter_map(|message| message.text_content("\n"))
2486 .any(|text| text.contains(TRAILING_ROUTING_INSTRUCTION))
2487 );
2488 Ok(())
2489 }
2490
2491 #[test]
2492 fn capability_judge_builds_a_structured_request() -> Result<()> {
2493 let judge = capability_judge(None)?;
2494 let request = Request {
2495 llm_request: LlmRequest {
2496 model: Some("inbound".to_string()),
2497 messages: vec![
2498 Message::text(Role::System, "client instructions"),
2499 Message::text(Role::Developer, "client developer instructions"),
2500 Message::text(Role::User, "initial task"),
2501 Message::text(Role::Assistant, "old response"),
2502 Message::text(Role::User, "old follow-up"),
2503 Message::text(Role::Assistant, "recent 1"),
2504 Message::text(Role::User, "recent 2"),
2505 Message::text(Role::Assistant, "recent 3"),
2506 Message::text(Role::User, "recent 4"),
2507 Message::text(Role::Assistant, "recent 5"),
2508 ],
2509 ..LlmRequest::default()
2510 },
2511 raw_request: None,
2512 metadata: None,
2513 };
2514 let judge_request = judge.build_request(&State::default(), &request);
2515
2516 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
2517 assert_eq!(judge_request.llm_request.instructions.len(), 1);
2518 assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
2519 assert_eq!(
2520 judge_request.llm_request.instructions[0].content,
2521 InstructionBlock {
2522 role: Role::System,
2523 content: Message::text(Role::System, judge.contract().system_prompt()).content,
2524 }
2525 .content,
2526 );
2527 assert_eq!(judge_request.llm_request.messages.len(), 2);
2528 let contents = judge_request
2529 .llm_request
2530 .messages
2531 .iter()
2532 .filter_map(|message| message.text_content("\n"))
2533 .collect::<Vec<_>>();
2534 assert!(contents.contains(&"recent 4".to_string()));
2535 assert!(contents.contains(&"initial task".to_string()));
2536 assert!(!contents.contains(&"recent 5".to_string()));
2537 assert!(!contents.contains(&"client instructions".to_string()));
2538 assert_eq!(
2539 judge_request.llm_request.output.response_format,
2540 Some(judge.contract().response_format().clone())
2541 );
2542 assert_eq!(
2543 judge_request.llm_request.output.max_output_tokens,
2544 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
2545 );
2546 Ok(())
2547 }
2548
2549 fn sample_value(spec: &Value) -> Value {
2550 if let Some(first) = spec
2551 .get("enum")
2552 .and_then(Value::as_array)
2553 .and_then(|values| values.first())
2554 {
2555 return first.clone();
2556 }
2557 match spec.get("type").and_then(Value::as_str) {
2558 Some("number") => serde_json::json!(0.5),
2559 Some("boolean") => serde_json::json!(false),
2560 _ => serde_json::json!("sample"),
2561 }
2562 }
2563
2564 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
2565 let properties = schema
2566 .pointer("/json_schema/schema/properties")
2567 .and_then(Value::as_object)
2568 .ok_or_else(|| LibsyError::AlgorithmError {
2569 message: "packaged schema declares no properties".to_string(),
2570 })?;
2571 Ok(Value::Object(
2572 properties
2573 .iter()
2574 .map(|(name, spec)| (name.clone(), sample_value(spec)))
2575 .collect(),
2576 )
2577 .to_string())
2578 }
2579
2580 #[test]
2583 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
2584 let contract =
2585 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
2586 let schema = contract.response_format();
2587 let reply = schema_shaped_verdict(schema)?;
2588 let judge: CapabilityJudge = StructuredJudge::new(
2589 TaskInput {
2590 recent_turn_window: None,
2591 },
2592 contract,
2593 SerdeDecoder::new(),
2594 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
2595 );
2596
2597 let verdict = judge.parse(&text_response(None, reply))?;
2598
2599 assert!(verdict.is_valid());
2600 assert!((0.0..=1.0).contains(&verdict.p_solve));
2601 Ok(())
2602 }
2603
2604 #[test]
2605 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
2606 let contract =
2607 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
2608 let prompt = contract.system_prompt();
2609 let schema_name = contract
2610 .response_format()
2611 .pointer("/json_schema/name")
2612 .and_then(Value::as_str)
2613 .ok_or_else(|| LibsyError::AlgorithmError {
2614 message: "packaged response schema has no name".to_string(),
2615 })?;
2616 assert_eq!(schema_name, "CapabilityClassifierDecision");
2617 assert!(prompt.contains("SUP-1 [supported]"));
2618 assert!(prompt.contains("SUP-5 [supported]"));
2619 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
2620 assert!(!prompt.contains("\"type\": \"object\""));
2621 assert!(!prompt.contains("\"json_schema\""));
2622 assert!(!prompt.contains(schema_name));
2623 let rule_values = contract
2624 .response_format()
2625 .pointer("/json_schema/schema/properties/primary_rule/enum")
2626 .and_then(Value::as_array)
2627 .ok_or_else(|| LibsyError::AlgorithmError {
2628 message: "rendered response schema has no primary rule enum".to_string(),
2629 })?;
2630 assert!(
2631 rule_values
2632 .iter()
2633 .any(|value| value.as_str() == Some("SUP-1"))
2634 );
2635 assert!(
2636 rule_values
2637 .iter()
2638 .any(|value| value.as_str() == Some("none"))
2639 );
2640 Ok(())
2641 }
2642}