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