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