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 "session": "session-1",
1392 "candidates": ["a", "b", "c"],
1393 "comparison": {"capable": "a", "efficient": "b"},
1394 "evidence": settings.evidence,
1395 })
1396 );
1397 assert_eq!(call.request.questions.len(), 1);
1398 let question = &call.request.questions["route"];
1399 let switchyard_protocol::DecisionKind::Choice { options } = &question.kind
1400 else {
1401 panic!("expected a choice")
1402 };
1403 assert_eq!(
1404 options
1405 .iter()
1406 .map(|option| option.id.as_str())
1407 .collect::<Vec<_>>(),
1408 ["advantage", "no_advantage"]
1409 );
1410 if let Some(instructions) = override_instructions {
1411 assert_eq!(&question.instructions, instructions);
1412 } else {
1413 for key in ["setting", "evidence", "comparison", "boundary", "policy"] {
1414 assert!(question.instructions[key].as_str().is_some());
1415 }
1416 }
1417 if name == "provider error" {
1418 return call
1419 .respond(std::future::ready(Err(LibsyError::client_call(
1420 "judge",
1421 LlmClientError::General("private provider body".into()),
1422 ))))
1423 .await;
1424 }
1425 if name == "dropped reply" {
1426 drop(call);
1427 return Ok(());
1428 }
1429 let value = if name == "wrong type" {
1430 DecisionValue::Boolean(BooleanEstimate::Value(true))
1431 } else {
1432 DecisionValue::Choice {
1433 selected: "no_advantage".into(),
1434 probabilities: (name != "no distribution").then(|| {
1435 BTreeMap::from([
1436 ("advantage".into(), Probability(score)),
1437 ("no_advantage".into(), Probability(1.0 - score)),
1438 ])
1439 }),
1440 }
1441 };
1442 let answers = if name == "missing answer" {
1443 BTreeMap::new()
1444 } else {
1445 BTreeMap::from([(
1446 "route".into(),
1447 DecisionAnswer {
1448 value,
1449 provider_confidence: Some(ProviderConfidence(0.99)),
1450 },
1451 )])
1452 };
1453 call.respond(std::future::ready(Ok(DecisionResponse {
1454 id: None,
1455 model: Some("provider-judge".into()),
1456 answers,
1457 usage: Default::default(),
1458 })))
1459 .await
1460 }
1461 };
1462 let models = Arc::new(RuntimeModels::new(runtime_models()));
1463 let result = drive(router.clone(), request.clone(), models.clone(), &serve).await;
1464 assert_eq!(
1465 calls.load(Ordering::SeqCst),
1466 usize::from(name != "missing candidate"),
1467 "{name}"
1468 );
1469 let Some(expected) = expected else {
1470 if name == "missing candidate" {
1471 assert!(
1472 matches!(result, Err(LibsyError::AlgorithmError { message }) if message.contains("candidate is missing"))
1473 );
1474 } else {
1475 assert!(
1476 matches!(result, Err(LibsyError::ClientCall { .. })),
1477 "{name}"
1478 );
1479 }
1480 continue;
1481 };
1482 let outcome = result?;
1483 assert_eq!(outcome.selected_model_id()?, expected, "{name}");
1484 assert!(outcome.response.is_none());
1485 assert_eq!(outcome.request.llm_request.messages, original_messages);
1486 let evidence = outcome
1487 .metadata
1488 .and_then(|metadata| metadata.evidence)
1489 .expect("routing evidence");
1490 if matches!(name, "above" | "equal" | "below") {
1491 assert_eq!(evidence["source"], "decision_classifier");
1492 assert_eq!(evidence["verdict"], "relative_advantage");
1493 assert_eq!(evidence["threshold"], settings.cutoff);
1494 assert_eq!(evidence["score"], score);
1495 } else {
1496 let reason = match name {
1497 "provider error" => "client_error",
1498 "dropped reply" => "call_error",
1499 _ => "invalid_verdict",
1500 };
1501 assert_eq!(
1502 evidence,
1503 json!({"source": "fail_open", "reason_code": reason})
1504 );
1505 }
1506 let retained = drive(router, request.clone(), models, &serve).await?;
1507 assert_eq!(retained.selected_model_id()?, expected);
1508 assert_eq!(
1509 calls.load(Ordering::SeqCst),
1510 1,
1511 "affinity should skip the judge: {name}"
1512 );
1513 }
1514 for cutoff in [-0.1, 1.1, f64::NAN] {
1515 let mut config = settings.clone();
1516 config.cutoff = cutoff;
1517 assert!(
1518 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1519 config: TaskClassifierConfig {
1520 judge: CapabilityJudgeConfig::Decision(config),
1521 ..TaskClassifierConfig::default()
1522 },
1523 })
1524 .is_err()
1525 );
1526 }
1527 let mut duplicate = settings.clone();
1528 duplicate
1529 .candidates
1530 .insert("duplicate-a".into(), "capable".into());
1531 assert!(duplicate.validate().is_err());
1532 Ok(())
1533 }
1534
1535 #[test]
1536 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1537 let policy = policy();
1538 let at_threshold = verdict(0.5, "supported", "SUP-1");
1539 let below_threshold = verdict(0.49, "supported", "SUP-1");
1540 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1541 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1542 Ok(())
1543 }
1544
1545 #[test]
1546 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1547 let borderline = verdict(0.5, "supported", "SUP-1");
1548 let strict = TaskClassifierPolicy::new(&llm_config(0.9));
1549 let lenient = TaskClassifierPolicy::new(&llm_config(0.1));
1550 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1551 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1552 Ok(())
1553 }
1554
1555 #[test]
1556 fn classifier_config_rejects_unknown_fields() {
1557 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1558 "base_threshold": 0.5,
1559 "classifier_magic": true,
1560 }))
1561 .expect_err("unknown classifier fields must be rejected");
1562
1563 assert!(
1564 error
1565 .to_string()
1566 .contains("unknown field `classifier_magic`"),
1567 "{error}"
1568 );
1569 }
1570
1571 #[test]
1572 fn invalid_classifier_config_is_rejected() -> Result<()> {
1573 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1574 assert!(
1575 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1576 config: test_config(bad),
1577 })
1578 .is_err(),
1579 "base threshold {bad} should be rejected"
1580 );
1581 }
1582 for config in [
1583 TaskClassifierConfig {
1584 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1585 base_threshold: 0.5,
1586 threshold_step: -0.1,
1587 ..LlmCapabilityConfig::default()
1588 }),
1589 ..TaskClassifierConfig::default()
1590 },
1591 TaskClassifierConfig {
1592 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1593 base_threshold: 0.8,
1594 threshold_step: 0.11,
1595 ..LlmCapabilityConfig::default()
1596 }),
1597 ..TaskClassifierConfig::default()
1598 },
1599 TaskClassifierConfig {
1600 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1601 base_threshold: 0.5,
1602 ..LlmCapabilityConfig::default()
1603 }),
1604 message_hash_fallback: true,
1605 ..TaskClassifierConfig::default()
1606 },
1607 TaskClassifierConfig {
1608 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1609 base_threshold: 0.5,
1610 max_output_tokens: 0,
1611 ..LlmCapabilityConfig::default()
1612 }),
1613 ..TaskClassifierConfig::default()
1614 },
1615 ] {
1616 assert!(LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).is_err());
1617 }
1618 for base_threshold in [0.0, 1.0] {
1619 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1620 config: test_config(base_threshold),
1621 })?;
1622 }
1623 Ok(())
1624 }
1625
1626 #[test]
1627 fn message_hash_fallback_accepts_retaining_triggers() -> Result<()> {
1628 for trigger in [ClassifyTrigger::NewSession, ClassifyTrigger::UserTurn] {
1632 let config = TaskClassifierConfig {
1633 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1634 base_threshold: 0.5,
1635 ..LlmCapabilityConfig::default()
1636 }),
1637 classify_trigger: trigger,
1638 message_hash_fallback: true,
1639 ..TaskClassifierConfig::default()
1640 };
1641 LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).map_err(
1642 |error| LibsyError::AlgorithmError {
1643 message: format!("{trigger:?} with message_hash_fallback rejected: {error}"),
1644 },
1645 )?;
1646 }
1647 let every_request = TaskClassifierConfig {
1648 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1649 base_threshold: 0.5,
1650 ..LlmCapabilityConfig::default()
1651 }),
1652 classify_trigger: ClassifyTrigger::EveryRequest,
1653 message_hash_fallback: true,
1654 ..TaskClassifierConfig::default()
1655 };
1656 assert!(
1657 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1658 config: every_request
1659 })
1660 .is_err(),
1661 "every_request with message_hash_fallback should stay rejected"
1662 );
1663 Ok(())
1664 }
1665
1666 #[test]
1667 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1668 let policy = policy();
1669 let inconsistent_rule = TaskClassifierVerdict {
1670 capability_boundary: "uncertain".to_string(),
1671 ..verdict(1.0, "supported", "SUP-1")
1672 };
1673 let empty_crux = TaskClassifierVerdict {
1674 crux: " ".to_string(),
1675 ..verdict(1.0, "supported", "SUP-1")
1676 };
1677 let unusable = [
1678 Some(verdict(1.1, "supported", "SUP-1")),
1679 Some(inconsistent_rule),
1680 Some(empty_crux),
1681 None,
1682 ];
1683 for verdict in unusable {
1684 let classification = policy.to_classification(verdict.as_ref(), &policy_driver())?;
1685 assert!(matches!(classification, Classification::Ambiguous(_)));
1686 assert!(classification.argmax(false)?.is_none());
1687 assert!(classification.argmax(true)?.is_none());
1688 }
1689 Ok(())
1690 }
1691
1692 #[test]
1693 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1694 let policy = TaskClassifierPolicy::new(&LlmCapabilityConfig {
1695 threshold_step: 0.1,
1696 ..llm_config(0.4)
1697 });
1698
1699 assert_eq!(
1700 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1701 "efficient"
1702 );
1703 assert_eq!(
1704 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1705 "capable"
1706 );
1707 assert_eq!(
1708 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1709 "efficient"
1710 );
1711 assert_eq!(
1712 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1713 "efficient"
1714 );
1715 assert_eq!(
1716 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1717 "capable"
1718 );
1719 assert_eq!(
1720 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1721 "efficient"
1722 );
1723 Ok(())
1724 }
1725
1726 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1729 Ok(StructuredJudge::new(
1730 TaskInput { recent_turn_window },
1731 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1732 SerdeDecoder::new(),
1733 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1734 ))
1735 }
1736
1737 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1738 let judge = capability_judge(Some(recent_turn_window))?;
1739 let request = Request {
1740 llm_request: LlmRequest {
1741 messages: vec![
1742 Message::text(Role::System, "client instructions"),
1743 Message::text(Role::User, "initial task"),
1744 Message::text(Role::Assistant, "old response"),
1745 Message::text(Role::User, "old follow-up"),
1746 Message::text(Role::Assistant, "recent 1"),
1747 Message::text(Role::User, "recent 2"),
1748 ],
1749 ..LlmRequest::default()
1750 },
1751 raw_request: None,
1752 metadata: None,
1753 };
1754 Ok(judge
1755 .build_request(&State::default(), &request)
1756 .llm_request
1757 .messages
1758 .iter()
1759 .filter_map(|message| message.text_content("\n"))
1760 .collect())
1761 }
1762
1763 #[test]
1764 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1765 let contents = judged_contents(2)?;
1767 assert!(contents.contains(&"client instructions".to_string()));
1768 assert!(contents.contains(&"initial task".to_string()));
1769 assert!(contents.contains(&"recent 1".to_string()));
1770 assert!(contents.contains(&"recent 2".to_string()));
1771 assert!(!contents.contains(&"old response".to_string()));
1772 Ok(())
1773 }
1774
1775 #[test]
1776 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1777 let contents = judged_contents(0)?;
1778 assert!(contents.contains(&"client instructions".to_string()));
1779 assert!(contents.contains(&"initial task".to_string()));
1780 assert!(!contents.contains(&"recent 2".to_string()));
1781 Ok(())
1782 }
1783
1784 fn tool_call(id: &str) -> Message {
1785 Message {
1786 role: Role::Assistant,
1787 content: vec![ContentBlock::ToolCall(ToolCall {
1788 id: id.to_string(),
1789 name: "search".to_string(),
1790 arguments: Value::Null,
1791 })],
1792 }
1793 }
1794
1795 fn tool_result(id: &str) -> Message {
1796 Message {
1797 role: Role::Tool,
1798 content: vec![ContentBlock::ToolResult(ToolResult {
1799 tool_call_id: id.to_string(),
1800 content: vec![ContentBlock::Text {
1801 text: "tool output".to_string(),
1802 }],
1803 is_error: None,
1804 })],
1805 }
1806 }
1807
1808 #[test]
1809 fn default_task_input_keeps_user_content_around_tool_results() {
1810 let mut result = tool_result("call-1");
1811 result.role = Role::User;
1812 let mut mixed = result.clone();
1813 mixed.content.push(ContentBlock::Text {
1814 text: "latest follow-up".to_string(),
1815 });
1816 let input = TaskInput {
1817 recent_turn_window: None,
1818 };
1819 let mut request = Request {
1820 llm_request: LlmRequest {
1821 messages: vec![
1822 result.clone(),
1823 Message::text(Role::User, "initial task"),
1824 tool_call("call-1"),
1825 mixed,
1826 result.clone(),
1827 ],
1828 ..LlmRequest::default()
1829 },
1830 ..Request::default()
1831 };
1832 assert_eq!(
1833 input.build_messages(&State::default(), &request),
1834 vec![
1835 Message::text(Role::User, "initial task"),
1836 Message::text(Role::User, "latest follow-up"),
1837 ]
1838 );
1839 request.llm_request.messages = vec![result];
1840 assert!(input.build_messages(&State::default(), &request).is_empty());
1841 }
1842
1843 #[test]
1846 fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1847 let messages = vec![
1848 Message::text(Role::System, "client instructions"),
1849 Message::text(Role::User, "initial task"),
1850 Message::text(Role::Assistant, "old response"),
1851 tool_call("call-1"),
1852 tool_result("call-1"),
1853 Message::text(Role::Assistant, "recent 1"),
1854 Message::text(Role::User, "recent 2"),
1855 Message::text(Role::Assistant, "recent 3"),
1856 Message::text(Role::User, "recent 4"),
1857 ];
1858
1859 let kept = trim_messages(&messages, 5);
1861
1862 assert_eq!(
1863 kept,
1864 vec![
1865 Message::text(Role::System, "client instructions"),
1866 Message::text(Role::User, "initial task"),
1867 tool_call("call-1"),
1868 tool_result("call-1"),
1869 Message::text(Role::Assistant, "recent 1"),
1870 Message::text(Role::User, "recent 2"),
1871 Message::text(Role::Assistant, "recent 3"),
1872 Message::text(Role::User, "recent 4"),
1873 ]
1874 );
1875 }
1876
1877 #[test]
1880 fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1881 let messages = vec![
1882 Message::text(Role::System, "client instructions"),
1883 Message::text(Role::User, "initial task"),
1884 tool_call("x"),
1885 tool_result("x"),
1886 Message::text(Role::Assistant, "later"),
1887 tool_call("x"),
1888 tool_result("x"),
1889 ];
1890
1891 let kept = trim_messages(&messages, 4);
1893
1894 assert_eq!(
1895 kept,
1896 vec![
1897 Message::text(Role::System, "client instructions"),
1898 Message::text(Role::User, "initial task"),
1899 tool_call("x"),
1900 tool_result("x"),
1901 Message::text(Role::Assistant, "later"),
1902 tool_call("x"),
1903 tool_result("x"),
1904 ]
1905 );
1906 }
1907
1908 #[test]
1911 fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1912 let messages = vec![
1913 Message::text(Role::System, "client instructions"),
1914 tool_call("orphan"),
1915 Message::text(Role::User, "initial task"),
1916 Message::text(Role::Assistant, "old response"),
1917 tool_result("orphan"),
1918 Message::text(Role::Assistant, "recent 1"),
1919 Message::text(Role::User, "recent 2"),
1920 ];
1921
1922 let kept = trim_messages(&messages, 3);
1923
1924 assert_eq!(
1925 kept,
1926 vec![
1927 Message::text(Role::System, "client instructions"),
1928 Message::text(Role::User, "initial task"),
1929 tool_result("orphan"),
1930 Message::text(Role::Assistant, "recent 1"),
1931 Message::text(Role::User, "recent 2"),
1932 ]
1933 );
1934 }
1935
1936 #[test]
1937 fn a_window_restates_the_routing_instruction_last() -> Result<()> {
1938 let contents = judged_contents(2)?;
1939
1940 assert_eq!(
1943 contents.last().map(String::as_str),
1944 Some(TRAILING_ROUTING_INSTRUCTION)
1945 );
1946 Ok(())
1947 }
1948
1949 #[test]
1954 fn a_window_drops_reasoning_but_keeps_visible_text_and_tool_pairs() {
1955 let messages = vec![
1956 Message::text(Role::System, "client instructions"),
1957 Message::text(Role::User, "initial task"),
1958 Message {
1959 role: Role::Assistant,
1960 content: vec![
1961 ContentBlock::Reasoning {
1962 text: "private chain of thought".to_string(),
1963 signature: None,
1964 details: Vec::new(),
1965 },
1966 ContentBlock::Text {
1967 text: "visible answer".to_string(),
1968 },
1969 ],
1970 },
1971 tool_call("call-1"),
1972 tool_result("call-1"),
1973 Message {
1974 role: Role::Assistant,
1975 content: vec![ContentBlock::Reasoning {
1976 text: "reasoning-only turn".to_string(),
1977 signature: None,
1978 details: Vec::new(),
1979 }],
1980 },
1981 Message::text(Role::User, "follow-up"),
1982 ];
1983 let request = Request {
1984 llm_request: LlmRequest {
1985 messages,
1986 ..LlmRequest::default()
1987 },
1988 raw_request: None,
1989 metadata: None,
1990 };
1991
1992 let built = TaskInput {
1993 recent_turn_window: Some(10),
1994 }
1995 .build_messages(&State::default(), &request);
1996
1997 assert!(
1998 !built
1999 .iter()
2000 .flat_map(|message| &message.content)
2001 .any(|block| matches!(block, ContentBlock::Reasoning { .. })),
2002 "{built:?}"
2003 );
2004 assert!(
2005 built
2006 .iter()
2007 .any(|message| message.text_content("\n").as_deref() == Some("visible answer"))
2008 );
2009 assert!(built.contains(&tool_call("call-1")));
2010 assert!(built.contains(&tool_result("call-1")));
2011 assert_eq!(built.len(), 7);
2014 assert!(built.iter().all(|message| !message.content.is_empty()));
2015 }
2016
2017 #[test]
2018 fn the_default_path_is_left_unchanged() -> Result<()> {
2019 let judge = capability_judge(None)?;
2022 let request = Request {
2023 llm_request: LlmRequest {
2024 messages: vec![Message::text(Role::User, "the task")],
2025 ..LlmRequest::default()
2026 },
2027 raw_request: None,
2028 metadata: None,
2029 };
2030
2031 let built = judge.build_request(&State::default(), &request);
2032
2033 assert_eq!(built.llm_request.messages.len(), 1);
2036 assert!(!built.llm_request.instructions.is_empty());
2037 assert!(
2038 !built
2039 .llm_request
2040 .messages
2041 .iter()
2042 .filter_map(|message| message.text_content("\n"))
2043 .any(|text| text.contains(TRAILING_ROUTING_INSTRUCTION))
2044 );
2045 Ok(())
2046 }
2047
2048 #[test]
2049 fn capability_judge_builds_a_structured_request() -> Result<()> {
2050 let judge = capability_judge(None)?;
2051 let request = Request {
2052 llm_request: LlmRequest {
2053 model: Some("inbound".to_string()),
2054 messages: vec![
2055 Message::text(Role::System, "client instructions"),
2056 Message::text(Role::Developer, "client developer instructions"),
2057 Message::text(Role::User, "initial task"),
2058 Message::text(Role::Assistant, "old response"),
2059 Message::text(Role::User, "old follow-up"),
2060 Message::text(Role::Assistant, "recent 1"),
2061 Message::text(Role::User, "recent 2"),
2062 Message::text(Role::Assistant, "recent 3"),
2063 Message::text(Role::User, "recent 4"),
2064 Message::text(Role::Assistant, "recent 5"),
2065 ],
2066 ..LlmRequest::default()
2067 },
2068 raw_request: None,
2069 metadata: None,
2070 };
2071 let judge_request = judge.build_request(&State::default(), &request);
2072
2073 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
2074 assert_eq!(judge_request.llm_request.instructions.len(), 1);
2075 assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
2076 assert_eq!(
2077 judge_request.llm_request.instructions[0].content,
2078 InstructionBlock {
2079 role: Role::System,
2080 content: Message::text(Role::System, judge.contract().system_prompt()).content,
2081 }
2082 .content,
2083 );
2084 assert_eq!(judge_request.llm_request.messages.len(), 2);
2085 let contents = judge_request
2086 .llm_request
2087 .messages
2088 .iter()
2089 .filter_map(|message| message.text_content("\n"))
2090 .collect::<Vec<_>>();
2091 assert!(contents.contains(&"recent 4".to_string()));
2092 assert!(contents.contains(&"initial task".to_string()));
2093 assert!(!contents.contains(&"recent 5".to_string()));
2094 assert!(!contents.contains(&"client instructions".to_string()));
2095 assert_eq!(
2096 judge_request.llm_request.output.response_format,
2097 Some(judge.contract().response_format().clone())
2098 );
2099 assert_eq!(
2100 judge_request.llm_request.output.max_output_tokens,
2101 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
2102 );
2103 Ok(())
2104 }
2105
2106 fn sample_value(spec: &Value) -> Value {
2107 if let Some(first) = spec
2108 .get("enum")
2109 .and_then(Value::as_array)
2110 .and_then(|values| values.first())
2111 {
2112 return first.clone();
2113 }
2114 match spec.get("type").and_then(Value::as_str) {
2115 Some("number") => serde_json::json!(0.5),
2116 Some("boolean") => serde_json::json!(false),
2117 _ => serde_json::json!("sample"),
2118 }
2119 }
2120
2121 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
2122 let properties = schema
2123 .pointer("/json_schema/schema/properties")
2124 .and_then(Value::as_object)
2125 .ok_or_else(|| LibsyError::AlgorithmError {
2126 message: "packaged schema declares no properties".to_string(),
2127 })?;
2128 Ok(Value::Object(
2129 properties
2130 .iter()
2131 .map(|(name, spec)| (name.clone(), sample_value(spec)))
2132 .collect(),
2133 )
2134 .to_string())
2135 }
2136
2137 #[test]
2140 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
2141 let contract =
2142 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
2143 let schema = contract.response_format();
2144 let reply = schema_shaped_verdict(schema)?;
2145 let judge: CapabilityJudge = StructuredJudge::new(
2146 TaskInput {
2147 recent_turn_window: None,
2148 },
2149 contract,
2150 SerdeDecoder::new(),
2151 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
2152 );
2153
2154 let verdict = judge.parse(&text_response(None, reply))?;
2155
2156 assert!(verdict.is_valid());
2157 assert!((0.0..=1.0).contains(&verdict.p_solve));
2158 Ok(())
2159 }
2160
2161 #[test]
2162 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
2163 let contract =
2164 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
2165 let prompt = contract.system_prompt();
2166 let schema_name = contract
2167 .response_format()
2168 .pointer("/json_schema/name")
2169 .and_then(Value::as_str)
2170 .ok_or_else(|| LibsyError::AlgorithmError {
2171 message: "packaged response schema has no name".to_string(),
2172 })?;
2173 assert_eq!(schema_name, "CapabilityClassifierDecision");
2174 assert!(prompt.contains("SUP-1 [supported]"));
2175 assert!(prompt.contains("SUP-5 [supported]"));
2176 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
2177 assert!(!prompt.contains("\"type\": \"object\""));
2178 assert!(!prompt.contains("\"json_schema\""));
2179 assert!(!prompt.contains(schema_name));
2180 let rule_values = contract
2181 .response_format()
2182 .pointer("/json_schema/schema/properties/primary_rule/enum")
2183 .and_then(Value::as_array)
2184 .ok_or_else(|| LibsyError::AlgorithmError {
2185 message: "rendered response schema has no primary rule enum".to_string(),
2186 })?;
2187 assert!(
2188 rule_values
2189 .iter()
2190 .any(|value| value.as_str() == Some("SUP-1"))
2191 );
2192 assert!(
2193 rule_values
2194 .iter()
2195 .any(|value| value.as_str() == Some("none"))
2196 );
2197 Ok(())
2198 }
2199}