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<State>,
603 inner: Arc<dyn Classifier<State>>,
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<S: Send> Classifier<S> for DefaultCategoryClassifier {
619 async fn score(
620 &self,
621 _state: &mut S,
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<State>> = 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<State>>> {
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::<State>::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(
789 inner: Arc<dyn Classifier<State>>,
790 config: ClassifierRouteConfig,
791 ) -> Result<Self> {
792 if config.message_hash_fallback && config.classify_trigger == ClassifyTrigger::EveryRequest
794 {
795 return Err(LibsyError::AlgorithmError {
796 message:
797 "message_hash_fallback requires classify_trigger = new_session or user_turn"
798 .to_string(),
799 });
800 }
801 let mut route = FallThrough::<State>::new_with_state().with_name(ALGORITHM_NAME);
803 if let Some(affinity) =
804 affinity_router(config.classify_trigger, config.message_hash_fallback).as_ref()
805 {
806 route = route
808 .with_processor(affinity.clone())
809 .with_classifier(affinity.clone());
810 }
811 let fallback = DefaultCategoryClassifier(config.default_target);
812 Ok(Self {
813 route: route
814 .with_classifier(inner.clone())
815 .with_classifier(Arc::new(fallback)),
816 inner,
817 })
818 }
819}
820
821#[async_trait]
822impl Classifier<State> for LlmTaskClassifier {
823 async fn score(
824 &self,
825 state: &mut State,
826 request: &mut Request,
827 driver: &Driver,
828 ) -> Result<(Classification, Option<Response>)> {
829 self.inner.score(state, request, driver).await
830 }
831}
832
833#[async_trait]
834impl Algorithm for LlmTaskClassifier {
835 fn name(&self) -> &str {
836 "llm_task_classifier"
837 }
838
839 async fn route(
840 self: Arc<Self>,
841 driver: Driver,
842 request: Request,
843 ) -> Result<crate::RoutingOutcome> {
844 self.route.execute(driver, request).await
845 }
846}
847
848#[cfg(test)]
849mod tests {
850 use std::collections::HashMap;
851 use std::sync::Arc;
852
853 use parking_lot::Mutex;
854 use serde_json::Value;
855
856 use super::*;
857 use switchyard_protocol::{
858 ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, ModelId, ToolCall,
859 ToolResult, completion_text, text_request, text_response,
860 };
861
862 use crate::algorithms::util::llm_judge::Judge;
863 use crate::core::testing::{Serve, test_drive_with_models};
864 use switchyard_protocol::{LlmResponse, Response};
865
866 const TEST_THRESHOLD: f64 = 0.5;
867
868 type CapabilityJudge = StructuredJudge<TaskInput, SerdeDecoder<TaskClassifierVerdict>>;
869
870 fn llm_config(base_threshold: f64) -> LlmCapabilityConfig {
871 LlmCapabilityConfig {
872 base_threshold,
873 ..LlmCapabilityConfig::default()
874 }
875 }
876
877 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
878 TaskClassifierConfig {
879 judge: CapabilityJudgeConfig::Llm(llm_config(base_threshold)),
880 ..TaskClassifierConfig::default()
881 }
882 }
883
884 fn policy() -> TaskClassifierPolicy {
885 TaskClassifierPolicy::new(&llm_config(TEST_THRESHOLD))
886 }
887
888 fn runtime_models() -> HashMap<Category, Vec<ModelId>> {
889 [
890 (Category::Judge, vec![ModelId::from("judge")]),
891 (Category::Efficient, vec![ModelId::from("efficient")]),
892 (Category::Capable, vec![ModelId::from("capable")]),
893 (
894 Category::Any,
895 vec![ModelId::from("efficient"), ModelId::from("capable")],
896 ),
897 ]
898 .into()
899 }
900
901 fn policy_driver() -> Driver {
902 Driver::new("test", Arc::new(runtime_models().into())).0
903 }
904
905 fn verdict(
906 p_solve: f64,
907 capability_boundary: &str,
908 primary_rule: &str,
909 ) -> TaskClassifierVerdict {
910 TaskClassifierVerdict {
911 crux: "test crux".to_string(),
912 primary_rule: primary_rule.to_string(),
913 capability_boundary: capability_boundary.to_string(),
914 p_solve,
915 }
916 }
917
918 fn selected(
919 policy: &TaskClassifierPolicy,
920 verdict: Option<&TaskClassifierVerdict>,
921 ) -> Result<ModelId> {
922 policy
923 .to_classification(verdict, &policy_driver())?
924 .argmax(false)?
925 .map(|score| score.target)
926 .ok_or_else(|| LibsyError::AlgorithmError {
927 message: "policy abstained".to_string(),
928 })
929 }
930
931 #[derive(Default)]
934 struct Recorder {
935 calls: Mutex<Vec<String>>,
936 call_roles: Mutex<Vec<(String, bool)>>,
937 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
938 judge_system_prompts: Mutex<Vec<String>>,
939 }
940
941 impl Recorder {
942 fn calls(&self) -> Vec<String> {
943 self.calls.lock().clone()
944 }
945
946 fn call_roles(&self) -> Vec<(String, bool)> {
947 self.call_roles.lock().clone()
948 }
949
950 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
951 self.judge_max_output_tokens.lock().clone()
952 }
953
954 fn judge_system_prompts(&self) -> Vec<String> {
955 self.judge_system_prompts.lock().clone()
956 }
957
958 fn serve(self: &Arc<Self>) -> impl Serve {
959 let recorder = Arc::clone(self);
960 move |model: ModelId, request: Request| {
961 let recorder = Arc::clone(&recorder);
962 async move {
963 let model = model.to_string();
964 recorder.calls.lock().push(model.clone());
965 recorder
966 .call_roles
967 .lock()
968 .push((model.clone(), model != "judge"));
969 let completion = if model == "judge" {
970 recorder
971 .judge_max_output_tokens
972 .lock()
973 .push(request.llm_request.output.max_output_tokens);
974 recorder.judge_system_prompts.lock().extend(
975 request
976 .llm_request
977 .instructions
978 .first()
979 .and_then(|instruction| {
980 instruction.content.iter().find_map(|b| {
981 if let ContentBlock::Text { text } = b {
982 Some(text.clone())
983 } else {
984 None
985 }
986 })
987 }),
988 );
989 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
990 } else {
991 format!("answer from {model}")
992 };
993 Ok(Response {
994 llm_response: LlmResponse::Agg(text_response(None, completion)),
995 metadata: request.metadata,
996 upstream_headers: http::HeaderMap::new(),
997 })
998 }
999 }
1000 }
1001 }
1002
1003 fn unreachable_judge() -> impl Serve {
1005 |model: ModelId, request: Request| async move {
1006 let model = model.to_string();
1007 if model == "judge" {
1008 return Err(LlmClientError::Timeout {
1009 source: Box::new(std::io::Error::other("judge unreachable")),
1010 });
1011 }
1012 Ok(Response {
1013 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1014 metadata: request.metadata,
1015 upstream_headers: http::HeaderMap::new(),
1016 })
1017 }
1018 }
1019
1020 fn router() -> Result<Arc<LlmTaskClassifier>> {
1021 Ok(Arc::new(LlmTaskClassifier::new(
1022 LlmClassifierConfig::Capability {
1023 config: test_config(TEST_THRESHOLD),
1024 },
1025 )?))
1026 }
1027
1028 fn classify_request() -> Request {
1029 Request {
1030 llm_request: text_request(Some("auto".to_string()), "classify this task"),
1031 raw_request: None,
1032 metadata: None,
1033 }
1034 }
1035
1036 fn classify_session_request() -> Request {
1037 Request {
1038 metadata: Some(Metadata {
1039 session_id: Some("session-1".to_string()),
1040 ..Metadata::default()
1041 }),
1042 ..classify_request()
1043 }
1044 }
1045
1046 fn classify_follow_up_request() -> Request {
1047 let mut request = classify_request();
1048 request
1049 .llm_request
1050 .messages
1051 .push(Message::text(Role::Assistant, "I will add the test."));
1052 request.llm_request.messages.push(Message::text(
1053 Role::User,
1054 "Now run the test suite and report the result.",
1055 ));
1056 request
1057 }
1058
1059 #[tokio::test]
1060 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1061 let router = router()?;
1062
1063 let (selected_model, response) = test_drive_with_models(
1064 router,
1065 classify_request(),
1066 runtime_models(),
1067 unreachable_judge(),
1068 )
1069 .await?;
1070
1071 assert_eq!(selected_model, "capable");
1072 assert_eq!(
1073 response.llm_response.as_agg().map(completion_text),
1074 Some("answer from capable".to_string())
1075 );
1076 Ok(())
1077 }
1078
1079 #[tokio::test]
1080 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1081 let recorder = Arc::new(Recorder::default());
1082 let router = router()?;
1083 let request = classify_request();
1084 let models = runtime_models();
1085
1086 test_drive_with_models(
1087 router.clone(),
1088 request.clone(),
1089 models.clone(),
1090 recorder.serve(),
1091 )
1092 .await?;
1093 test_drive_with_models(router, request, models, recorder.serve()).await?;
1094
1095 assert_eq!(
1096 recorder.calls(),
1097 vec!["judge", "efficient", "judge", "efficient"]
1098 );
1099 assert_eq!(
1100 recorder.call_roles(),
1101 vec![
1102 ("judge".to_string(), false),
1103 ("efficient".to_string(), true),
1104 ("judge".to_string(), false),
1105 ("efficient".to_string(), true),
1106 ]
1107 );
1108 Ok(())
1109 }
1110
1111 #[tokio::test]
1112 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1113 let recorder = Arc::new(Recorder::default());
1114 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1115 config: TaskClassifierConfig {
1116 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1117 max_output_tokens: 512,
1118 ..llm_config(TEST_THRESHOLD)
1119 }),
1120 ..test_config(TEST_THRESHOLD)
1121 },
1122 })?);
1123
1124 test_drive_with_models(
1125 router,
1126 classify_request(),
1127 runtime_models(),
1128 recorder.serve(),
1129 )
1130 .await?;
1131
1132 assert_eq!(recorder.judge_max_output_tokens(), vec![Some(512)]);
1133 Ok(())
1134 }
1135
1136 #[tokio::test]
1137 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1138 let recorder = Arc::new(Recorder::default());
1139 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1140 config: TaskClassifierConfig {
1141 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1142 contract: ClassifierContractConfig::default()
1143 .with_prompt("Custom capability rubric."),
1144 ..llm_config(TEST_THRESHOLD)
1145 }),
1146 ..test_config(TEST_THRESHOLD)
1147 },
1148 })?);
1149
1150 test_drive_with_models(
1151 router,
1152 classify_request(),
1153 runtime_models(),
1154 recorder.serve(),
1155 )
1156 .await?;
1157
1158 let prompts = recorder.judge_system_prompts();
1159 assert_eq!(prompts.len(), 1);
1160 assert_eq!(prompts[0], "Custom capability rubric.");
1161 Ok(())
1162 }
1163
1164 #[tokio::test]
1165 async fn classifier_config_enables_new_session_trigger() -> Result<()> {
1166 let recorder = Arc::new(Recorder::default());
1167 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1168 config: TaskClassifierConfig {
1169 classify_trigger: ClassifyTrigger::NewSession,
1170 ..test_config(TEST_THRESHOLD)
1171 },
1172 })?);
1173
1174 let request = classify_session_request();
1175 let models = runtime_models();
1176 test_drive_with_models(
1177 router.clone(),
1178 request.clone(),
1179 models.clone(),
1180 recorder.serve(),
1181 )
1182 .await?;
1183 test_drive_with_models(router, request, models, recorder.serve()).await?;
1184
1185 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1186 Ok(())
1187 }
1188
1189 #[tokio::test]
1190 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1191 let recorder = Arc::new(Recorder::default());
1192 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1193 config: TaskClassifierConfig {
1194 classify_trigger: ClassifyTrigger::NewSession,
1195 message_hash_fallback: true,
1196 recent_turn_window: None,
1197 ..test_config(TEST_THRESHOLD)
1198 },
1199 })?);
1200
1201 let models = runtime_models();
1202 test_drive_with_models(
1203 router.clone(),
1204 classify_request(),
1205 models.clone(),
1206 recorder.serve(),
1207 )
1208 .await?;
1209 test_drive_with_models(
1210 router,
1211 classify_follow_up_request(),
1212 models,
1213 recorder.serve(),
1214 )
1215 .await?;
1216
1217 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1218 Ok(())
1219 }
1220
1221 #[tokio::test]
1222 async fn one_classifier_uses_each_requests_runtime_models() -> Result<()> {
1223 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1224 config: TaskClassifierConfig {
1225 classify_trigger: ClassifyTrigger::NewSession,
1226 ..test_config(TEST_THRESHOLD)
1227 },
1228 })?);
1229 let calls = Arc::new(Mutex::new(Vec::new()));
1230 let serve = |calls: Arc<Mutex<Vec<String>>>| {
1231 move |model: ModelId, _request: Request| {
1232 let calls = Arc::clone(&calls);
1233 async move {
1234 calls.lock().push(model.to_string());
1235 let text = if model.as_str().starts_with("judge-") {
1236 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1237 } else {
1238 model.to_string()
1239 };
1240 Ok(Response {
1241 llm_response: LlmResponse::Agg(text_response(None, text)),
1242 metadata: None,
1243 upstream_headers: Default::default(),
1244 })
1245 }
1246 }
1247 };
1248 let models = |suffix: &str| -> HashMap<Category, Vec<ModelId>> {
1249 [
1250 (
1251 Category::Judge,
1252 vec![ModelId::from(format!("judge-{suffix}"))],
1253 ),
1254 (
1255 Category::Efficient,
1256 vec![ModelId::from(format!("efficient-{suffix}"))],
1257 ),
1258 (
1259 Category::Capable,
1260 vec![ModelId::from(format!("capable-{suffix}"))],
1261 ),
1262 (
1263 Category::Any,
1264 vec![
1265 ModelId::from(format!("efficient-{suffix}")),
1266 ModelId::from(format!("capable-{suffix}")),
1267 ],
1268 ),
1269 ]
1270 .into()
1271 };
1272 let request = classify_session_request();
1273
1274 let (first, _) = test_drive_with_models(
1275 router.clone(),
1276 request.clone(),
1277 models("a"),
1278 serve(Arc::clone(&calls)),
1279 )
1280 .await?;
1281 let (second, _) =
1282 test_drive_with_models(router, request, models("b"), serve(Arc::clone(&calls))).await?;
1283
1284 assert_eq!(first, "efficient-a");
1285 assert_eq!(second, "efficient-b");
1286 assert_eq!(
1287 &*calls.lock(),
1288 &["judge-a", "efficient-a", "judge-b", "efficient-b"]
1289 );
1290 Ok(())
1291 }
1292
1293 #[tokio::test]
1294 async fn decision_judge_routes_on_relative_advantage() -> Result<()> {
1295 use crate::{Call, RuntimeModels, drive};
1296 use serde_json::json;
1297 use std::collections::BTreeMap;
1298 use std::sync::atomic::{AtomicUsize, Ordering};
1299 use switchyard_protocol::{
1300 BooleanEstimate, DecisionAnswer, DecisionResponse, DecisionValue, Probability,
1301 ProviderConfidence,
1302 };
1303
1304 let settings = DecisionJudgeConfig {
1305 cutoff: 0.4,
1306 instructions: None,
1307 candidates: BTreeMap::from([
1308 ("a".into(), "capable".into()),
1309 ("b".into(), "efficient".into()),
1310 ("c".into(), "third".into()),
1311 ]),
1312 evidence: json!({
1313 "candidate_descriptions": {"a": "Profile A", "b": "Profile B", "c": "Profile C"},
1314 "reference_cases": [{"task": "Independent example", "observed_solved": {"a": true, "b": null, "c": false}}],
1315 "summaries": [],
1316 }),
1317 };
1318 let mut request = classify_session_request();
1319 request.llm_request.messages.push(Message {
1320 role: Role::Assistant,
1321 content: vec![
1322 ContentBlock::Reasoning {
1323 text: "private reasoning".into(),
1324 signature: None,
1325 details: vec![],
1326 },
1327 ContentBlock::Text {
1328 text: "visible progress".into(),
1329 },
1330 ],
1331 });
1332 request
1333 .llm_request
1334 .messages
1335 .push(Message::text(Role::User, "latest follow-up"));
1336 let original_messages = request.llm_request.messages.clone();
1337 let expected_task = json!([
1338 original_messages[0].clone(),
1339 Message::text(Role::Assistant, "visible progress"),
1340 Message::text(Role::User, "latest follow-up"),
1341 ]);
1342
1343 for (name, score, fail_open, expected) in [
1344 ("above", 0.45, true, Some("capable")),
1345 ("equal", 0.4, true, Some("efficient")),
1346 ("below", 0.39, false, Some("efficient")),
1347 ("missing answer", 0.0, true, Some("capable")),
1348 ("no distribution", 0.0, true, Some("capable")),
1349 ("wrong type", 0.0, true, Some("capable")),
1350 ("out of range", 1.1, true, Some("capable")),
1351 ("nonfinite", f64::NAN, false, Some("capable")),
1352 ("provider error", 0.0, true, Some("capable")),
1353 ("provider error", 0.0, false, None),
1354 ("dropped reply", 0.0, true, Some("capable")),
1355 ("missing candidate", 0.0, true, None),
1356 ] {
1357 let mut config = settings.clone();
1358 if name == "missing candidate" {
1359 config.candidates.remove("a");
1360 }
1361 if name == "below" {
1362 config.instructions = Some(
1363 json!({"policy": "Judge capable-only success using the supplied evidence."}),
1364 );
1365 }
1366 let override_instructions = config.instructions.clone();
1367 let router: Arc<dyn Algorithm> =
1368 Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1369 config: TaskClassifierConfig {
1370 judge: CapabilityJudgeConfig::Decision(config),
1371 fail_open,
1372 recent_turn_window: Some(3),
1373 classify_trigger: ClassifyTrigger::NewSession,
1374 ..TaskClassifierConfig::default()
1375 },
1376 })?);
1377 let calls = AtomicUsize::new(0);
1378 let serve = |call| {
1379 let calls = &calls;
1380 let expected_task = &expected_task;
1381 let settings = &settings;
1382 let override_instructions = &override_instructions;
1383 async move {
1384 let Call::Decision(call) = call else {
1385 panic!("judge must use the decision step")
1386 };
1387 calls.fetch_add(1, Ordering::SeqCst);
1388 assert_eq!(call.model, "judge");
1389 assert_eq!(call.request.model, Some("judge".into()));
1390 assert_eq!(
1391 call.request.context,
1392 json!({
1393 "task": expected_task,
1394 "candidates": ["a", "b", "c"],
1395 "comparison": {"capable": "a", "efficient": "b"},
1396 "evidence": settings.evidence,
1397 })
1398 );
1399 assert_eq!(call.request.questions.len(), 1);
1400 let question = &call.request.questions["route"];
1401 let switchyard_protocol::DecisionKind::Choice { options } = &question.kind
1402 else {
1403 panic!("expected a choice")
1404 };
1405 assert_eq!(
1406 options
1407 .iter()
1408 .map(|option| option.id.as_str())
1409 .collect::<Vec<_>>(),
1410 ["advantage", "no_advantage"]
1411 );
1412 if let Some(instructions) = override_instructions {
1413 assert_eq!(&question.instructions, instructions);
1414 } else {
1415 for key in ["setting", "evidence", "comparison", "boundary", "policy"] {
1416 assert!(question.instructions[key].as_str().is_some());
1417 }
1418 }
1419 if name == "provider error" {
1420 return call
1421 .respond(std::future::ready(Err(LibsyError::client_call(
1422 "judge",
1423 LlmClientError::General("private provider body".into()),
1424 ))))
1425 .await;
1426 }
1427 if name == "dropped reply" {
1428 drop(call);
1429 return Ok(());
1430 }
1431 let value = if name == "wrong type" {
1432 DecisionValue::Boolean(BooleanEstimate::Value(true))
1433 } else {
1434 DecisionValue::Choice {
1435 selected: "no_advantage".into(),
1436 probabilities: (name != "no distribution").then(|| {
1437 BTreeMap::from([
1438 ("advantage".into(), Probability(score)),
1439 ("no_advantage".into(), Probability(1.0 - score)),
1440 ])
1441 }),
1442 }
1443 };
1444 let answers = if name == "missing answer" {
1445 BTreeMap::new()
1446 } else {
1447 BTreeMap::from([(
1448 "route".into(),
1449 DecisionAnswer {
1450 value,
1451 provider_confidence: Some(ProviderConfidence(0.99)),
1452 },
1453 )])
1454 };
1455 call.respond(std::future::ready(Ok(DecisionResponse {
1456 id: None,
1457 model: Some("provider-judge".into()),
1458 answers,
1459 usage: Default::default(),
1460 })))
1461 .await
1462 }
1463 };
1464 let models = Arc::new(RuntimeModels::new(runtime_models()));
1465 let result = drive(router.clone(), request.clone(), models.clone(), &serve).await;
1466 assert_eq!(
1467 calls.load(Ordering::SeqCst),
1468 usize::from(name != "missing candidate"),
1469 "{name}"
1470 );
1471 let Some(expected) = expected else {
1472 if name == "missing candidate" {
1473 assert!(
1474 matches!(result, Err(LibsyError::AlgorithmError { message }) if message.contains("candidate is missing"))
1475 );
1476 } else {
1477 assert!(
1478 matches!(result, Err(LibsyError::ClientCall { .. })),
1479 "{name}"
1480 );
1481 }
1482 continue;
1483 };
1484 let outcome = result?;
1485 assert_eq!(outcome.selected_model_id()?, expected, "{name}");
1486 assert!(outcome.response.is_none());
1487 assert_eq!(outcome.request.llm_request.messages, original_messages);
1488 let evidence = outcome
1489 .metadata
1490 .and_then(|metadata| metadata.evidence)
1491 .expect("routing evidence");
1492 if matches!(name, "above" | "equal" | "below") {
1493 assert_eq!(evidence["source"], "decision_classifier");
1494 assert_eq!(evidence["verdict"], "relative_advantage");
1495 assert_eq!(evidence["threshold"], settings.cutoff);
1496 assert_eq!(evidence["score"], score);
1497 } else {
1498 let reason = match name {
1499 "provider error" => "client_error",
1500 "dropped reply" => "call_error",
1501 _ => "invalid_verdict",
1502 };
1503 assert_eq!(
1504 evidence,
1505 json!({"source": "fail_open", "reason_code": reason})
1506 );
1507 }
1508 let retained = drive(router, request.clone(), models, &serve).await?;
1509 assert_eq!(retained.selected_model_id()?, expected);
1510 assert_eq!(
1511 calls.load(Ordering::SeqCst),
1512 1,
1513 "affinity should skip the judge: {name}"
1514 );
1515 }
1516 for cutoff in [-0.1, 1.1, f64::NAN] {
1517 let mut config = settings.clone();
1518 config.cutoff = cutoff;
1519 assert!(
1520 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1521 config: TaskClassifierConfig {
1522 judge: CapabilityJudgeConfig::Decision(config),
1523 ..TaskClassifierConfig::default()
1524 },
1525 })
1526 .is_err()
1527 );
1528 }
1529 let mut duplicate = settings.clone();
1530 duplicate
1531 .candidates
1532 .insert("duplicate-a".into(), "capable".into());
1533 assert!(duplicate.validate().is_err());
1534 Ok(())
1535 }
1536
1537 #[test]
1538 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1539 let policy = policy();
1540 let at_threshold = verdict(0.5, "supported", "SUP-1");
1541 let below_threshold = verdict(0.49, "supported", "SUP-1");
1542 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1543 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1544 Ok(())
1545 }
1546
1547 #[test]
1548 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1549 let borderline = verdict(0.5, "supported", "SUP-1");
1550 let strict = TaskClassifierPolicy::new(&llm_config(0.9));
1551 let lenient = TaskClassifierPolicy::new(&llm_config(0.1));
1552 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1553 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1554 Ok(())
1555 }
1556
1557 #[test]
1558 fn classifier_config_rejects_unknown_fields() {
1559 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1560 "base_threshold": 0.5,
1561 "classifier_magic": true,
1562 }))
1563 .expect_err("unknown classifier fields must be rejected");
1564
1565 assert!(
1566 error
1567 .to_string()
1568 .contains("unknown field `classifier_magic`"),
1569 "{error}"
1570 );
1571 }
1572
1573 #[test]
1574 fn invalid_classifier_config_is_rejected() -> Result<()> {
1575 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1576 assert!(
1577 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1578 config: test_config(bad),
1579 })
1580 .is_err(),
1581 "base threshold {bad} should be rejected"
1582 );
1583 }
1584 for config in [
1585 TaskClassifierConfig {
1586 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1587 base_threshold: 0.5,
1588 threshold_step: -0.1,
1589 ..LlmCapabilityConfig::default()
1590 }),
1591 ..TaskClassifierConfig::default()
1592 },
1593 TaskClassifierConfig {
1594 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1595 base_threshold: 0.8,
1596 threshold_step: 0.11,
1597 ..LlmCapabilityConfig::default()
1598 }),
1599 ..TaskClassifierConfig::default()
1600 },
1601 TaskClassifierConfig {
1602 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1603 base_threshold: 0.5,
1604 ..LlmCapabilityConfig::default()
1605 }),
1606 message_hash_fallback: true,
1607 ..TaskClassifierConfig::default()
1608 },
1609 TaskClassifierConfig {
1610 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1611 base_threshold: 0.5,
1612 max_output_tokens: 0,
1613 ..LlmCapabilityConfig::default()
1614 }),
1615 ..TaskClassifierConfig::default()
1616 },
1617 ] {
1618 assert!(LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).is_err());
1619 }
1620 for base_threshold in [0.0, 1.0] {
1621 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1622 config: test_config(base_threshold),
1623 })?;
1624 }
1625 Ok(())
1626 }
1627
1628 #[test]
1629 fn message_hash_fallback_accepts_retaining_triggers() -> Result<()> {
1630 for trigger in [ClassifyTrigger::NewSession, ClassifyTrigger::UserTurn] {
1634 let config = TaskClassifierConfig {
1635 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1636 base_threshold: 0.5,
1637 ..LlmCapabilityConfig::default()
1638 }),
1639 classify_trigger: trigger,
1640 message_hash_fallback: true,
1641 ..TaskClassifierConfig::default()
1642 };
1643 LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).map_err(
1644 |error| LibsyError::AlgorithmError {
1645 message: format!("{trigger:?} with message_hash_fallback rejected: {error}"),
1646 },
1647 )?;
1648 }
1649 let every_request = TaskClassifierConfig {
1650 judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1651 base_threshold: 0.5,
1652 ..LlmCapabilityConfig::default()
1653 }),
1654 classify_trigger: ClassifyTrigger::EveryRequest,
1655 message_hash_fallback: true,
1656 ..TaskClassifierConfig::default()
1657 };
1658 assert!(
1659 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1660 config: every_request
1661 })
1662 .is_err(),
1663 "every_request with message_hash_fallback should stay rejected"
1664 );
1665 Ok(())
1666 }
1667
1668 #[test]
1669 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1670 let policy = policy();
1671 let inconsistent_rule = TaskClassifierVerdict {
1672 capability_boundary: "uncertain".to_string(),
1673 ..verdict(1.0, "supported", "SUP-1")
1674 };
1675 let empty_crux = TaskClassifierVerdict {
1676 crux: " ".to_string(),
1677 ..verdict(1.0, "supported", "SUP-1")
1678 };
1679 let unusable = [
1680 Some(verdict(1.1, "supported", "SUP-1")),
1681 Some(inconsistent_rule),
1682 Some(empty_crux),
1683 None,
1684 ];
1685 for verdict in unusable {
1686 let classification = policy.to_classification(verdict.as_ref(), &policy_driver())?;
1687 assert!(matches!(classification, Classification::Ambiguous(_)));
1688 assert!(classification.argmax(false)?.is_none());
1689 assert!(classification.argmax(true)?.is_none());
1690 }
1691 Ok(())
1692 }
1693
1694 #[test]
1695 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1696 let policy = TaskClassifierPolicy::new(&LlmCapabilityConfig {
1697 threshold_step: 0.1,
1698 ..llm_config(0.4)
1699 });
1700
1701 assert_eq!(
1702 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1703 "efficient"
1704 );
1705 assert_eq!(
1706 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1707 "capable"
1708 );
1709 assert_eq!(
1710 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1711 "efficient"
1712 );
1713 assert_eq!(
1714 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1715 "efficient"
1716 );
1717 assert_eq!(
1718 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1719 "capable"
1720 );
1721 assert_eq!(
1722 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1723 "efficient"
1724 );
1725 Ok(())
1726 }
1727
1728 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1731 Ok(StructuredJudge::new(
1732 TaskInput { recent_turn_window },
1733 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1734 SerdeDecoder::new(),
1735 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1736 ))
1737 }
1738
1739 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1740 let judge = capability_judge(Some(recent_turn_window))?;
1741 let request = Request {
1742 llm_request: LlmRequest {
1743 messages: vec![
1744 Message::text(Role::System, "client instructions"),
1745 Message::text(Role::User, "initial task"),
1746 Message::text(Role::Assistant, "old response"),
1747 Message::text(Role::User, "old follow-up"),
1748 Message::text(Role::Assistant, "recent 1"),
1749 Message::text(Role::User, "recent 2"),
1750 ],
1751 ..LlmRequest::default()
1752 },
1753 raw_request: None,
1754 metadata: None,
1755 };
1756 Ok(judge
1757 .build_request(&State::default(), &request)
1758 .llm_request
1759 .messages
1760 .iter()
1761 .filter_map(|message| message.text_content("\n"))
1762 .collect())
1763 }
1764
1765 #[test]
1766 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1767 let contents = judged_contents(2)?;
1769 assert!(contents.contains(&"client instructions".to_string()));
1770 assert!(contents.contains(&"initial task".to_string()));
1771 assert!(contents.contains(&"recent 1".to_string()));
1772 assert!(contents.contains(&"recent 2".to_string()));
1773 assert!(!contents.contains(&"old response".to_string()));
1774 Ok(())
1775 }
1776
1777 #[test]
1778 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1779 let contents = judged_contents(0)?;
1780 assert!(contents.contains(&"client instructions".to_string()));
1781 assert!(contents.contains(&"initial task".to_string()));
1782 assert!(!contents.contains(&"recent 2".to_string()));
1783 Ok(())
1784 }
1785
1786 fn tool_call(id: &str) -> Message {
1787 Message {
1788 role: Role::Assistant,
1789 content: vec![ContentBlock::ToolCall(ToolCall {
1790 id: id.to_string(),
1791 name: "search".to_string(),
1792 arguments: Value::Null,
1793 })],
1794 }
1795 }
1796
1797 fn tool_result(id: &str) -> Message {
1798 Message {
1799 role: Role::Tool,
1800 content: vec![ContentBlock::ToolResult(ToolResult {
1801 tool_call_id: id.to_string(),
1802 content: vec![ContentBlock::Text {
1803 text: "tool output".to_string(),
1804 }],
1805 is_error: None,
1806 })],
1807 }
1808 }
1809
1810 #[test]
1811 fn default_task_input_keeps_user_content_around_tool_results() {
1812 let mut result = tool_result("call-1");
1813 result.role = Role::User;
1814 let mut mixed = result.clone();
1815 mixed.content.push(ContentBlock::Text {
1816 text: "latest follow-up".to_string(),
1817 });
1818 let input = TaskInput {
1819 recent_turn_window: None,
1820 };
1821 let mut request = Request {
1822 llm_request: LlmRequest {
1823 messages: vec![
1824 result.clone(),
1825 Message::text(Role::User, "initial task"),
1826 tool_call("call-1"),
1827 mixed,
1828 result.clone(),
1829 ],
1830 ..LlmRequest::default()
1831 },
1832 ..Request::default()
1833 };
1834 assert_eq!(
1835 input.build_messages(&State::default(), &request),
1836 vec![
1837 Message::text(Role::User, "initial task"),
1838 Message::text(Role::User, "latest follow-up"),
1839 ]
1840 );
1841 request.llm_request.messages = vec![result];
1842 assert!(input.build_messages(&State::default(), &request).is_empty());
1843 }
1844
1845 #[test]
1848 fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1849 let messages = vec![
1850 Message::text(Role::System, "client instructions"),
1851 Message::text(Role::User, "initial task"),
1852 Message::text(Role::Assistant, "old response"),
1853 tool_call("call-1"),
1854 tool_result("call-1"),
1855 Message::text(Role::Assistant, "recent 1"),
1856 Message::text(Role::User, "recent 2"),
1857 Message::text(Role::Assistant, "recent 3"),
1858 Message::text(Role::User, "recent 4"),
1859 ];
1860
1861 let kept = trim_messages(&messages, 5);
1863
1864 assert_eq!(
1865 kept,
1866 vec![
1867 Message::text(Role::System, "client instructions"),
1868 Message::text(Role::User, "initial task"),
1869 tool_call("call-1"),
1870 tool_result("call-1"),
1871 Message::text(Role::Assistant, "recent 1"),
1872 Message::text(Role::User, "recent 2"),
1873 Message::text(Role::Assistant, "recent 3"),
1874 Message::text(Role::User, "recent 4"),
1875 ]
1876 );
1877 }
1878
1879 #[test]
1882 fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1883 let messages = vec![
1884 Message::text(Role::System, "client instructions"),
1885 Message::text(Role::User, "initial task"),
1886 tool_call("x"),
1887 tool_result("x"),
1888 Message::text(Role::Assistant, "later"),
1889 tool_call("x"),
1890 tool_result("x"),
1891 ];
1892
1893 let kept = trim_messages(&messages, 4);
1895
1896 assert_eq!(
1897 kept,
1898 vec![
1899 Message::text(Role::System, "client instructions"),
1900 Message::text(Role::User, "initial task"),
1901 tool_call("x"),
1902 tool_result("x"),
1903 Message::text(Role::Assistant, "later"),
1904 tool_call("x"),
1905 tool_result("x"),
1906 ]
1907 );
1908 }
1909
1910 #[test]
1913 fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1914 let messages = vec![
1915 Message::text(Role::System, "client instructions"),
1916 tool_call("orphan"),
1917 Message::text(Role::User, "initial task"),
1918 Message::text(Role::Assistant, "old response"),
1919 tool_result("orphan"),
1920 Message::text(Role::Assistant, "recent 1"),
1921 Message::text(Role::User, "recent 2"),
1922 ];
1923
1924 let kept = trim_messages(&messages, 3);
1925
1926 assert_eq!(
1927 kept,
1928 vec![
1929 Message::text(Role::System, "client instructions"),
1930 Message::text(Role::User, "initial task"),
1931 tool_result("orphan"),
1932 Message::text(Role::Assistant, "recent 1"),
1933 Message::text(Role::User, "recent 2"),
1934 ]
1935 );
1936 }
1937
1938 #[test]
1939 fn a_window_restates_the_routing_instruction_last() -> Result<()> {
1940 let contents = judged_contents(2)?;
1941
1942 assert_eq!(
1945 contents.last().map(String::as_str),
1946 Some(TRAILING_ROUTING_INSTRUCTION)
1947 );
1948 Ok(())
1949 }
1950
1951 #[test]
1956 fn a_window_drops_reasoning_but_keeps_visible_text_and_tool_pairs() {
1957 let messages = vec![
1958 Message::text(Role::System, "client instructions"),
1959 Message::text(Role::User, "initial task"),
1960 Message {
1961 role: Role::Assistant,
1962 content: vec![
1963 ContentBlock::Reasoning {
1964 text: "private chain of thought".to_string(),
1965 signature: None,
1966 details: Vec::new(),
1967 },
1968 ContentBlock::Text {
1969 text: "visible answer".to_string(),
1970 },
1971 ],
1972 },
1973 tool_call("call-1"),
1974 tool_result("call-1"),
1975 Message {
1976 role: Role::Assistant,
1977 content: vec![ContentBlock::Reasoning {
1978 text: "reasoning-only turn".to_string(),
1979 signature: None,
1980 details: Vec::new(),
1981 }],
1982 },
1983 Message::text(Role::User, "follow-up"),
1984 ];
1985 let request = Request {
1986 llm_request: LlmRequest {
1987 messages,
1988 ..LlmRequest::default()
1989 },
1990 raw_request: None,
1991 metadata: None,
1992 };
1993
1994 let built = TaskInput {
1995 recent_turn_window: Some(10),
1996 }
1997 .build_messages(&State::default(), &request);
1998
1999 assert!(
2000 !built
2001 .iter()
2002 .flat_map(|message| &message.content)
2003 .any(|block| matches!(block, ContentBlock::Reasoning { .. })),
2004 "{built:?}"
2005 );
2006 assert!(
2007 built
2008 .iter()
2009 .any(|message| message.text_content("\n").as_deref() == Some("visible answer"))
2010 );
2011 assert!(built.contains(&tool_call("call-1")));
2012 assert!(built.contains(&tool_result("call-1")));
2013 assert_eq!(built.len(), 7);
2016 assert!(built.iter().all(|message| !message.content.is_empty()));
2017 }
2018
2019 #[test]
2020 fn the_default_path_is_left_unchanged() -> Result<()> {
2021 let judge = capability_judge(None)?;
2024 let request = Request {
2025 llm_request: LlmRequest {
2026 messages: vec![Message::text(Role::User, "the task")],
2027 ..LlmRequest::default()
2028 },
2029 raw_request: None,
2030 metadata: None,
2031 };
2032
2033 let built = judge.build_request(&State::default(), &request);
2034
2035 assert_eq!(built.llm_request.messages.len(), 1);
2038 assert!(!built.llm_request.instructions.is_empty());
2039 assert!(
2040 !built
2041 .llm_request
2042 .messages
2043 .iter()
2044 .filter_map(|message| message.text_content("\n"))
2045 .any(|text| text.contains(TRAILING_ROUTING_INSTRUCTION))
2046 );
2047 Ok(())
2048 }
2049
2050 #[test]
2051 fn capability_judge_builds_a_structured_request() -> Result<()> {
2052 let judge = capability_judge(None)?;
2053 let request = Request {
2054 llm_request: LlmRequest {
2055 model: Some("inbound".to_string()),
2056 messages: vec![
2057 Message::text(Role::System, "client instructions"),
2058 Message::text(Role::Developer, "client developer instructions"),
2059 Message::text(Role::User, "initial task"),
2060 Message::text(Role::Assistant, "old response"),
2061 Message::text(Role::User, "old follow-up"),
2062 Message::text(Role::Assistant, "recent 1"),
2063 Message::text(Role::User, "recent 2"),
2064 Message::text(Role::Assistant, "recent 3"),
2065 Message::text(Role::User, "recent 4"),
2066 Message::text(Role::Assistant, "recent 5"),
2067 ],
2068 ..LlmRequest::default()
2069 },
2070 raw_request: None,
2071 metadata: None,
2072 };
2073 let judge_request = judge.build_request(&State::default(), &request);
2074
2075 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
2076 assert_eq!(judge_request.llm_request.instructions.len(), 1);
2077 assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
2078 assert_eq!(
2079 judge_request.llm_request.instructions[0].content,
2080 InstructionBlock {
2081 role: Role::System,
2082 content: Message::text(Role::System, judge.contract().system_prompt()).content,
2083 }
2084 .content,
2085 );
2086 assert_eq!(judge_request.llm_request.messages.len(), 2);
2087 let contents = judge_request
2088 .llm_request
2089 .messages
2090 .iter()
2091 .filter_map(|message| message.text_content("\n"))
2092 .collect::<Vec<_>>();
2093 assert!(contents.contains(&"recent 4".to_string()));
2094 assert!(contents.contains(&"initial task".to_string()));
2095 assert!(!contents.contains(&"recent 5".to_string()));
2096 assert!(!contents.contains(&"client instructions".to_string()));
2097 assert_eq!(
2098 judge_request.llm_request.output.response_format,
2099 Some(judge.contract().response_format().clone())
2100 );
2101 assert_eq!(
2102 judge_request.llm_request.output.max_output_tokens,
2103 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
2104 );
2105 Ok(())
2106 }
2107
2108 fn sample_value(spec: &Value) -> Value {
2109 if let Some(first) = spec
2110 .get("enum")
2111 .and_then(Value::as_array)
2112 .and_then(|values| values.first())
2113 {
2114 return first.clone();
2115 }
2116 match spec.get("type").and_then(Value::as_str) {
2117 Some("number") => serde_json::json!(0.5),
2118 Some("boolean") => serde_json::json!(false),
2119 _ => serde_json::json!("sample"),
2120 }
2121 }
2122
2123 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
2124 let properties = schema
2125 .pointer("/json_schema/schema/properties")
2126 .and_then(Value::as_object)
2127 .ok_or_else(|| LibsyError::AlgorithmError {
2128 message: "packaged schema declares no properties".to_string(),
2129 })?;
2130 Ok(Value::Object(
2131 properties
2132 .iter()
2133 .map(|(name, spec)| (name.clone(), sample_value(spec)))
2134 .collect(),
2135 )
2136 .to_string())
2137 }
2138
2139 #[test]
2142 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
2143 let contract =
2144 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
2145 let schema = contract.response_format();
2146 let reply = schema_shaped_verdict(schema)?;
2147 let judge: CapabilityJudge = StructuredJudge::new(
2148 TaskInput {
2149 recent_turn_window: None,
2150 },
2151 contract,
2152 SerdeDecoder::new(),
2153 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
2154 );
2155
2156 let verdict = judge.parse(&text_response(None, reply))?;
2157
2158 assert!(verdict.is_valid());
2159 assert!((0.0..=1.0).contains(&verdict.p_solve));
2160 Ok(())
2161 }
2162
2163 #[test]
2164 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
2165 let contract =
2166 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
2167 let prompt = contract.system_prompt();
2168 let schema_name = contract
2169 .response_format()
2170 .pointer("/json_schema/name")
2171 .and_then(Value::as_str)
2172 .ok_or_else(|| LibsyError::AlgorithmError {
2173 message: "packaged response schema has no name".to_string(),
2174 })?;
2175 assert_eq!(schema_name, "CapabilityClassifierDecision");
2176 assert!(prompt.contains("SUP-1 [supported]"));
2177 assert!(prompt.contains("SUP-5 [supported]"));
2178 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
2179 assert!(!prompt.contains("\"type\": \"object\""));
2180 assert!(!prompt.contains("\"json_schema\""));
2181 assert!(!prompt.contains(schema_name));
2182 let rule_values = contract
2183 .response_format()
2184 .pointer("/json_schema/schema/properties/primary_rule/enum")
2185 .and_then(Value::as_array)
2186 .ok_or_else(|| LibsyError::AlgorithmError {
2187 message: "rendered response schema has no primary rule enum".to_string(),
2188 })?;
2189 assert!(
2190 rule_values
2191 .iter()
2192 .any(|value| value.as_str() == Some("SUP-1"))
2193 );
2194 assert!(
2195 rule_values
2196 .iter()
2197 .any(|value| value.as_str() == Some("none"))
2198 );
2199 Ok(())
2200 }
2201}