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