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;
15use decision::DecisionClassifier;
16pub use decision::DecisionJudgeConfig;
17
18use super::escalation;
19use super::fall_through::FallThrough;
20use super::util::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS;
21use super::util::affinity::{AffinityRouter, ClassifyTrigger};
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) -> Option<u8> {
81 match self.capability_boundary.as_str() {
82 "supported" => Some(0),
83 "uncertain" | "unmatched" => Some(1),
84 "unsupported" => Some(2),
85 _ => None,
86 }
87 }
88}
89
90fn trim_messages(messages: &[Message], recent_turn_window: usize) -> Vec<Message> {
100 let is_instruction = |message: &Message| matches!(message.role, Role::System | Role::Developer);
101 let mut kept: Vec<&Message> = messages.iter().filter(|m| is_instruction(m)).collect();
102 let Some(task) = messages.iter().position(|m| m.role == Role::User) else {
103 return kept.into_iter().cloned().collect();
104 };
105 kept.push(&messages[task]);
106
107 let tail: Vec<&Message> = messages[task + 1..]
108 .iter()
109 .filter(|m| !is_instruction(m))
110 .collect();
111 kept.extend(&tail[window_start(&tail, recent_turn_window)..]);
112 kept.into_iter().cloned().collect()
113}
114
115fn window_start(tail: &[&Message], recent_turn_window: usize) -> usize {
128 let counted = tail.len().saturating_sub(recent_turn_window);
129 if counted == tail.len() {
131 return counted;
132 }
133 let mut unpaired: HashSet<&str> = HashSet::new();
134 for (start, message) in tail.iter().enumerate().rev() {
135 for block in message.content.iter().rev() {
138 match block {
139 ContentBlock::ToolResult(result) => {
140 unpaired.insert(result.tool_call_id.as_str());
141 }
142 ContentBlock::ToolCall(call) => {
143 unpaired.remove(call.id.as_str());
144 }
145 _ => {}
146 }
147 }
148 if start <= counted && unpaired.is_empty() {
149 return start;
150 }
151 }
152 counted
153}
154
155fn task_messages(messages: &[Message]) -> Vec<Message> {
157 let is_task_content = |block: &ContentBlock| {
160 !matches!(
161 block,
162 ContentBlock::ToolCall(_)
163 | ContentBlock::ToolResult(_)
164 | ContentBlock::Reasoning { .. }
165 )
166 };
167 let mut user_messages = messages.iter().filter(|message| {
168 message.role == Role::User && message.content.iter().any(is_task_content)
169 });
170 let Some(opening_task) = user_messages.next() else {
171 return Vec::new();
172 };
173 [Some(opening_task), user_messages.next_back()]
174 .into_iter()
175 .flatten()
176 .map(|message| Message {
177 role: Role::User,
178 content: message
179 .content
180 .iter()
181 .filter(|block| is_task_content(block))
182 .cloned()
183 .collect(),
184 })
185 .collect()
186}
187
188struct TaskInput {
190 recent_turn_window: Option<usize>,
191}
192
193impl TaskInput {
194 fn messages(&self, request: &Request) -> Vec<Message> {
196 let mut messages = match self.recent_turn_window {
199 Some(window) => trim_messages(&request.llm_request.messages, window),
200 None => task_messages(&request.llm_request.messages),
201 };
202 for message in &mut messages {
207 message
208 .content
209 .retain(|block| !matches!(block, ContentBlock::Reasoning { .. }));
210 }
211 messages.retain(|message| !message.content.is_empty());
212 messages
213 }
214}
215
216impl ClassifierInput for TaskInput {
217 fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
218 let mut messages = self.messages(request);
219 if self.recent_turn_window.is_some() {
223 messages.push(Message::text(
224 Role::User,
225 TRAILING_ROUTING_INSTRUCTION.to_string(),
226 ));
227 }
228 messages
229 }
230}
231
232struct TaskClassifierPolicy {
233 base_threshold: f64,
234 threshold_step: f64,
235}
236
237impl TaskClassifierPolicy {
238 fn new(config: &LlmCapabilityConfig) -> Self {
239 Self {
240 base_threshold: config.base_threshold,
241 threshold_step: config.threshold_step,
242 }
243 }
244
245 fn threshold(&self, verdict: &TaskClassifierVerdict) -> Option<f64> {
247 Some(self.base_threshold + f64::from(verdict.boundary_steps()?) * self.threshold_step)
248 }
249}
250
251impl JudgePolicy for TaskClassifierPolicy {
252 type Verdict = TaskClassifierVerdict;
253
254 fn to_classification(
255 &self,
256 verdict: Option<&Self::Verdict>,
257 driver: &Driver,
258 ) -> Result<Classification> {
259 let Some(verdict) = verdict.filter(|verdict| verdict.is_valid()) else {
262 return Ok(Classification::Ambiguous(vec![]));
263 };
264 let Some(threshold) = self.threshold(verdict) else {
267 return Ok(Classification::Ambiguous(vec![]));
268 };
269 let category = if verdict.p_solve >= threshold
270 || (threshold - verdict.p_solve).abs() <= f64::EPSILON
271 {
272 Category::Efficient
273 } else {
274 Category::Capable
275 };
276 let Some(target) = driver.models_for(&category).first().cloned() else {
279 return Ok(Classification::Ambiguous(vec![]));
280 };
281 Ok(Classification::Scores(vec![Score {
282 target,
283 confidence: 1.0,
284 category: Some(category),
285 }]))
286 }
287}
288
289fn capability_evidence(
291 policy: &TaskClassifierPolicy,
292 verdict: Option<&TaskClassifierVerdict>,
293) -> Option<Value> {
294 let verdict = verdict?;
295 let Some(threshold) = verdict
296 .is_valid()
297 .then(|| policy.threshold(verdict))
298 .flatten()
299 else {
300 return Some(serde_json::json!({
301 "source": "fail_open",
302 "reason_code": "invalid_verdict",
303 }));
304 };
305 Some(serde_json::json!({
306 "source": "llm-classifier",
307 "score": verdict.p_solve,
308 "threshold": threshold,
309 }))
310}
311
312#[derive(Clone, Debug)]
314pub struct TaskClassifierConfig {
315 pub judge: CapabilityJudgeConfig,
317 pub fail_open: bool,
319 pub classify_trigger: ClassifyTrigger,
321 pub message_hash_fallback: bool,
323 pub recent_turn_window: Option<usize>,
326}
327
328#[derive(Clone, Debug)]
330pub enum CapabilityJudgeConfig {
331 Llm(LlmCapabilityConfig),
333 Decision(DecisionJudgeConfig),
335}
336
337impl Default for CapabilityJudgeConfig {
338 fn default() -> Self {
339 Self::Llm(LlmCapabilityConfig::default())
340 }
341}
342
343#[derive(Clone, Debug)]
345pub struct LlmCapabilityConfig {
346 pub base_threshold: f64,
348 pub threshold_step: f64,
351 pub contract: ClassifierContractConfig,
353 pub max_output_tokens: u64,
355}
356
357impl Default for LlmCapabilityConfig {
358 fn default() -> Self {
359 Self {
360 base_threshold: 0.0,
361 threshold_step: 0.0,
362 contract: ClassifierContractConfig::default(),
363 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
364 }
365 }
366}
367
368#[derive(Deserialize)]
370#[serde(deny_unknown_fields)]
371struct TaskClassifierConfigWire {
372 base_threshold: f64,
373 #[serde(default = "default_fail_open")]
374 fail_open: bool,
375 #[serde(default)]
376 threshold_step: f64,
377 #[serde(default)]
378 classify_trigger: ClassifyTrigger,
379 #[serde(default)]
380 message_hash_fallback: bool,
381 #[serde(default)]
382 recent_turn_window: Option<usize>,
383 #[serde(default)]
384 prompt: Option<String>,
385 #[serde(default)]
386 response_format_type: ClassifierResponseFormat,
387 #[serde(default = "default_judge_max_output_tokens")]
388 max_output_tokens: u64,
389}
390
391impl<'de> Deserialize<'de> for TaskClassifierConfig {
392 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
393 where
394 D: Deserializer<'de>,
395 {
396 let wire = TaskClassifierConfigWire::deserialize(deserializer)?;
397 let mut contract = ClassifierContractConfig::default();
398 if let Some(prompt) = wire.prompt {
399 contract = contract.with_prompt(prompt);
400 }
401 contract = contract.with_response_format_type(wire.response_format_type);
402 Ok(Self {
403 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
404 base_threshold: wire.base_threshold,
405 threshold_step: wire.threshold_step,
406 contract,
407 max_output_tokens: wire.max_output_tokens,
408 }),
409 fail_open: wire.fail_open,
410 classify_trigger: wire.classify_trigger,
411 message_hash_fallback: wire.message_hash_fallback,
412 recent_turn_window: wire.recent_turn_window,
413 })
414 }
415}
416
417const fn default_fail_open() -> bool {
418 true
419}
420
421const fn default_judge_max_output_tokens() -> u64 {
422 DEFAULT_JUDGE_MAX_OUTPUT_TOKENS
423}
424
425impl Default for TaskClassifierConfig {
426 fn default() -> Self {
427 Self {
428 judge: CapabilityJudgeConfig::default(),
429 fail_open: default_fail_open(),
430 classify_trigger: ClassifyTrigger::default(),
431 message_hash_fallback: false,
432 recent_turn_window: None,
433 }
434 }
435}
436
437impl LlmCapabilityConfig {
438 fn validate(&self) -> Result<()> {
439 if !(0.0..=1.0).contains(&self.base_threshold) {
440 return Err(LibsyError::AlgorithmError {
441 message: format!(
442 "base_threshold must be between 0 and 1, got {}",
443 self.base_threshold
444 ),
445 });
446 }
447 if !self.threshold_step.is_finite() || self.threshold_step < 0.0 {
448 return Err(LibsyError::AlgorithmError {
449 message: format!(
450 "threshold_step must be finite and greater than or equal to 0, got {}",
451 self.threshold_step
452 ),
453 });
454 }
455 let unsupported_threshold = self.base_threshold + 2.0 * self.threshold_step;
456 if unsupported_threshold > 1.0 && unsupported_threshold - 1.0 > f64::EPSILON {
457 return Err(LibsyError::AlgorithmError {
458 message: format!(
459 "base_threshold + 2 * threshold_step must be at most 1, got {unsupported_threshold}"
460 ),
461 });
462 }
463 if self.max_output_tokens == 0 {
464 return Err(LibsyError::AlgorithmError {
465 message: "max_output_tokens must be at least 1".to_string(),
466 });
467 }
468 Ok(())
469 }
470}
471
472impl TaskClassifierConfig {
473 fn validate(&self) -> Result<()> {
474 match &self.judge {
475 CapabilityJudgeConfig::Llm(config) => config.validate()?,
476 CapabilityJudgeConfig::Decision(config) => config.validate()?,
477 }
478 if self.message_hash_fallback && self.classify_trigger == ClassifyTrigger::EveryRequest {
480 return Err(LibsyError::AlgorithmError {
481 message:
482 "message_hash_fallback requires classify_trigger = new_session or user_turn"
483 .to_string(),
484 });
485 }
486 Ok(())
487 }
488}
489
490#[derive(Clone, Debug)]
492pub enum CustomClassifierPolicy {
493 TargetSelector {
495 selector: String,
497 },
498}
499
500impl CustomClassifierPolicy {
501 pub fn target_selector(selector: impl Into<String>) -> Self {
503 Self::TargetSelector {
504 selector: selector.into(),
505 }
506 }
507}
508
509#[derive(Clone, Debug)]
511pub struct CustomClassifierConfig {
512 pub prompt: String,
514 pub response_schema: Value,
516 pub policy: CustomClassifierPolicy,
518 pub classify_trigger: ClassifyTrigger,
520 pub message_hash_fallback: bool,
522 pub recent_turn_window: Option<usize>,
524 pub max_output_tokens: u64,
526}
527
528impl CustomClassifierConfig {
529 pub fn new(
531 prompt: impl Into<String>,
532 response_schema: Value,
533 policy: CustomClassifierPolicy,
534 ) -> Self {
535 Self {
536 prompt: prompt.into(),
537 response_schema,
538 policy,
539 classify_trigger: ClassifyTrigger::default(),
540 message_hash_fallback: false,
541 recent_turn_window: None,
542 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
543 }
544 }
545
546 fn validate(&self) -> Result<()> {
547 if self.max_output_tokens == 0 {
548 return Err(LibsyError::AlgorithmError {
549 message: "max_output_tokens must be at least 1".to_string(),
550 });
551 }
552 if self.message_hash_fallback && self.classify_trigger == ClassifyTrigger::EveryRequest {
554 return Err(LibsyError::AlgorithmError {
555 message:
556 "message_hash_fallback requires classify_trigger = new_session or user_turn"
557 .to_string(),
558 });
559 }
560 Ok(())
561 }
562}
563
564enum CustomPolicyRuntime {
565 TargetSelector(TargetSelectorPolicy),
566}
567
568impl JudgePolicy for CustomPolicyRuntime {
569 type Verdict = Value;
570
571 fn to_classification(
572 &self,
573 verdict: Option<&Self::Verdict>,
574 driver: &Driver,
575 ) -> Result<Classification> {
576 match self {
577 Self::TargetSelector(policy) => policy.to_classification(verdict, driver),
578 }
579 }
580}
581
582fn affinity_router(
584 trigger: ClassifyTrigger,
585 message_hash_fallback: bool,
586) -> Option<Arc<AffinityRouter>> {
587 let router = match trigger {
588 ClassifyTrigger::EveryRequest => return None,
589 ClassifyTrigger::NewSession => AffinityRouter::new(),
590 ClassifyTrigger::UserTurn => AffinityRouter::new().with_release_on_user_turn(),
591 };
592 let router = if message_hash_fallback {
593 router.with_message_hash_fallback()
594 } else {
595 router
596 };
597 Some(Arc::new(router))
598}
599
600pub struct LlmTaskClassifier {
602 route: FallThrough,
603 inner: Arc<dyn Classifier>,
605}
606
607struct ClassifierRouteConfig {
608 default_target: Category,
609 classify_trigger: ClassifyTrigger,
610 message_hash_fallback: bool,
611}
612
613pub struct DefaultCategoryClassifier(pub Category);
616
617#[async_trait]
618impl Classifier for DefaultCategoryClassifier {
619 async fn score(
620 &self,
621 _state: &mut State,
622 _request: &mut Request,
623 driver: &Driver,
624 ) -> Result<(Classification, Option<Response>)> {
625 let target = driver.first_model_for(&self.0)?;
626 driver.set_evidence_if_empty(serde_json::json!({"source": "fall_open"}));
627 Ok((
628 Classification::Scores(vec![Score {
629 target: target.clone(),
630 confidence: 0.0,
631 category: Some(self.0.clone()),
632 }]),
633 None,
634 ))
635 }
636}
637
638#[derive(Clone)]
640#[non_exhaustive]
641pub enum LlmClassifierConfig {
642 Capability {
644 config: TaskClassifierConfig,
646 },
647 Escalation {
649 contract: ClassifierContractConfig,
651 config: EscalationJudgeConfig,
653 max_output_tokens: u64,
655 },
656 Custom {
658 default_target: Category,
660 config: CustomClassifierConfig,
662 },
663}
664
665impl LlmTaskClassifier {
666 pub fn new(config: LlmClassifierConfig) -> Result<Self> {
673 match config {
674 LlmClassifierConfig::Capability { config } => Self::build_capability(config),
675 LlmClassifierConfig::Escalation {
676 contract,
677 config,
678 max_output_tokens,
679 } => Self::build_escalation(contract, config, max_output_tokens),
680 LlmClassifierConfig::Custom {
681 default_target,
682 config,
683 } => Self::build_custom(default_target, config),
684 }
685 }
686
687 fn build_capability(config: TaskClassifierConfig) -> Result<Self> {
688 config.validate()?;
689 let classify_trigger = config.classify_trigger;
690 let message_hash_fallback = config.message_hash_fallback;
691 let input = TaskInput {
692 recent_turn_window: config.recent_turn_window,
693 };
694 let classifier: Arc<dyn Classifier> = match config.judge {
695 CapabilityJudgeConfig::Llm(judge) => Arc::new(
696 JudgeClassifier::new(
697 StructuredJudge::new(
698 input,
699 Self::load_capability_contract(&judge.contract)?,
700 SerdeDecoder::new(),
701 JudgeRuntimeConfig::new(judge.max_output_tokens)?,
702 ),
703 TaskClassifierPolicy::new(&judge),
704 )
705 .with_error_recovery(config.fail_open)
706 .with_evidence(capability_evidence),
707 ),
708 CapabilityJudgeConfig::Decision(judge) => {
709 Arc::new(DecisionClassifier::new(judge, input, config.fail_open)?)
710 }
711 };
712 Self::from_classifier(
713 classifier,
714 ClassifierRouteConfig {
715 default_target: Category::Capable,
716 classify_trigger,
717 message_hash_fallback,
718 },
719 )
720 }
721
722 fn build_custom(default_target: Category, config: CustomClassifierConfig) -> Result<Self> {
723 Self::from_classifier(
724 Self::custom_classifier(&config, false)?,
725 ClassifierRouteConfig {
726 default_target,
727 classify_trigger: config.classify_trigger,
728 message_hash_fallback: config.message_hash_fallback,
729 },
730 )
731 }
732
733 pub(crate) fn custom_classifier(
735 config: &CustomClassifierConfig,
736 fail_open: bool,
737 ) -> Result<Arc<dyn Classifier>> {
738 config.validate()?;
739 let contract =
740 ClassifierContract::from_inner_schema(&config.prompt, config.response_schema.clone())?;
741 let policy = match &config.policy {
742 CustomClassifierPolicy::TargetSelector { selector } => {
743 CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(selector)?)
744 }
745 };
746 let classifier = JudgeClassifier::new(
747 StructuredJudge::new(
748 TaskInput {
749 recent_turn_window: config.recent_turn_window,
750 },
751 contract,
752 JsonSchemaDecoder::new(),
753 JudgeRuntimeConfig::new(config.max_output_tokens)?,
754 ),
755 policy,
756 )
757 .with_error_recovery(fail_open);
758 let classifier = if fail_open {
759 classifier.with_evidence(|_, verdict| {
760 verdict.map(|_| serde_json::json!({"source": "llm-classifier"}))
761 })
762 } else {
763 classifier
764 };
765 Ok(Arc::new(classifier))
766 }
767
768 fn build_escalation(
769 contract_config: ClassifierContractConfig,
770 config: EscalationJudgeConfig,
771 max_output_tokens: u64,
772 ) -> Result<Self> {
773 let inner = escalation::build_classifier(contract_config, config, max_output_tokens)?;
774 Ok(Self {
775 route: FallThrough::new_with_state()
776 .with_name(ALGORITHM_NAME)
777 .with_classifier(Arc::clone(&inner)),
778 inner,
779 })
780 }
781
782 fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
784 ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
785 }
786
787 fn from_classifier(inner: Arc<dyn Classifier>, config: ClassifierRouteConfig) -> Result<Self> {
789 if config.message_hash_fallback && config.classify_trigger == ClassifyTrigger::EveryRequest
791 {
792 return Err(LibsyError::AlgorithmError {
793 message:
794 "message_hash_fallback requires classify_trigger = new_session or user_turn"
795 .to_string(),
796 });
797 }
798 let mut route = FallThrough::new_with_state().with_name(ALGORITHM_NAME);
800 if let Some(affinity) =
801 affinity_router(config.classify_trigger, config.message_hash_fallback).as_ref()
802 {
803 route = route
805 .with_processor(affinity.clone())
806 .with_classifier(affinity.clone());
807 }
808 let fallback = DefaultCategoryClassifier(config.default_target);
809 Ok(Self {
810 route: route
811 .with_classifier(inner.clone())
812 .with_classifier(Arc::new(fallback)),
813 inner,
814 })
815 }
816}
817
818#[async_trait]
819impl Classifier for LlmTaskClassifier {
820 async fn score(
821 &self,
822 state: &mut State,
823 request: &mut Request,
824 driver: &Driver,
825 ) -> Result<(Classification, Option<Response>)> {
826 self.inner.score(state, request, driver).await
827 }
828}
829
830#[async_trait]
831impl Algorithm for LlmTaskClassifier {
832 fn name(&self) -> &str {
833 "llm_task_classifier"
834 }
835
836 async fn route(
837 self: Arc<Self>,
838 driver: Driver,
839 request: Request,
840 ) -> Result<crate::RoutingOutcome> {
841 self.route.execute(driver, request).await
842 }
843}
844
845#[cfg(test)]
846mod tests {
847 use std::collections::HashMap;
848 use std::sync::Arc;
849
850 use parking_lot::Mutex;
851 use serde_json::Value;
852
853 use super::*;
854 use switchyard_protocol::{
855 ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, ModelId, ToolCall,
856 ToolResult, completion_text, text_request, text_response,
857 };
858
859 use crate::algorithms::util::llm_judge::Judge;
860 use crate::core::testing::{Serve, test_drive_with_models};
861 use switchyard_protocol::{LlmResponse, Response};
862
863 const TEST_THRESHOLD: f64 = 0.5;
864
865 type CapabilityJudge = StructuredJudge<TaskInput, SerdeDecoder<TaskClassifierVerdict>>;
866
867 fn llm_config(base_threshold: f64) -> LlmCapabilityConfig {
868 LlmCapabilityConfig {
869 base_threshold,
870 ..LlmCapabilityConfig::default()
871 }
872 }
873
874 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
875 TaskClassifierConfig {
876 judge: CapabilityJudgeConfig::Llm(llm_config(base_threshold)),
877 ..TaskClassifierConfig::default()
878 }
879 }
880
881 fn policy() -> TaskClassifierPolicy {
882 TaskClassifierPolicy::new(&llm_config(TEST_THRESHOLD))
883 }
884
885 fn runtime_models() -> HashMap<Category, Vec<ModelId>> {
886 [
887 (Category::Judge, vec![ModelId::from("judge")]),
888 (Category::Efficient, vec![ModelId::from("efficient")]),
889 (Category::Capable, vec![ModelId::from("capable")]),
890 (
891 Category::Any,
892 vec![ModelId::from("efficient"), ModelId::from("capable")],
893 ),
894 ]
895 .into()
896 }
897
898 fn policy_driver() -> Driver {
899 Driver::new("test", Arc::new(runtime_models().into())).0
900 }
901
902 fn verdict(
903 p_solve: f64,
904 capability_boundary: &str,
905 primary_rule: &str,
906 ) -> TaskClassifierVerdict {
907 TaskClassifierVerdict {
908 crux: "test crux".to_string(),
909 primary_rule: primary_rule.to_string(),
910 capability_boundary: capability_boundary.to_string(),
911 p_solve,
912 }
913 }
914
915 fn selected(
916 policy: &TaskClassifierPolicy,
917 verdict: Option<&TaskClassifierVerdict>,
918 ) -> Result<ModelId> {
919 policy
920 .to_classification(verdict, &policy_driver())?
921 .argmax(false)?
922 .map(|score| score.target)
923 .ok_or_else(|| LibsyError::AlgorithmError {
924 message: "policy abstained".to_string(),
925 })
926 }
927
928 #[derive(Default)]
931 struct Recorder {
932 calls: Mutex<Vec<String>>,
933 call_roles: Mutex<Vec<(String, bool)>>,
934 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
935 judge_system_prompts: Mutex<Vec<String>>,
936 }
937
938 impl Recorder {
939 fn calls(&self) -> Vec<String> {
940 self.calls.lock().clone()
941 }
942
943 fn call_roles(&self) -> Vec<(String, bool)> {
944 self.call_roles.lock().clone()
945 }
946
947 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
948 self.judge_max_output_tokens.lock().clone()
949 }
950
951 fn judge_system_prompts(&self) -> Vec<String> {
952 self.judge_system_prompts.lock().clone()
953 }
954
955 fn serve(self: &Arc<Self>) -> impl Serve {
956 let recorder = Arc::clone(self);
957 move |model: ModelId, request: Request| {
958 let recorder = Arc::clone(&recorder);
959 async move {
960 let model = model.to_string();
961 recorder.calls.lock().push(model.clone());
962 recorder
963 .call_roles
964 .lock()
965 .push((model.clone(), model != "judge"));
966 let completion = if model == "judge" {
967 recorder
968 .judge_max_output_tokens
969 .lock()
970 .push(request.llm_request.output.max_output_tokens);
971 recorder.judge_system_prompts.lock().extend(
972 request
973 .llm_request
974 .instructions
975 .first()
976 .and_then(|instruction| {
977 instruction.content.iter().find_map(|b| {
978 if let ContentBlock::Text { text } = b {
979 Some(text.clone())
980 } else {
981 None
982 }
983 })
984 }),
985 );
986 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
987 } else {
988 format!("answer from {model}")
989 };
990 Ok(Response {
991 llm_response: LlmResponse::Agg(text_response(None, completion)),
992 metadata: request.metadata,
993 upstream_headers: http::HeaderMap::new(),
994 })
995 }
996 }
997 }
998 }
999
1000 fn unreachable_judge() -> impl Serve {
1002 |model: ModelId, request: Request| async move {
1003 let model = model.to_string();
1004 if model == "judge" {
1005 return Err(LlmClientError::Timeout {
1006 source: Box::new(std::io::Error::other("judge unreachable")),
1007 });
1008 }
1009 Ok(Response {
1010 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1011 metadata: request.metadata,
1012 upstream_headers: http::HeaderMap::new(),
1013 })
1014 }
1015 }
1016
1017 fn router() -> Result<Arc<LlmTaskClassifier>> {
1018 Ok(Arc::new(LlmTaskClassifier::new(
1019 LlmClassifierConfig::Capability {
1020 config: test_config(TEST_THRESHOLD),
1021 },
1022 )?))
1023 }
1024
1025 fn classify_request() -> Request {
1026 Request {
1027 llm_request: text_request(Some("auto".to_string()), "classify this task"),
1028 raw_request: None,
1029 metadata: None,
1030 }
1031 }
1032
1033 fn classify_session_request() -> Request {
1034 Request {
1035 metadata: Some(Metadata {
1036 session_id: Some("session-1".to_string()),
1037 ..Metadata::default()
1038 }),
1039 ..classify_request()
1040 }
1041 }
1042
1043 fn classify_follow_up_request() -> Request {
1044 let mut request = classify_request();
1045 request
1046 .llm_request
1047 .messages
1048 .push(Message::text(Role::Assistant, "I will add the test."));
1049 request.llm_request.messages.push(Message::text(
1050 Role::User,
1051 "Now run the test suite and report the result.",
1052 ));
1053 request
1054 }
1055
1056 #[tokio::test]
1057 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1058 let router = router()?;
1059
1060 let (selected_model, response) = test_drive_with_models(
1061 router,
1062 classify_request(),
1063 runtime_models(),
1064 unreachable_judge(),
1065 )
1066 .await?;
1067
1068 assert_eq!(selected_model, "capable");
1069 assert_eq!(
1070 response.llm_response.as_agg().map(completion_text),
1071 Some("answer from capable".to_string())
1072 );
1073 Ok(())
1074 }
1075
1076 #[tokio::test]
1077 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1078 let recorder = Arc::new(Recorder::default());
1079 let router = router()?;
1080 let request = classify_request();
1081 let models = runtime_models();
1082
1083 test_drive_with_models(
1084 router.clone(),
1085 request.clone(),
1086 models.clone(),
1087 recorder.serve(),
1088 )
1089 .await?;
1090 test_drive_with_models(router, request, models, recorder.serve()).await?;
1091
1092 assert_eq!(
1093 recorder.calls(),
1094 vec!["judge", "efficient", "judge", "efficient"]
1095 );
1096 assert_eq!(
1097 recorder.call_roles(),
1098 vec![
1099 ("judge".to_string(), false),
1100 ("efficient".to_string(), true),
1101 ("judge".to_string(), false),
1102 ("efficient".to_string(), true),
1103 ]
1104 );
1105 Ok(())
1106 }
1107
1108 #[tokio::test]
1109 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1110 let recorder = Arc::new(Recorder::default());
1111 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1112 config: TaskClassifierConfig {
1113 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1114 max_output_tokens: 512,
1115 ..llm_config(TEST_THRESHOLD)
1116 }),
1117 ..test_config(TEST_THRESHOLD)
1118 },
1119 })?);
1120
1121 test_drive_with_models(
1122 router,
1123 classify_request(),
1124 runtime_models(),
1125 recorder.serve(),
1126 )
1127 .await?;
1128
1129 assert_eq!(recorder.judge_max_output_tokens(), vec![Some(512)]);
1130 Ok(())
1131 }
1132
1133 #[tokio::test]
1134 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1135 let recorder = Arc::new(Recorder::default());
1136 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1137 config: TaskClassifierConfig {
1138 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1139 contract: ClassifierContractConfig::default()
1140 .with_prompt("Custom capability rubric."),
1141 ..llm_config(TEST_THRESHOLD)
1142 }),
1143 ..test_config(TEST_THRESHOLD)
1144 },
1145 })?);
1146
1147 test_drive_with_models(
1148 router,
1149 classify_request(),
1150 runtime_models(),
1151 recorder.serve(),
1152 )
1153 .await?;
1154
1155 let prompts = recorder.judge_system_prompts();
1156 assert_eq!(prompts.len(), 1);
1157 assert_eq!(prompts[0], "Custom capability rubric.");
1158 Ok(())
1159 }
1160
1161 #[tokio::test]
1162 async fn classifier_config_enables_new_session_trigger() -> Result<()> {
1163 let recorder = Arc::new(Recorder::default());
1164 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1165 config: TaskClassifierConfig {
1166 classify_trigger: ClassifyTrigger::NewSession,
1167 ..test_config(TEST_THRESHOLD)
1168 },
1169 })?);
1170
1171 let request = classify_session_request();
1172 let models = runtime_models();
1173 test_drive_with_models(
1174 router.clone(),
1175 request.clone(),
1176 models.clone(),
1177 recorder.serve(),
1178 )
1179 .await?;
1180 test_drive_with_models(router, request, models, recorder.serve()).await?;
1181
1182 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1183 Ok(())
1184 }
1185
1186 #[tokio::test]
1187 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1188 let recorder = Arc::new(Recorder::default());
1189 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1190 config: TaskClassifierConfig {
1191 classify_trigger: ClassifyTrigger::NewSession,
1192 message_hash_fallback: true,
1193 recent_turn_window: None,
1194 ..test_config(TEST_THRESHOLD)
1195 },
1196 })?);
1197
1198 let models = runtime_models();
1199 test_drive_with_models(
1200 router.clone(),
1201 classify_request(),
1202 models.clone(),
1203 recorder.serve(),
1204 )
1205 .await?;
1206 test_drive_with_models(
1207 router,
1208 classify_follow_up_request(),
1209 models,
1210 recorder.serve(),
1211 )
1212 .await?;
1213
1214 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1215 Ok(())
1216 }
1217
1218 #[tokio::test]
1219 async fn one_classifier_uses_each_requests_runtime_models() -> Result<()> {
1220 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1221 config: TaskClassifierConfig {
1222 classify_trigger: ClassifyTrigger::NewSession,
1223 ..test_config(TEST_THRESHOLD)
1224 },
1225 })?);
1226 let calls = Arc::new(Mutex::new(Vec::new()));
1227 let serve = |calls: Arc<Mutex<Vec<String>>>| {
1228 move |model: ModelId, _request: Request| {
1229 let calls = Arc::clone(&calls);
1230 async move {
1231 calls.lock().push(model.to_string());
1232 let text = if model.as_str().starts_with("judge-") {
1233 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1234 } else {
1235 model.to_string()
1236 };
1237 Ok(Response {
1238 llm_response: LlmResponse::Agg(text_response(None, text)),
1239 metadata: None,
1240 upstream_headers: Default::default(),
1241 })
1242 }
1243 }
1244 };
1245 let models = |suffix: &str| -> HashMap<Category, Vec<ModelId>> {
1246 [
1247 (
1248 Category::Judge,
1249 vec![ModelId::from(format!("judge-{suffix}"))],
1250 ),
1251 (
1252 Category::Efficient,
1253 vec![ModelId::from(format!("efficient-{suffix}"))],
1254 ),
1255 (
1256 Category::Capable,
1257 vec![ModelId::from(format!("capable-{suffix}"))],
1258 ),
1259 (
1260 Category::Any,
1261 vec![
1262 ModelId::from(format!("efficient-{suffix}")),
1263 ModelId::from(format!("capable-{suffix}")),
1264 ],
1265 ),
1266 ]
1267 .into()
1268 };
1269 let request = classify_session_request();
1270
1271 let (first, _) = test_drive_with_models(
1272 router.clone(),
1273 request.clone(),
1274 models("a"),
1275 serve(Arc::clone(&calls)),
1276 )
1277 .await?;
1278 let (second, _) =
1279 test_drive_with_models(router, request, models("b"), serve(Arc::clone(&calls))).await?;
1280
1281 assert_eq!(first, "efficient-a");
1282 assert_eq!(second, "efficient-b");
1283 assert_eq!(
1284 &*calls.lock(),
1285 &["judge-a", "efficient-a", "judge-b", "efficient-b"]
1286 );
1287 Ok(())
1288 }
1289
1290 #[tokio::test]
1291 async fn decision_judge_routes_on_relative_advantage() -> Result<()> {
1292 use crate::{Call, RuntimeModels, drive};
1293 use serde_json::json;
1294 use std::collections::BTreeMap;
1295 use std::sync::atomic::{AtomicUsize, Ordering};
1296 use switchyard_protocol::{
1297 BooleanEstimate, DecisionAnswer, DecisionResponse, DecisionValue, Probability,
1298 ProviderConfidence,
1299 };
1300
1301 let settings = DecisionJudgeConfig {
1302 cutoff: 0.4,
1303 instructions: None,
1304 candidates: BTreeMap::from([
1305 ("a".into(), "capable".into()),
1306 ("b".into(), "efficient".into()),
1307 ("c".into(), "third".into()),
1308 ]),
1309 evidence: json!({
1310 "candidate_descriptions": {"a": "Profile A", "b": "Profile B", "c": "Profile C"},
1311 "reference_cases": [{"task": "Independent example", "observed_solved": {"a": true, "b": null, "c": false}}],
1312 "summaries": [],
1313 }),
1314 };
1315 let mut request = classify_session_request();
1316 request.llm_request.messages.push(Message {
1317 role: Role::Assistant,
1318 content: vec![
1319 ContentBlock::Reasoning {
1320 text: "private reasoning".into(),
1321 signature: None,
1322 details: vec![],
1323 },
1324 ContentBlock::Text {
1325 text: "visible progress".into(),
1326 },
1327 ],
1328 });
1329 request
1330 .llm_request
1331 .messages
1332 .push(Message::text(Role::User, "latest follow-up"));
1333 let original_messages = request.llm_request.messages.clone();
1334 let expected_task = json!([
1335 original_messages[0].clone(),
1336 Message::text(Role::Assistant, "visible progress"),
1337 Message::text(Role::User, "latest follow-up"),
1338 ]);
1339
1340 for (name, score, fail_open, expected) in [
1341 ("above", 0.45, true, Some("capable")),
1342 ("equal", 0.4, true, Some("efficient")),
1343 ("below", 0.39, false, Some("efficient")),
1344 ("missing answer", 0.0, true, Some("capable")),
1345 ("no distribution", 0.0, true, Some("capable")),
1346 ("wrong type", 0.0, true, Some("capable")),
1347 ("out of range", 1.1, true, Some("capable")),
1348 ("nonfinite", f64::NAN, false, Some("capable")),
1349 ("provider error", 0.0, true, Some("capable")),
1350 ("provider error", 0.0, false, None),
1351 ("dropped reply", 0.0, true, Some("capable")),
1352 ("missing candidate", 0.0, true, None),
1353 ] {
1354 let mut config = settings.clone();
1355 if name == "missing candidate" {
1356 config.candidates.remove("a");
1357 }
1358 if name == "below" {
1359 config.instructions = Some(
1360 json!({"policy": "Judge capable-only success using the supplied evidence."}),
1361 );
1362 }
1363 let override_instructions = config.instructions.clone();
1364 let router: Arc<dyn Algorithm> =
1365 Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1366 config: TaskClassifierConfig {
1367 judge: CapabilityJudgeConfig::Decision(config),
1368 fail_open,
1369 recent_turn_window: Some(3),
1370 classify_trigger: ClassifyTrigger::NewSession,
1371 ..TaskClassifierConfig::default()
1372 },
1373 })?);
1374 let calls = AtomicUsize::new(0);
1375 let serve = |call| {
1376 let calls = &calls;
1377 let expected_task = &expected_task;
1378 let settings = &settings;
1379 let override_instructions = &override_instructions;
1380 async move {
1381 let Call::Decision(call) = call else {
1382 panic!("judge must use the decision step")
1383 };
1384 calls.fetch_add(1, Ordering::SeqCst);
1385 assert_eq!(call.model, "judge");
1386 assert_eq!(call.request.model, Some("judge".into()));
1387 assert_eq!(
1388 call.request.context,
1389 json!({
1390 "task": expected_task,
1391 "candidates": ["a", "b", "c"],
1392 "comparison": {"capable": "a", "efficient": "b"},
1393 "evidence": settings.evidence,
1394 })
1395 );
1396 assert_eq!(call.request.questions.len(), 1);
1397 let question = &call.request.questions["route"];
1398 let switchyard_protocol::DecisionKind::Choice { options } = &question.kind
1399 else {
1400 panic!("expected a choice")
1401 };
1402 assert_eq!(
1403 options
1404 .iter()
1405 .map(|option| option.id.as_str())
1406 .collect::<Vec<_>>(),
1407 ["advantage", "no_advantage"]
1408 );
1409 if let Some(instructions) = override_instructions {
1410 assert_eq!(&question.instructions, instructions);
1411 } else {
1412 for key in ["setting", "evidence", "comparison", "boundary", "policy"] {
1413 assert!(question.instructions[key].as_str().is_some());
1414 }
1415 }
1416 if name == "provider error" {
1417 return call
1418 .respond(std::future::ready(Err(LibsyError::client_call(
1419 "judge",
1420 LlmClientError::General("private provider body".into()),
1421 ))))
1422 .await;
1423 }
1424 if name == "dropped reply" {
1425 drop(call);
1426 return Ok(());
1427 }
1428 let value = if name == "wrong type" {
1429 DecisionValue::Boolean(BooleanEstimate::Value(true))
1430 } else {
1431 DecisionValue::Choice {
1432 selected: "no_advantage".into(),
1433 probabilities: (name != "no distribution").then(|| {
1434 BTreeMap::from([
1435 ("advantage".into(), Probability(score)),
1436 ("no_advantage".into(), Probability(1.0 - score)),
1437 ])
1438 }),
1439 }
1440 };
1441 let answers = if name == "missing answer" {
1442 BTreeMap::new()
1443 } else {
1444 BTreeMap::from([(
1445 "route".into(),
1446 DecisionAnswer {
1447 value,
1448 provider_confidence: Some(ProviderConfidence(0.99)),
1449 },
1450 )])
1451 };
1452 call.respond(std::future::ready(Ok(DecisionResponse {
1453 id: None,
1454 model: Some("provider-judge".into()),
1455 answers,
1456 usage: Default::default(),
1457 })))
1458 .await
1459 }
1460 };
1461 let models = Arc::new(RuntimeModels::new(runtime_models()));
1462 let result = drive(router.clone(), request.clone(), models.clone(), &serve).await;
1463 assert_eq!(
1464 calls.load(Ordering::SeqCst),
1465 usize::from(name != "missing candidate"),
1466 "{name}"
1467 );
1468 let Some(expected) = expected else {
1469 if name == "missing candidate" {
1470 assert!(
1471 matches!(result, Err(LibsyError::AlgorithmError { message }) if message.contains("candidate is missing"))
1472 );
1473 } else {
1474 assert!(
1475 matches!(result, Err(LibsyError::ClientCall { .. })),
1476 "{name}"
1477 );
1478 }
1479 continue;
1480 };
1481 let outcome = result?;
1482 assert_eq!(outcome.selected_model_id()?, expected, "{name}");
1483 assert!(outcome.response.is_none());
1484 assert_eq!(outcome.request.llm_request.messages, original_messages);
1485 let evidence = outcome
1486 .metadata
1487 .and_then(|metadata| metadata.evidence)
1488 .expect("routing evidence");
1489 if matches!(name, "above" | "equal" | "below") {
1490 assert_eq!(evidence["source"], "decision_classifier");
1491 assert_eq!(evidence["verdict"], "relative_advantage");
1492 assert_eq!(evidence["threshold"], settings.cutoff);
1493 assert_eq!(evidence["score"], score);
1494 } else {
1495 let reason = match name {
1496 "provider error" => "client_error",
1497 "dropped reply" => "call_error",
1498 _ => "invalid_verdict",
1499 };
1500 assert_eq!(
1501 evidence,
1502 json!({"source": "fail_open", "reason_code": reason})
1503 );
1504 }
1505 let retained = drive(router, request.clone(), models, &serve).await?;
1506 assert_eq!(retained.selected_model_id()?, expected);
1507 assert_eq!(
1508 calls.load(Ordering::SeqCst),
1509 1,
1510 "affinity should skip the judge: {name}"
1511 );
1512 }
1513 for cutoff in [-0.1, 1.1, f64::NAN] {
1514 let mut config = settings.clone();
1515 config.cutoff = cutoff;
1516 assert!(
1517 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1518 config: TaskClassifierConfig {
1519 judge: CapabilityJudgeConfig::Decision(config),
1520 ..TaskClassifierConfig::default()
1521 },
1522 })
1523 .is_err()
1524 );
1525 }
1526 let mut duplicate = settings.clone();
1527 duplicate
1528 .candidates
1529 .insert("duplicate-a".into(), "capable".into());
1530 assert!(duplicate.validate().is_err());
1531 Ok(())
1532 }
1533
1534 #[test]
1535 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1536 let policy = policy();
1537 let at_threshold = verdict(0.5, "supported", "SUP-1");
1538 let below_threshold = verdict(0.49, "supported", "SUP-1");
1539 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1540 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1541 Ok(())
1542 }
1543
1544 #[test]
1545 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1546 let borderline = verdict(0.5, "supported", "SUP-1");
1547 let strict = TaskClassifierPolicy::new(&llm_config(0.9));
1548 let lenient = TaskClassifierPolicy::new(&llm_config(0.1));
1549 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1550 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1551 Ok(())
1552 }
1553
1554 #[test]
1555 fn classifier_config_rejects_unknown_fields() {
1556 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1557 "base_threshold": 0.5,
1558 "classifier_magic": true,
1559 }))
1560 .expect_err("unknown classifier fields must be rejected");
1561
1562 assert!(
1563 error
1564 .to_string()
1565 .contains("unknown field `classifier_magic`"),
1566 "{error}"
1567 );
1568 }
1569
1570 #[test]
1571 fn invalid_classifier_config_is_rejected() -> Result<()> {
1572 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1573 assert!(
1574 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1575 config: test_config(bad),
1576 })
1577 .is_err(),
1578 "base threshold {bad} should be rejected"
1579 );
1580 }
1581 for config in [
1582 TaskClassifierConfig {
1583 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1584 base_threshold: 0.5,
1585 threshold_step: -0.1,
1586 ..LlmCapabilityConfig::default()
1587 }),
1588 ..TaskClassifierConfig::default()
1589 },
1590 TaskClassifierConfig {
1591 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1592 base_threshold: 0.8,
1593 threshold_step: 0.11,
1594 ..LlmCapabilityConfig::default()
1595 }),
1596 ..TaskClassifierConfig::default()
1597 },
1598 TaskClassifierConfig {
1599 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1600 base_threshold: 0.5,
1601 ..LlmCapabilityConfig::default()
1602 }),
1603 message_hash_fallback: true,
1604 ..TaskClassifierConfig::default()
1605 },
1606 TaskClassifierConfig {
1607 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1608 base_threshold: 0.5,
1609 max_output_tokens: 0,
1610 ..LlmCapabilityConfig::default()
1611 }),
1612 ..TaskClassifierConfig::default()
1613 },
1614 ] {
1615 assert!(LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).is_err());
1616 }
1617 for base_threshold in [0.0, 1.0] {
1618 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1619 config: test_config(base_threshold),
1620 })?;
1621 }
1622 Ok(())
1623 }
1624
1625 #[test]
1626 fn message_hash_fallback_accepts_retaining_triggers() -> Result<()> {
1627 for trigger in [ClassifyTrigger::NewSession, ClassifyTrigger::UserTurn] {
1631 let config = TaskClassifierConfig {
1632 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1633 base_threshold: 0.5,
1634 ..LlmCapabilityConfig::default()
1635 }),
1636 classify_trigger: trigger,
1637 message_hash_fallback: true,
1638 ..TaskClassifierConfig::default()
1639 };
1640 LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).map_err(
1641 |error| LibsyError::AlgorithmError {
1642 message: format!("{trigger:?} with message_hash_fallback rejected: {error}"),
1643 },
1644 )?;
1645 }
1646 let every_request = TaskClassifierConfig {
1647 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1648 base_threshold: 0.5,
1649 ..LlmCapabilityConfig::default()
1650 }),
1651 classify_trigger: ClassifyTrigger::EveryRequest,
1652 message_hash_fallback: true,
1653 ..TaskClassifierConfig::default()
1654 };
1655 assert!(
1656 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1657 config: every_request
1658 })
1659 .is_err(),
1660 "every_request with message_hash_fallback should stay rejected"
1661 );
1662 Ok(())
1663 }
1664
1665 #[test]
1666 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1667 let policy = policy();
1668 let inconsistent_rule = TaskClassifierVerdict {
1669 capability_boundary: "uncertain".to_string(),
1670 ..verdict(1.0, "supported", "SUP-1")
1671 };
1672 let empty_crux = TaskClassifierVerdict {
1673 crux: " ".to_string(),
1674 ..verdict(1.0, "supported", "SUP-1")
1675 };
1676 let unusable = [
1677 Some(verdict(1.1, "supported", "SUP-1")),
1678 Some(inconsistent_rule),
1679 Some(empty_crux),
1680 None,
1681 ];
1682 for verdict in unusable {
1683 let classification = policy.to_classification(verdict.as_ref(), &policy_driver())?;
1684 assert!(matches!(classification, Classification::Ambiguous(_)));
1685 assert!(classification.argmax(false)?.is_none());
1686 assert!(classification.argmax(true)?.is_none());
1687 }
1688 Ok(())
1689 }
1690
1691 #[test]
1692 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1693 let policy = TaskClassifierPolicy::new(&LlmCapabilityConfig {
1694 threshold_step: 0.1,
1695 ..llm_config(0.4)
1696 });
1697
1698 assert_eq!(
1699 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1700 "efficient"
1701 );
1702 assert_eq!(
1703 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1704 "capable"
1705 );
1706 assert_eq!(
1707 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1708 "efficient"
1709 );
1710 assert_eq!(
1711 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1712 "efficient"
1713 );
1714 assert_eq!(
1715 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1716 "capable"
1717 );
1718 assert_eq!(
1719 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1720 "efficient"
1721 );
1722 Ok(())
1723 }
1724
1725 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1728 Ok(StructuredJudge::new(
1729 TaskInput { recent_turn_window },
1730 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1731 SerdeDecoder::new(),
1732 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1733 ))
1734 }
1735
1736 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1737 let judge = capability_judge(Some(recent_turn_window))?;
1738 let request = Request {
1739 llm_request: LlmRequest {
1740 messages: vec![
1741 Message::text(Role::System, "client instructions"),
1742 Message::text(Role::User, "initial task"),
1743 Message::text(Role::Assistant, "old response"),
1744 Message::text(Role::User, "old follow-up"),
1745 Message::text(Role::Assistant, "recent 1"),
1746 Message::text(Role::User, "recent 2"),
1747 ],
1748 ..LlmRequest::default()
1749 },
1750 raw_request: None,
1751 metadata: None,
1752 };
1753 Ok(judge
1754 .build_request(&State::default(), &request)
1755 .llm_request
1756 .messages
1757 .iter()
1758 .filter_map(|message| message.text_content("\n"))
1759 .collect())
1760 }
1761
1762 #[test]
1763 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1764 let contents = judged_contents(2)?;
1766 assert!(contents.contains(&"client instructions".to_string()));
1767 assert!(contents.contains(&"initial task".to_string()));
1768 assert!(contents.contains(&"recent 1".to_string()));
1769 assert!(contents.contains(&"recent 2".to_string()));
1770 assert!(!contents.contains(&"old response".to_string()));
1771 Ok(())
1772 }
1773
1774 #[test]
1775 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1776 let contents = judged_contents(0)?;
1777 assert!(contents.contains(&"client instructions".to_string()));
1778 assert!(contents.contains(&"initial task".to_string()));
1779 assert!(!contents.contains(&"recent 2".to_string()));
1780 Ok(())
1781 }
1782
1783 fn tool_call(id: &str) -> Message {
1784 Message {
1785 role: Role::Assistant,
1786 content: vec![ContentBlock::ToolCall(ToolCall {
1787 id: id.to_string(),
1788 name: "search".to_string(),
1789 arguments: Value::Null,
1790 })],
1791 }
1792 }
1793
1794 fn tool_result(id: &str) -> Message {
1795 Message {
1796 role: Role::Tool,
1797 content: vec![ContentBlock::ToolResult(ToolResult {
1798 tool_call_id: id.to_string(),
1799 content: vec![ContentBlock::Text {
1800 text: "tool output".to_string(),
1801 }],
1802 is_error: None,
1803 })],
1804 }
1805 }
1806
1807 #[test]
1808 fn default_task_input_keeps_user_content_around_tool_results() {
1809 let mut result = tool_result("call-1");
1810 result.role = Role::User;
1811 let mut mixed = result.clone();
1812 mixed.content.push(ContentBlock::Text {
1813 text: "latest follow-up".to_string(),
1814 });
1815 let input = TaskInput {
1816 recent_turn_window: None,
1817 };
1818 let mut request = Request {
1819 llm_request: LlmRequest {
1820 messages: vec![
1821 result.clone(),
1822 Message::text(Role::User, "initial task"),
1823 tool_call("call-1"),
1824 mixed,
1825 result.clone(),
1826 ],
1827 ..LlmRequest::default()
1828 },
1829 ..Request::default()
1830 };
1831 assert_eq!(
1832 input.build_messages(&State::default(), &request),
1833 vec![
1834 Message::text(Role::User, "initial task"),
1835 Message::text(Role::User, "latest follow-up"),
1836 ]
1837 );
1838 request.llm_request.messages = vec![result];
1839 assert!(input.build_messages(&State::default(), &request).is_empty());
1840 }
1841
1842 #[test]
1845 fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1846 let messages = vec![
1847 Message::text(Role::System, "client instructions"),
1848 Message::text(Role::User, "initial task"),
1849 Message::text(Role::Assistant, "old response"),
1850 tool_call("call-1"),
1851 tool_result("call-1"),
1852 Message::text(Role::Assistant, "recent 1"),
1853 Message::text(Role::User, "recent 2"),
1854 Message::text(Role::Assistant, "recent 3"),
1855 Message::text(Role::User, "recent 4"),
1856 ];
1857
1858 let kept = trim_messages(&messages, 5);
1860
1861 assert_eq!(
1862 kept,
1863 vec![
1864 Message::text(Role::System, "client instructions"),
1865 Message::text(Role::User, "initial task"),
1866 tool_call("call-1"),
1867 tool_result("call-1"),
1868 Message::text(Role::Assistant, "recent 1"),
1869 Message::text(Role::User, "recent 2"),
1870 Message::text(Role::Assistant, "recent 3"),
1871 Message::text(Role::User, "recent 4"),
1872 ]
1873 );
1874 }
1875
1876 #[test]
1879 fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1880 let messages = vec![
1881 Message::text(Role::System, "client instructions"),
1882 Message::text(Role::User, "initial task"),
1883 tool_call("x"),
1884 tool_result("x"),
1885 Message::text(Role::Assistant, "later"),
1886 tool_call("x"),
1887 tool_result("x"),
1888 ];
1889
1890 let kept = trim_messages(&messages, 4);
1892
1893 assert_eq!(
1894 kept,
1895 vec![
1896 Message::text(Role::System, "client instructions"),
1897 Message::text(Role::User, "initial task"),
1898 tool_call("x"),
1899 tool_result("x"),
1900 Message::text(Role::Assistant, "later"),
1901 tool_call("x"),
1902 tool_result("x"),
1903 ]
1904 );
1905 }
1906
1907 #[test]
1910 fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1911 let messages = vec![
1912 Message::text(Role::System, "client instructions"),
1913 tool_call("orphan"),
1914 Message::text(Role::User, "initial task"),
1915 Message::text(Role::Assistant, "old response"),
1916 tool_result("orphan"),
1917 Message::text(Role::Assistant, "recent 1"),
1918 Message::text(Role::User, "recent 2"),
1919 ];
1920
1921 let kept = trim_messages(&messages, 3);
1922
1923 assert_eq!(
1924 kept,
1925 vec![
1926 Message::text(Role::System, "client instructions"),
1927 Message::text(Role::User, "initial task"),
1928 tool_result("orphan"),
1929 Message::text(Role::Assistant, "recent 1"),
1930 Message::text(Role::User, "recent 2"),
1931 ]
1932 );
1933 }
1934
1935 #[test]
1936 fn a_window_restates_the_routing_instruction_last() -> Result<()> {
1937 let contents = judged_contents(2)?;
1938
1939 assert_eq!(
1942 contents.last().map(String::as_str),
1943 Some(TRAILING_ROUTING_INSTRUCTION)
1944 );
1945 Ok(())
1946 }
1947
1948 #[test]
1953 fn a_window_drops_reasoning_but_keeps_visible_text_and_tool_pairs() {
1954 let messages = vec![
1955 Message::text(Role::System, "client instructions"),
1956 Message::text(Role::User, "initial task"),
1957 Message {
1958 role: Role::Assistant,
1959 content: vec![
1960 ContentBlock::Reasoning {
1961 text: "private chain of thought".to_string(),
1962 signature: None,
1963 details: Vec::new(),
1964 },
1965 ContentBlock::Text {
1966 text: "visible answer".to_string(),
1967 },
1968 ],
1969 },
1970 tool_call("call-1"),
1971 tool_result("call-1"),
1972 Message {
1973 role: Role::Assistant,
1974 content: vec![ContentBlock::Reasoning {
1975 text: "reasoning-only turn".to_string(),
1976 signature: None,
1977 details: Vec::new(),
1978 }],
1979 },
1980 Message::text(Role::User, "follow-up"),
1981 ];
1982 let request = Request {
1983 llm_request: LlmRequest {
1984 messages,
1985 ..LlmRequest::default()
1986 },
1987 raw_request: None,
1988 metadata: None,
1989 };
1990
1991 let built = TaskInput {
1992 recent_turn_window: Some(10),
1993 }
1994 .build_messages(&State::default(), &request);
1995
1996 assert!(
1997 !built
1998 .iter()
1999 .flat_map(|message| &message.content)
2000 .any(|block| matches!(block, ContentBlock::Reasoning { .. })),
2001 "{built:?}"
2002 );
2003 assert!(
2004 built
2005 .iter()
2006 .any(|message| message.text_content("\n").as_deref() == Some("visible answer"))
2007 );
2008 assert!(built.contains(&tool_call("call-1")));
2009 assert!(built.contains(&tool_result("call-1")));
2010 assert_eq!(built.len(), 7);
2013 assert!(built.iter().all(|message| !message.content.is_empty()));
2014 }
2015
2016 #[test]
2017 fn the_default_path_is_left_unchanged() -> Result<()> {
2018 let judge = capability_judge(None)?;
2021 let request = Request {
2022 llm_request: LlmRequest {
2023 messages: vec![Message::text(Role::User, "the task")],
2024 ..LlmRequest::default()
2025 },
2026 raw_request: None,
2027 metadata: None,
2028 };
2029
2030 let built = judge.build_request(&State::default(), &request);
2031
2032 assert_eq!(built.llm_request.messages.len(), 1);
2035 assert!(!built.llm_request.instructions.is_empty());
2036 assert!(
2037 !built
2038 .llm_request
2039 .messages
2040 .iter()
2041 .filter_map(|message| message.text_content("\n"))
2042 .any(|text| text.contains(TRAILING_ROUTING_INSTRUCTION))
2043 );
2044 Ok(())
2045 }
2046
2047 #[test]
2048 fn capability_judge_builds_a_structured_request() -> Result<()> {
2049 let judge = capability_judge(None)?;
2050 let request = Request {
2051 llm_request: LlmRequest {
2052 model: Some("inbound".to_string()),
2053 messages: vec![
2054 Message::text(Role::System, "client instructions"),
2055 Message::text(Role::Developer, "client developer instructions"),
2056 Message::text(Role::User, "initial task"),
2057 Message::text(Role::Assistant, "old response"),
2058 Message::text(Role::User, "old follow-up"),
2059 Message::text(Role::Assistant, "recent 1"),
2060 Message::text(Role::User, "recent 2"),
2061 Message::text(Role::Assistant, "recent 3"),
2062 Message::text(Role::User, "recent 4"),
2063 Message::text(Role::Assistant, "recent 5"),
2064 ],
2065 ..LlmRequest::default()
2066 },
2067 raw_request: None,
2068 metadata: None,
2069 };
2070 let judge_request = judge.build_request(&State::default(), &request);
2071
2072 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
2073 assert_eq!(judge_request.llm_request.instructions.len(), 1);
2074 assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
2075 assert_eq!(
2076 judge_request.llm_request.instructions[0].content,
2077 InstructionBlock {
2078 role: Role::System,
2079 content: Message::text(Role::System, judge.contract().system_prompt()).content,
2080 }
2081 .content,
2082 );
2083 assert_eq!(judge_request.llm_request.messages.len(), 2);
2084 let contents = judge_request
2085 .llm_request
2086 .messages
2087 .iter()
2088 .filter_map(|message| message.text_content("\n"))
2089 .collect::<Vec<_>>();
2090 assert!(contents.contains(&"recent 4".to_string()));
2091 assert!(contents.contains(&"initial task".to_string()));
2092 assert!(!contents.contains(&"recent 5".to_string()));
2093 assert!(!contents.contains(&"client instructions".to_string()));
2094 assert_eq!(
2095 judge_request.llm_request.output.response_format,
2096 Some(judge.contract().response_format().clone())
2097 );
2098 assert_eq!(
2099 judge_request.llm_request.output.max_output_tokens,
2100 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
2101 );
2102 Ok(())
2103 }
2104
2105 fn sample_value(spec: &Value) -> Value {
2106 if let Some(first) = spec
2107 .get("enum")
2108 .and_then(Value::as_array)
2109 .and_then(|values| values.first())
2110 {
2111 return first.clone();
2112 }
2113 match spec.get("type").and_then(Value::as_str) {
2114 Some("number") => serde_json::json!(0.5),
2115 Some("boolean") => serde_json::json!(false),
2116 _ => serde_json::json!("sample"),
2117 }
2118 }
2119
2120 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
2121 let properties = schema
2122 .pointer("/json_schema/schema/properties")
2123 .and_then(Value::as_object)
2124 .ok_or_else(|| LibsyError::AlgorithmError {
2125 message: "packaged schema declares no properties".to_string(),
2126 })?;
2127 Ok(Value::Object(
2128 properties
2129 .iter()
2130 .map(|(name, spec)| (name.clone(), sample_value(spec)))
2131 .collect(),
2132 )
2133 .to_string())
2134 }
2135
2136 #[test]
2139 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
2140 let contract =
2141 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
2142 let schema = contract.response_format();
2143 let reply = schema_shaped_verdict(schema)?;
2144 let judge: CapabilityJudge = StructuredJudge::new(
2145 TaskInput {
2146 recent_turn_window: None,
2147 },
2148 contract,
2149 SerdeDecoder::new(),
2150 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
2151 );
2152
2153 let verdict = judge.parse(&text_response(None, reply))?;
2154
2155 assert!(verdict.is_valid());
2156 assert!((0.0..=1.0).contains(&verdict.p_solve));
2157 Ok(())
2158 }
2159
2160 #[test]
2161 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
2162 let contract =
2163 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
2164 let prompt = contract.system_prompt();
2165 let schema_name = contract
2166 .response_format()
2167 .pointer("/json_schema/name")
2168 .and_then(Value::as_str)
2169 .ok_or_else(|| LibsyError::AlgorithmError {
2170 message: "packaged response schema has no name".to_string(),
2171 })?;
2172 assert_eq!(schema_name, "CapabilityClassifierDecision");
2173 assert!(prompt.contains("SUP-1 [supported]"));
2174 assert!(prompt.contains("SUP-5 [supported]"));
2175 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
2176 assert!(!prompt.contains("\"type\": \"object\""));
2177 assert!(!prompt.contains("\"json_schema\""));
2178 assert!(!prompt.contains(schema_name));
2179 let rule_values = contract
2180 .response_format()
2181 .pointer("/json_schema/schema/properties/primary_rule/enum")
2182 .and_then(Value::as_array)
2183 .ok_or_else(|| LibsyError::AlgorithmError {
2184 message: "rendered response schema has no primary rule enum".to_string(),
2185 })?;
2186 assert!(
2187 rule_values
2188 .iter()
2189 .any(|value| value.as_str() == Some("SUP-1"))
2190 );
2191 assert!(
2192 rule_values
2193 .iter()
2194 .any(|value| value.as_str() == Some("none"))
2195 );
2196 Ok(())
2197 }
2198}