1use std::collections::{BTreeMap, BTreeSet, HashSet};
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use serde::{Deserialize, Deserializer};
11use serde_json::Value;
12use switchyard_protocol::{ContentBlock, Message, ModelId, Role};
13
14use super::escalation;
15use super::fall_through::{DefaultTarget, FallThrough};
16use super::util::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS;
17use super::util::affinity::{AffinityRouter, ClassifyTrigger};
18use super::util::classifier_contract::{
19 ClassifierContract, ClassifierContractConfig, ClassifierResponseFormat,
20};
21use super::util::escalation::{EscalationGateConfig, EscalationJudgeConfig};
22use super::util::llm_judge::{
23 ClassifierInput, JsonSchemaDecoder, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig,
24 SerdeDecoder, StructuredJudge,
25};
26use super::util::target_selector::TargetSelectorPolicy;
27use crate::core::algorithm::{self, Algorithm, Driver};
28use crate::core::classifier::{Classification, Classifier, Score};
29use crate::core::state::State;
30use crate::{LibsyError, Result};
31use switchyard_protocol::{Request, Response};
32
33const PROMPT_TEMPLATE: &str = include_str!("../prompts/capability-classifier/prompt.md");
34const SCHEMA_TEMPLATE: &str = include_str!("../prompts/capability-classifier/schema.json");
35const ALGORITHM_NAME: &str = "llm_task_classifier";
37
38const TRAILING_ROUTING_INSTRUCTION: &str =
47 "Route the conversation above. Output ONLY the routing JSON object, nothing else.";
48
49#[derive(Deserialize)]
50#[serde(deny_unknown_fields)]
51struct TaskClassifierVerdict {
52 crux: String,
53 primary_rule: String,
54 capability_boundary: String,
55 p_solve: f64,
56}
57
58impl TaskClassifierVerdict {
59 fn is_valid(&self) -> bool {
61 (0.0..=1.0).contains(&self.p_solve)
62 && !self.crux.trim().is_empty()
63 && matches!(
64 (
65 self.primary_rule.as_str(),
66 self.capability_boundary.as_str()
67 ),
68 ("SUP-1" | "SUP-2" | "SUP-3" | "SUP-4" | "SUP-5", "supported")
69 | ("UNC-1" | "UNC-2", "uncertain")
70 | ("LIM-1" | "LIM-2", "unsupported")
71 | ("none", "unmatched")
72 )
73 }
74
75 fn boundary_steps(&self) -> Option<u8> {
77 match self.capability_boundary.as_str() {
78 "supported" => Some(0),
79 "uncertain" | "unmatched" => Some(1),
80 "unsupported" => Some(2),
81 _ => None,
82 }
83 }
84}
85
86fn trim_messages(messages: &[Message], recent_turn_window: usize) -> Vec<Message> {
96 let is_instruction = |message: &Message| matches!(message.role, Role::System | Role::Developer);
97 let mut kept: Vec<&Message> = messages.iter().filter(|m| is_instruction(m)).collect();
98 let Some(task) = messages.iter().position(|m| m.role == Role::User) else {
99 return kept.into_iter().cloned().collect();
100 };
101 kept.push(&messages[task]);
102
103 let tail: Vec<&Message> = messages[task + 1..]
104 .iter()
105 .filter(|m| !is_instruction(m))
106 .collect();
107 kept.extend(&tail[window_start(&tail, recent_turn_window)..]);
108 kept.into_iter().cloned().collect()
109}
110
111fn window_start(tail: &[&Message], recent_turn_window: usize) -> usize {
124 let counted = tail.len().saturating_sub(recent_turn_window);
125 if counted == tail.len() {
127 return counted;
128 }
129 let mut unpaired: HashSet<&str> = HashSet::new();
130 for (start, message) in tail.iter().enumerate().rev() {
131 for block in message.content.iter().rev() {
134 match block {
135 ContentBlock::ToolResult(result) => {
136 unpaired.insert(result.tool_call_id.as_str());
137 }
138 ContentBlock::ToolCall(call) => {
139 unpaired.remove(call.id.as_str());
140 }
141 _ => {}
142 }
143 }
144 if start <= counted && unpaired.is_empty() {
145 return start;
146 }
147 }
148 counted
149}
150
151fn task_messages(messages: &[Message]) -> Vec<Message> {
153 let mut user_messages = messages.iter().filter(|message| message.role == Role::User);
154 let Some(opening_task) = user_messages.next() else {
155 return Vec::new();
156 };
157 match user_messages.next_back() {
158 Some(latest_follow_up) => vec![opening_task.clone(), latest_follow_up.clone()],
159 None => vec![opening_task.clone()],
160 }
161}
162
163struct TaskInput {
165 recent_turn_window: Option<usize>,
166}
167
168impl ClassifierInput for TaskInput {
169 fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
170 let mut messages = match self.recent_turn_window {
173 Some(window) => trim_messages(&request.llm_request.messages, window),
174 None => task_messages(&request.llm_request.messages),
175 };
176 for message in &mut messages {
181 message
182 .content
183 .retain(|block| !matches!(block, ContentBlock::Reasoning { .. }));
184 }
185 messages.retain(|message| !message.content.is_empty());
186 if self.recent_turn_window.is_some() {
190 messages.push(Message::text(
191 Role::User,
192 TRAILING_ROUTING_INSTRUCTION.to_string(),
193 ));
194 }
195 messages
196 }
197}
198
199type CapabilityJudge = StructuredJudge<TaskInput, SerdeDecoder<TaskClassifierVerdict>>;
200
201struct TaskClassifierPolicy {
202 efficient_target: ModelId,
203 capable_target: ModelId,
204 base_threshold: f64,
205 threshold_step: f64,
206}
207
208impl TaskClassifierPolicy {
209 fn new(
210 efficient_target: impl Into<ModelId>,
211 capable_target: impl Into<ModelId>,
212 config: &TaskClassifierConfig,
213 ) -> Self {
214 Self {
215 efficient_target: efficient_target.into(),
216 capable_target: capable_target.into(),
217 base_threshold: config.base_threshold,
218 threshold_step: config.threshold_step,
219 }
220 }
221
222 fn threshold(&self, verdict: &TaskClassifierVerdict) -> Option<f64> {
224 Some(self.base_threshold + f64::from(verdict.boundary_steps()?) * self.threshold_step)
225 }
226}
227
228impl JudgePolicy for TaskClassifierPolicy {
229 type Verdict = TaskClassifierVerdict;
230
231 fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
232 let Some(verdict) = verdict.filter(|verdict| verdict.is_valid()) else {
235 return Classification::Ambiguous(vec![]);
236 };
237 let Some(threshold) = self.threshold(verdict) else {
240 return Classification::Ambiguous(vec![]);
241 };
242 let target = if verdict.p_solve >= threshold
243 || (threshold - verdict.p_solve).abs() <= f64::EPSILON
244 {
245 &self.efficient_target
246 } else {
247 &self.capable_target
248 };
249 Classification::Scores(vec![Score {
250 target: target.clone(),
251 confidence: 1.0,
252 }])
253 }
254}
255
256fn capability_evidence(
258 policy: &TaskClassifierPolicy,
259 verdict: Option<&TaskClassifierVerdict>,
260) -> Option<Value> {
261 let verdict = verdict?;
262 let Some(threshold) = verdict
263 .is_valid()
264 .then(|| policy.threshold(verdict))
265 .flatten()
266 else {
267 return Some(serde_json::json!({
268 "source": "fail_open",
269 "reason_code": "invalid_verdict",
270 }));
271 };
272 Some(serde_json::json!({
273 "source": "llm-classifier",
274 "score": verdict.p_solve,
275 "threshold": threshold,
276 }))
277}
278
279#[derive(Clone, Debug)]
280pub struct TaskClassifierConfig {
282 pub base_threshold: f64,
284 pub threshold_step: f64,
289 pub classify_trigger: ClassifyTrigger,
291 pub message_hash_fallback: bool,
293 pub recent_turn_window: Option<usize>,
300 pub contract: ClassifierContractConfig,
302 pub max_output_tokens: u64,
304}
305
306#[derive(Deserialize)]
308#[serde(deny_unknown_fields)]
309struct TaskClassifierConfigWire {
310 base_threshold: f64,
311 #[serde(default)]
312 threshold_step: f64,
313 #[serde(default)]
314 classify_trigger: ClassifyTrigger,
315 #[serde(default)]
316 message_hash_fallback: bool,
317 #[serde(default)]
318 recent_turn_window: Option<usize>,
319 #[serde(default)]
320 prompt: Option<String>,
321 #[serde(default)]
322 response_format_type: ClassifierResponseFormat,
323 #[serde(default = "default_judge_max_output_tokens")]
324 max_output_tokens: u64,
325}
326
327impl<'de> Deserialize<'de> for TaskClassifierConfig {
328 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
329 where
330 D: Deserializer<'de>,
331 {
332 let wire = TaskClassifierConfigWire::deserialize(deserializer)?;
333 let mut contract = ClassifierContractConfig::default();
334 if let Some(prompt) = wire.prompt {
335 contract = contract.with_prompt(prompt);
336 }
337 contract = contract.with_response_format_type(wire.response_format_type);
338 Ok(Self {
339 base_threshold: wire.base_threshold,
340 threshold_step: wire.threshold_step,
341 classify_trigger: wire.classify_trigger,
342 message_hash_fallback: wire.message_hash_fallback,
343 recent_turn_window: wire.recent_turn_window,
344 contract,
345 max_output_tokens: wire.max_output_tokens,
346 })
347 }
348}
349
350const fn default_judge_max_output_tokens() -> u64 {
351 DEFAULT_JUDGE_MAX_OUTPUT_TOKENS
352}
353
354impl Default for TaskClassifierConfig {
355 fn default() -> Self {
356 Self {
357 base_threshold: 0.0,
358 threshold_step: 0.0,
359 classify_trigger: ClassifyTrigger::default(),
360 message_hash_fallback: false,
361 recent_turn_window: None,
362 contract: ClassifierContractConfig::default(),
363 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
364 }
365 }
366}
367
368impl TaskClassifierConfig {
369 fn validate(&self) -> Result<()> {
371 if !(0.0..=1.0).contains(&self.base_threshold) {
372 return Err(LibsyError::AlgorithmError {
373 message: format!(
374 "base_threshold must be between 0 and 1, got {}",
375 self.base_threshold
376 ),
377 });
378 }
379 if !self.threshold_step.is_finite() || self.threshold_step < 0.0 {
380 return Err(LibsyError::AlgorithmError {
381 message: format!(
382 "threshold_step must be finite and greater than or equal to 0, got {}",
383 self.threshold_step
384 ),
385 });
386 }
387 let unsupported_threshold = self.base_threshold + 2.0 * self.threshold_step;
388 if unsupported_threshold > 1.0 && unsupported_threshold - 1.0 > f64::EPSILON {
389 return Err(LibsyError::AlgorithmError {
390 message: format!(
391 "base_threshold + 2 * threshold_step must be at most 1, got {unsupported_threshold}"
392 ),
393 });
394 }
395 if self.max_output_tokens == 0 {
396 return Err(LibsyError::AlgorithmError {
397 message: "max_output_tokens must be at least 1".to_string(),
398 });
399 }
400 if self.message_hash_fallback && self.classify_trigger != ClassifyTrigger::NewSession {
401 return Err(LibsyError::AlgorithmError {
402 message: "message_hash_fallback requires classify_trigger = new_session"
403 .to_string(),
404 });
405 }
406 Ok(())
407 }
408}
409
410#[derive(Clone, Debug)]
412pub enum CustomClassifierPolicy {
413 TargetSelector {
415 selector: String,
417 },
418}
419
420impl CustomClassifierPolicy {
421 pub fn target_selector(selector: impl Into<String>) -> Self {
423 Self::TargetSelector {
424 selector: selector.into(),
425 }
426 }
427}
428
429#[derive(Clone, Debug)]
431pub struct CustomClassifierConfig {
432 pub prompt: String,
434 pub response_schema: Value,
436 pub policy: CustomClassifierPolicy,
438 pub classify_trigger: ClassifyTrigger,
440 pub message_hash_fallback: bool,
442 pub recent_turn_window: Option<usize>,
444 pub max_output_tokens: u64,
446}
447
448impl CustomClassifierConfig {
449 pub fn new(
451 prompt: impl Into<String>,
452 response_schema: Value,
453 policy: CustomClassifierPolicy,
454 ) -> Self {
455 Self {
456 prompt: prompt.into(),
457 response_schema,
458 policy,
459 classify_trigger: ClassifyTrigger::default(),
460 message_hash_fallback: false,
461 recent_turn_window: None,
462 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
463 }
464 }
465
466 fn validate(&self) -> Result<()> {
467 if self.max_output_tokens == 0 {
468 return Err(LibsyError::AlgorithmError {
469 message: "max_output_tokens must be at least 1".to_string(),
470 });
471 }
472 if self.message_hash_fallback && self.classify_trigger != ClassifyTrigger::NewSession {
473 return Err(LibsyError::AlgorithmError {
474 message: "message_hash_fallback requires classify_trigger = new_session"
475 .to_string(),
476 });
477 }
478 Ok(())
479 }
480}
481
482enum CustomPolicyRuntime {
483 TargetSelector(TargetSelectorPolicy),
484}
485
486impl JudgePolicy for CustomPolicyRuntime {
487 type Verdict = Value;
488
489 fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
490 match self {
491 Self::TargetSelector(policy) => policy.to_classification(verdict),
492 }
493 }
494}
495
496struct TaskClassifier {
497 classifier: JudgeClassifier<CapabilityJudge, TaskClassifierPolicy>,
498 capable_target: ModelId,
499}
500
501fn affinity_router(
503 trigger: ClassifyTrigger,
504 message_hash_fallback: bool,
505) -> Option<Arc<AffinityRouter>> {
506 let router = match trigger {
507 ClassifyTrigger::EveryRequest => return None,
508 ClassifyTrigger::NewSession => AffinityRouter::new(),
509 ClassifyTrigger::UserTurn => AffinityRouter::new().with_release_on_user_turn(),
510 };
511 let router = if message_hash_fallback {
512 router.with_message_hash_fallback()
513 } else {
514 router
515 };
516 Some(Arc::new(router))
517}
518
519pub struct LlmTaskClassifier {
521 route: FallThrough<State>,
522 inner: Arc<dyn Classifier<State>>,
524}
525
526struct ClassifierRouteConfig {
527 default_target: ModelId,
528 classify_trigger: ClassifyTrigger,
529 message_hash_fallback: bool,
530}
531
532#[derive(Clone)]
534#[non_exhaustive]
535pub enum LlmClassifierConfig {
536 Capability {
538 judge_target: ModelId,
540 efficient_target: ModelId,
542 capable_target: ModelId,
544 config: TaskClassifierConfig,
546 },
547 Escalation {
549 judge_target: ModelId,
551 efficient_target: ModelId,
553 capable_target: ModelId,
555 contract: ClassifierContractConfig,
557 config: EscalationJudgeConfig,
559 max_output_tokens: u64,
561 gate_judge_target: Option<ModelId>,
563 },
564 Custom {
566 judge_target: ModelId,
568 targets: Vec<(String, ModelId)>,
570 default_target: String,
572 config: CustomClassifierConfig,
574 },
575}
576
577impl LlmTaskClassifier {
578 pub fn new(config: LlmClassifierConfig) -> Result<Self> {
585 match config {
586 LlmClassifierConfig::Capability {
587 judge_target,
588 efficient_target,
589 capable_target,
590 config,
591 } => Self::build_capability(judge_target, efficient_target, capable_target, config),
592 LlmClassifierConfig::Escalation {
593 judge_target,
594 efficient_target,
595 capable_target,
596 contract,
597 config,
598 max_output_tokens,
599 gate_judge_target,
600 } => Self::build_escalation(
601 gate_judge_target,
602 judge_target,
603 efficient_target,
604 capable_target,
605 contract,
606 config,
607 max_output_tokens,
608 ),
609 LlmClassifierConfig::Custom {
610 judge_target,
611 targets,
612 default_target,
613 config,
614 } => Self::build_custom(judge_target, targets, default_target, config),
615 }
616 }
617
618 fn build_capability(
619 judge_target: ModelId,
620 efficient_target: ModelId,
621 capable_target: ModelId,
622 config: TaskClassifierConfig,
623 ) -> Result<Self> {
624 config.validate()?;
625 let contract = Self::load_capability_contract(&config.contract)?;
626 let targets = vec![efficient_target.clone(), capable_target.clone()];
627 let classify_trigger = config.classify_trigger;
628 let message_hash_fallback = config.message_hash_fallback;
629 let classifier = Arc::new(TaskClassifier {
630 classifier: JudgeClassifier::new(
631 StructuredJudge::new(
632 TaskInput {
633 recent_turn_window: config.recent_turn_window,
634 },
635 contract,
636 SerdeDecoder::new(),
637 JudgeRuntimeConfig::new(config.max_output_tokens)?,
638 ),
639 judge_target.clone(),
640 TaskClassifierPolicy::new(
641 efficient_target.clone(),
642 capable_target.clone(),
643 &config,
644 ),
645 )
646 .with_evidence(capability_evidence),
647 capable_target: capable_target.clone(),
648 });
649 let inner: Arc<dyn Classifier<State>> = classifier.clone();
650 Self::from_classifier(
651 targets,
652 inner,
653 ClassifierRouteConfig {
654 default_target: classifier.capable_target.clone(),
655 classify_trigger,
656 message_hash_fallback,
657 },
658 )
659 }
660
661 fn build_custom(
662 judge_target: ModelId,
663 targets: Vec<(String, ModelId)>,
664 default_target: String,
665 config: CustomClassifierConfig,
666 ) -> Result<Self> {
667 config.validate()?;
668 if targets.len() < 2 {
669 return Err(LibsyError::AlgorithmError {
670 message: "custom classifier requires at least two targets".to_string(),
671 });
672 }
673
674 let mut labels = BTreeSet::new();
675 let mut resolved_names = BTreeSet::new();
676 let mut target_map = BTreeMap::new();
677 let mut resolved_targets = Vec::with_capacity(targets.len());
678 for (label, target) in targets {
679 if label.trim().is_empty() || label.trim() != label {
680 return Err(LibsyError::AlgorithmError {
681 message: "custom classifier target labels must be non-empty and have no surrounding whitespace"
682 .to_string(),
683 });
684 }
685 if !labels.insert(label.clone()) {
686 return Err(LibsyError::AlgorithmError {
687 message: format!("custom classifier target label {label:?} is duplicated"),
688 });
689 }
690 if !resolved_names.insert(target.clone()) {
691 return Err(LibsyError::AlgorithmError {
692 message: format!("custom classifier resolved target {target:?} is duplicated"),
693 });
694 }
695 target_map.insert(label, target.clone());
696 resolved_targets.push(target);
697 }
698 let default_name =
699 target_map
700 .get(&default_target)
701 .cloned()
702 .ok_or_else(|| LibsyError::AlgorithmError {
703 message: format!(
704 "default_target {default_target:?} must be one of the configured targets"
705 ),
706 })?;
707
708 let CustomClassifierConfig {
709 prompt,
710 response_schema,
711 policy,
712 classify_trigger,
713 message_hash_fallback,
714 recent_turn_window,
715 max_output_tokens,
716 } = config;
717 let contract = ClassifierContract::from_inner_schema(&prompt, response_schema)?;
718 let policy = match policy {
719 CustomClassifierPolicy::TargetSelector { selector } => {
720 CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(
721 selector, target_map,
722 )?)
723 }
724 };
725 let classifier: Arc<dyn Classifier<State>> = Arc::new(JudgeClassifier::new(
726 StructuredJudge::new(
727 TaskInput { recent_turn_window },
728 contract,
729 JsonSchemaDecoder::new(),
730 JudgeRuntimeConfig::new(max_output_tokens)?,
731 ),
732 judge_target,
733 policy,
734 ));
735
736 Self::from_classifier(
737 resolved_targets,
738 classifier,
739 ClassifierRouteConfig {
740 default_target: default_name,
741 classify_trigger,
742 message_hash_fallback,
743 },
744 )
745 }
746
747 fn build_escalation(
748 gate_judge_target: Option<ModelId>,
749 judge_target: ModelId,
750 efficient_target: ModelId,
751 capable_target: ModelId,
752 contract_config: ClassifierContractConfig,
753 config: EscalationJudgeConfig,
754 max_output_tokens: u64,
755 ) -> Result<Self> {
756 let inner = escalation::build_classifier(
757 gate_judge_target,
758 judge_target,
759 &efficient_target,
760 &capable_target,
761 contract_config,
762 config,
763 max_output_tokens,
764 )?;
765 let targets = vec![capable_target, efficient_target];
766 Ok(Self {
767 route: FallThrough::<State>::new_with_state(targets)
768 .with_name(ALGORITHM_NAME)
769 .with_classifier(Arc::clone(&inner)),
770 inner,
771 })
772 }
773
774 fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
776 ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
777 }
778
779 fn from_classifier(
781 targets: Vec<ModelId>,
782 inner: Arc<dyn Classifier<State>>,
783 config: ClassifierRouteConfig,
784 ) -> Result<Self> {
785 algorithm::ensure_model_is_target(&targets, &config.default_target)?;
786 if config.message_hash_fallback && config.classify_trigger != ClassifyTrigger::NewSession {
787 return Err(LibsyError::AlgorithmError {
788 message: "message_hash_fallback requires classify_trigger = new_session"
789 .to_string(),
790 });
791 }
792 let mut route = FallThrough::<State>::new_with_state(targets).with_name(ALGORITHM_NAME);
794 if let Some(affinity) =
795 affinity_router(config.classify_trigger, config.message_hash_fallback).as_ref()
796 {
797 route = route
799 .with_processor(affinity.clone())
800 .with_classifier(affinity.clone());
801 }
802 let fallback = DefaultTarget::new(config.default_target);
803 Ok(Self {
804 route: route
805 .with_classifier(inner.clone())
806 .with_classifier(Arc::new(fallback)),
807 inner,
808 })
809 }
810}
811
812pub(super) fn build_capability_gate(
817 judge_target: ModelId,
818 efficient_target: &ModelId,
819 capable_target: &ModelId,
820 gate: &EscalationGateConfig,
821 response_format_type: ClassifierResponseFormat,
822 max_output_tokens: u64,
823) -> Result<Arc<dyn Classifier<State>>> {
824 let mut contract_config =
825 ClassifierContractConfig::default().with_response_format_type(response_format_type);
826 if let Some(prompt) = &gate.prompt {
827 contract_config = contract_config.with_prompt(prompt.clone());
828 }
829 let contract =
830 ClassifierContract::from_config(&contract_config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)?;
831 let config = TaskClassifierConfig {
832 base_threshold: gate.base_threshold,
833 threshold_step: gate.threshold_step,
834 ..TaskClassifierConfig::default()
835 };
836 Ok(Arc::new(
837 JudgeClassifier::new(
838 StructuredJudge::new(
839 TaskInput {
840 recent_turn_window: None,
841 },
842 contract,
843 SerdeDecoder::new(),
844 JudgeRuntimeConfig::new(max_output_tokens)?,
845 ),
846 judge_target,
847 TaskClassifierPolicy::new(efficient_target.clone(), capable_target.clone(), &config),
848 )
849 .with_evidence(capability_evidence),
850 ))
851}
852
853#[async_trait]
854impl Classifier<State> for TaskClassifier {
855 async fn score(
856 &self,
857 state: &mut State,
858 request: &mut Request,
859 driver: Option<&Driver>,
860 ) -> Result<(Classification, Option<Response>)> {
861 self.classifier.score(state, request, driver).await
862 }
863}
864
865#[async_trait]
866impl Classifier<State> for LlmTaskClassifier {
867 async fn score(
868 &self,
869 state: &mut State,
870 request: &mut Request,
871 driver: Option<&Driver>,
872 ) -> Result<(Classification, Option<Response>)> {
873 self.inner.score(state, request, driver).await
874 }
875}
876
877#[async_trait]
878impl Algorithm for LlmTaskClassifier {
879 fn name(&self) -> &str {
880 "llm_task_classifier"
881 }
882
883 async fn route(
884 self: Arc<Self>,
885 driver: Driver,
886 request: Request,
887 ) -> Result<crate::RoutingOutcome> {
888 self.route.execute(driver, request).await
889 }
890}
891
892#[cfg(test)]
893mod tests {
894 use std::sync::Arc;
895
896 use parking_lot::Mutex;
897 use serde_json::Value;
898
899 use super::*;
900 use switchyard_protocol::{
901 ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, ToolCall, ToolResult,
902 completion_text, text_request, text_response,
903 };
904
905 use crate::algorithms::util::llm_judge::Judge;
906 use crate::core::testing::{Serve, test_drive};
907 use switchyard_protocol::{LlmResponse, Response};
908
909 const TEST_THRESHOLD: f64 = 0.5;
910
911 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
912 TaskClassifierConfig {
913 base_threshold,
914 ..TaskClassifierConfig::default()
915 }
916 }
917
918 fn policy() -> TaskClassifierPolicy {
919 TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
920 }
921
922 fn verdict(
923 p_solve: f64,
924 capability_boundary: &str,
925 primary_rule: &str,
926 ) -> TaskClassifierVerdict {
927 TaskClassifierVerdict {
928 crux: "test crux".to_string(),
929 primary_rule: primary_rule.to_string(),
930 capability_boundary: capability_boundary.to_string(),
931 p_solve,
932 }
933 }
934
935 fn selected(
936 policy: &TaskClassifierPolicy,
937 verdict: Option<&TaskClassifierVerdict>,
938 ) -> Result<ModelId> {
939 policy
940 .to_classification(verdict)
941 .argmax(false)?
942 .map(|score| score.target)
943 .ok_or_else(|| LibsyError::AlgorithmError {
944 message: "policy abstained".to_string(),
945 })
946 }
947
948 #[derive(Default)]
951 struct Recorder {
952 calls: Mutex<Vec<String>>,
953 call_roles: Mutex<Vec<(String, bool)>>,
954 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
955 judge_system_prompts: Mutex<Vec<String>>,
956 }
957
958 impl Recorder {
959 fn calls(&self) -> Vec<String> {
960 self.calls.lock().clone()
961 }
962
963 fn call_roles(&self) -> Vec<(String, bool)> {
964 self.call_roles.lock().clone()
965 }
966
967 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
968 self.judge_max_output_tokens.lock().clone()
969 }
970
971 fn judge_system_prompts(&self) -> Vec<String> {
972 self.judge_system_prompts.lock().clone()
973 }
974
975 fn serve(self: &Arc<Self>) -> impl Serve {
976 let recorder = Arc::clone(self);
977 move |model: ModelId, request: Request| {
978 let recorder = Arc::clone(&recorder);
979 async move {
980 let model = model.to_string();
981 recorder.calls.lock().push(model.clone());
982 recorder
983 .call_roles
984 .lock()
985 .push((model.clone(), model != "judge"));
986 let completion = if model == "judge" {
987 recorder
988 .judge_max_output_tokens
989 .lock()
990 .push(request.llm_request.output.max_output_tokens);
991 recorder.judge_system_prompts.lock().extend(
992 request
993 .llm_request
994 .instructions
995 .first()
996 .and_then(|instruction| {
997 instruction.content.iter().find_map(|b| {
998 if let ContentBlock::Text { text } = b {
999 Some(text.clone())
1000 } else {
1001 None
1002 }
1003 })
1004 }),
1005 );
1006 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1007 } else {
1008 format!("answer from {model}")
1009 };
1010 Ok(Response {
1011 llm_response: LlmResponse::Agg(text_response(None, completion)),
1012 metadata: request.metadata,
1013 })
1014 }
1015 }
1016 }
1017 }
1018
1019 fn unreachable_judge() -> impl Serve {
1021 |model: ModelId, request: Request| async move {
1022 let model = model.to_string();
1023 if model == "judge" {
1024 return Err(LlmClientError::Timeout {
1025 source: Box::new(std::io::Error::other("judge unreachable")),
1026 });
1027 }
1028 Ok(Response {
1029 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1030 metadata: request.metadata,
1031 })
1032 }
1033 }
1034
1035 fn router() -> Result<Arc<LlmTaskClassifier>> {
1036 Ok(Arc::new(LlmTaskClassifier::new(
1037 LlmClassifierConfig::Capability {
1038 judge_target: ModelId::from("judge"),
1039 efficient_target: ModelId::from("efficient"),
1040 capable_target: ModelId::from("capable"),
1041 config: test_config(TEST_THRESHOLD),
1042 },
1043 )?))
1044 }
1045
1046 fn classify_request() -> Request {
1047 Request {
1048 llm_request: text_request(Some("auto".to_string()), "classify this task"),
1049 raw_request: None,
1050 metadata: None,
1051 }
1052 }
1053
1054 fn classify_session_request() -> Request {
1055 Request {
1056 metadata: Some(Metadata {
1057 session_id: Some("session-1".to_string()),
1058 ..Metadata::default()
1059 }),
1060 ..classify_request()
1061 }
1062 }
1063
1064 fn classify_follow_up_request() -> Request {
1065 let mut request = classify_request();
1066 request
1067 .llm_request
1068 .messages
1069 .push(Message::text(Role::Assistant, "I will add the test."));
1070 request.llm_request.messages.push(Message::text(
1071 Role::User,
1072 "Now run the test suite and report the result.",
1073 ));
1074 request
1075 }
1076
1077 #[tokio::test]
1078 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1079 let router = router()?;
1080
1081 let (selected_model, response) =
1082 test_drive(router, classify_request(), unreachable_judge()).await?;
1083
1084 assert_eq!(selected_model, "capable");
1085 assert_eq!(
1086 response.llm_response.as_agg().map(completion_text),
1087 Some("answer from capable".to_string())
1088 );
1089 Ok(())
1090 }
1091
1092 #[tokio::test]
1093 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1094 let recorder = Arc::new(Recorder::default());
1095 let router = router()?;
1096 let request = classify_request;
1097
1098 test_drive(router.clone(), request(), recorder.serve()).await?;
1099 test_drive(router.clone(), request(), recorder.serve()).await?;
1100
1101 assert_eq!(
1102 recorder.calls(),
1103 vec!["judge", "efficient", "judge", "efficient"]
1104 );
1105 assert_eq!(
1106 recorder.call_roles(),
1107 vec![
1108 ("judge".to_string(), false),
1109 ("efficient".to_string(), true),
1110 ("judge".to_string(), false),
1111 ("efficient".to_string(), true),
1112 ]
1113 );
1114 Ok(())
1115 }
1116
1117 #[tokio::test]
1118 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1119 let recorder = Arc::new(Recorder::default());
1120 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1121 judge_target: ModelId::from("judge"),
1122 efficient_target: ModelId::from("efficient"),
1123 capable_target: ModelId::from("capable"),
1124 config: TaskClassifierConfig {
1125 max_output_tokens: 512,
1126 ..test_config(TEST_THRESHOLD)
1127 },
1128 })?);
1129
1130 test_drive(router, classify_request(), recorder.serve()).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 judge_target: ModelId::from("judge"),
1141 efficient_target: ModelId::from("efficient"),
1142 capable_target: ModelId::from("capable"),
1143 config: TaskClassifierConfig {
1144 contract: ClassifierContractConfig::default()
1145 .with_prompt("Custom capability rubric."),
1146 ..test_config(TEST_THRESHOLD)
1147 },
1148 })?);
1149
1150 test_drive(router, classify_request(), recorder.serve()).await?;
1151
1152 let prompts = recorder.judge_system_prompts();
1153 assert_eq!(prompts.len(), 1);
1154 assert_eq!(prompts[0], "Custom capability rubric.");
1155 Ok(())
1156 }
1157
1158 #[tokio::test]
1159 async fn classifier_config_enables_new_session_trigger() -> Result<()> {
1160 let recorder = Arc::new(Recorder::default());
1161 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1162 judge_target: ModelId::from("judge"),
1163 efficient_target: ModelId::from("efficient"),
1164 capable_target: ModelId::from("capable"),
1165 config: TaskClassifierConfig {
1166 classify_trigger: ClassifyTrigger::NewSession,
1167 ..test_config(TEST_THRESHOLD)
1168 },
1169 })?);
1170
1171 let session_request = classify_session_request;
1172 test_drive(router.clone(), session_request(), recorder.serve()).await?;
1173 test_drive(router.clone(), session_request(), recorder.serve()).await?;
1174
1175 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1176 Ok(())
1177 }
1178
1179 #[tokio::test]
1180 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1181 let recorder = Arc::new(Recorder::default());
1182 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1183 judge_target: ModelId::from("judge"),
1184 efficient_target: ModelId::from("efficient"),
1185 capable_target: ModelId::from("capable"),
1186 config: TaskClassifierConfig {
1187 classify_trigger: ClassifyTrigger::NewSession,
1188 message_hash_fallback: true,
1189 recent_turn_window: None,
1190 ..test_config(TEST_THRESHOLD)
1191 },
1192 })?);
1193
1194 test_drive(router.clone(), classify_request(), recorder.serve()).await?;
1195 test_drive(
1196 router.clone(),
1197 classify_follow_up_request(),
1198 recorder.serve(),
1199 )
1200 .await?;
1201
1202 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1203 Ok(())
1204 }
1205
1206 #[test]
1207 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1208 let policy = policy();
1209 let at_threshold = verdict(0.5, "supported", "SUP-1");
1210 let below_threshold = verdict(0.49, "supported", "SUP-1");
1211 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1212 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1213 Ok(())
1214 }
1215
1216 #[test]
1217 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1218 let borderline = verdict(0.5, "supported", "SUP-1");
1219 let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1220 let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1221 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1222 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1223 Ok(())
1224 }
1225
1226 #[test]
1227 fn classifier_config_rejects_unknown_fields() {
1228 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1229 "base_threshold": 0.5,
1230 "classifier_magic": true,
1231 }))
1232 .expect_err("unknown classifier fields must be rejected");
1233
1234 assert!(
1235 error
1236 .to_string()
1237 .contains("unknown field `classifier_magic`"),
1238 "{error}"
1239 );
1240 }
1241
1242 #[test]
1243 fn invalid_classifier_config_is_rejected() -> Result<()> {
1244 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1245 assert!(
1246 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1247 judge_target: ModelId::from("judge"),
1248 efficient_target: ModelId::from("e"),
1249 capable_target: ModelId::from("c"),
1250 config: test_config(bad),
1251 })
1252 .is_err(),
1253 "base threshold {bad} should be rejected"
1254 );
1255 }
1256 for config in [
1257 TaskClassifierConfig {
1258 base_threshold: 0.5,
1259 threshold_step: -0.1,
1260 ..TaskClassifierConfig::default()
1261 },
1262 TaskClassifierConfig {
1263 base_threshold: 0.8,
1264 threshold_step: 0.11,
1265 ..TaskClassifierConfig::default()
1266 },
1267 TaskClassifierConfig {
1268 base_threshold: 0.5,
1269 message_hash_fallback: true,
1270 ..TaskClassifierConfig::default()
1271 },
1272 TaskClassifierConfig {
1273 base_threshold: 0.5,
1274 max_output_tokens: 0,
1275 ..TaskClassifierConfig::default()
1276 },
1277 ] {
1278 assert!(
1279 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1280 judge_target: ModelId::from("judge"),
1281 efficient_target: ModelId::from("e"),
1282 capable_target: ModelId::from("c"),
1283 config,
1284 })
1285 .is_err()
1286 );
1287 }
1288 for base_threshold in [0.0, 1.0] {
1289 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1290 judge_target: ModelId::from("judge"),
1291 efficient_target: ModelId::from("e"),
1292 capable_target: ModelId::from("c"),
1293 config: test_config(base_threshold),
1294 })?;
1295 }
1296 Ok(())
1297 }
1298
1299 #[test]
1300 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1301 let policy = policy();
1302 let inconsistent_rule = TaskClassifierVerdict {
1303 capability_boundary: "uncertain".to_string(),
1304 ..verdict(1.0, "supported", "SUP-1")
1305 };
1306 let empty_crux = TaskClassifierVerdict {
1307 crux: " ".to_string(),
1308 ..verdict(1.0, "supported", "SUP-1")
1309 };
1310 let unusable = [
1311 Some(verdict(1.1, "supported", "SUP-1")),
1312 Some(inconsistent_rule),
1313 Some(empty_crux),
1314 None,
1315 ];
1316 for verdict in unusable {
1317 let classification = policy.to_classification(verdict.as_ref());
1318 assert!(matches!(classification, Classification::Ambiguous(_)));
1319 assert!(classification.argmax(false)?.is_none());
1320 assert!(classification.argmax(true)?.is_none());
1321 }
1322 Ok(())
1323 }
1324
1325 #[test]
1326 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1327 let policy = TaskClassifierPolicy::new(
1328 "efficient",
1329 "capable",
1330 &TaskClassifierConfig {
1331 threshold_step: 0.1,
1332 ..test_config(0.4)
1333 },
1334 );
1335
1336 assert_eq!(
1337 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1338 "efficient"
1339 );
1340 assert_eq!(
1341 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1342 "capable"
1343 );
1344 assert_eq!(
1345 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1346 "efficient"
1347 );
1348 assert_eq!(
1349 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1350 "efficient"
1351 );
1352 assert_eq!(
1353 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1354 "capable"
1355 );
1356 assert_eq!(
1357 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1358 "efficient"
1359 );
1360 Ok(())
1361 }
1362
1363 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1366 Ok(StructuredJudge::new(
1367 TaskInput { recent_turn_window },
1368 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1369 SerdeDecoder::new(),
1370 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1371 ))
1372 }
1373
1374 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1375 let judge = capability_judge(Some(recent_turn_window))?;
1376 let request = Request {
1377 llm_request: LlmRequest {
1378 messages: vec![
1379 Message::text(Role::System, "client instructions"),
1380 Message::text(Role::User, "initial task"),
1381 Message::text(Role::Assistant, "old response"),
1382 Message::text(Role::User, "old follow-up"),
1383 Message::text(Role::Assistant, "recent 1"),
1384 Message::text(Role::User, "recent 2"),
1385 ],
1386 ..LlmRequest::default()
1387 },
1388 raw_request: None,
1389 metadata: None,
1390 };
1391 Ok(judge
1392 .build_request(&State::default(), &request)
1393 .llm_request
1394 .messages
1395 .iter()
1396 .filter_map(|message| message.text_content("\n"))
1397 .collect())
1398 }
1399
1400 #[test]
1401 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1402 let contents = judged_contents(2)?;
1404 assert!(contents.contains(&"client instructions".to_string()));
1405 assert!(contents.contains(&"initial task".to_string()));
1406 assert!(contents.contains(&"recent 1".to_string()));
1407 assert!(contents.contains(&"recent 2".to_string()));
1408 assert!(!contents.contains(&"old response".to_string()));
1409 Ok(())
1410 }
1411
1412 #[test]
1413 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1414 let contents = judged_contents(0)?;
1415 assert!(contents.contains(&"client instructions".to_string()));
1416 assert!(contents.contains(&"initial task".to_string()));
1417 assert!(!contents.contains(&"recent 2".to_string()));
1418 Ok(())
1419 }
1420
1421 fn tool_call(id: &str) -> Message {
1422 Message {
1423 role: Role::Assistant,
1424 content: vec![ContentBlock::ToolCall(ToolCall {
1425 id: id.to_string(),
1426 name: "search".to_string(),
1427 arguments: Value::Null,
1428 })],
1429 }
1430 }
1431
1432 fn tool_result(id: &str) -> Message {
1433 Message {
1434 role: Role::Tool,
1435 content: vec![ContentBlock::ToolResult(ToolResult {
1436 tool_call_id: id.to_string(),
1437 content: vec![ContentBlock::Text {
1438 text: "tool output".to_string(),
1439 }],
1440 is_error: None,
1441 })],
1442 }
1443 }
1444
1445 #[test]
1448 fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1449 let messages = vec![
1450 Message::text(Role::System, "client instructions"),
1451 Message::text(Role::User, "initial task"),
1452 Message::text(Role::Assistant, "old response"),
1453 tool_call("call-1"),
1454 tool_result("call-1"),
1455 Message::text(Role::Assistant, "recent 1"),
1456 Message::text(Role::User, "recent 2"),
1457 Message::text(Role::Assistant, "recent 3"),
1458 Message::text(Role::User, "recent 4"),
1459 ];
1460
1461 let kept = trim_messages(&messages, 5);
1463
1464 assert_eq!(
1465 kept,
1466 vec![
1467 Message::text(Role::System, "client instructions"),
1468 Message::text(Role::User, "initial task"),
1469 tool_call("call-1"),
1470 tool_result("call-1"),
1471 Message::text(Role::Assistant, "recent 1"),
1472 Message::text(Role::User, "recent 2"),
1473 Message::text(Role::Assistant, "recent 3"),
1474 Message::text(Role::User, "recent 4"),
1475 ]
1476 );
1477 }
1478
1479 #[test]
1482 fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1483 let messages = vec![
1484 Message::text(Role::System, "client instructions"),
1485 Message::text(Role::User, "initial task"),
1486 tool_call("x"),
1487 tool_result("x"),
1488 Message::text(Role::Assistant, "later"),
1489 tool_call("x"),
1490 tool_result("x"),
1491 ];
1492
1493 let kept = trim_messages(&messages, 4);
1495
1496 assert_eq!(
1497 kept,
1498 vec![
1499 Message::text(Role::System, "client instructions"),
1500 Message::text(Role::User, "initial task"),
1501 tool_call("x"),
1502 tool_result("x"),
1503 Message::text(Role::Assistant, "later"),
1504 tool_call("x"),
1505 tool_result("x"),
1506 ]
1507 );
1508 }
1509
1510 #[test]
1513 fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1514 let messages = vec![
1515 Message::text(Role::System, "client instructions"),
1516 tool_call("orphan"),
1517 Message::text(Role::User, "initial task"),
1518 Message::text(Role::Assistant, "old response"),
1519 tool_result("orphan"),
1520 Message::text(Role::Assistant, "recent 1"),
1521 Message::text(Role::User, "recent 2"),
1522 ];
1523
1524 let kept = trim_messages(&messages, 3);
1525
1526 assert_eq!(
1527 kept,
1528 vec![
1529 Message::text(Role::System, "client instructions"),
1530 Message::text(Role::User, "initial task"),
1531 tool_result("orphan"),
1532 Message::text(Role::Assistant, "recent 1"),
1533 Message::text(Role::User, "recent 2"),
1534 ]
1535 );
1536 }
1537
1538 #[test]
1539 fn a_window_restates_the_routing_instruction_last() -> Result<()> {
1540 let contents = judged_contents(2)?;
1541
1542 assert_eq!(
1545 contents.last().map(String::as_str),
1546 Some(TRAILING_ROUTING_INSTRUCTION)
1547 );
1548 Ok(())
1549 }
1550
1551 #[test]
1556 fn a_window_drops_reasoning_but_keeps_visible_text_and_tool_pairs() {
1557 let messages = vec![
1558 Message::text(Role::System, "client instructions"),
1559 Message::text(Role::User, "initial task"),
1560 Message {
1561 role: Role::Assistant,
1562 content: vec![
1563 ContentBlock::Reasoning {
1564 text: "private chain of thought".to_string(),
1565 signature: None,
1566 details: Vec::new(),
1567 },
1568 ContentBlock::Text {
1569 text: "visible answer".to_string(),
1570 },
1571 ],
1572 },
1573 tool_call("call-1"),
1574 tool_result("call-1"),
1575 Message {
1576 role: Role::Assistant,
1577 content: vec![ContentBlock::Reasoning {
1578 text: "reasoning-only turn".to_string(),
1579 signature: None,
1580 details: Vec::new(),
1581 }],
1582 },
1583 Message::text(Role::User, "follow-up"),
1584 ];
1585 let request = Request {
1586 llm_request: LlmRequest {
1587 messages,
1588 ..LlmRequest::default()
1589 },
1590 raw_request: None,
1591 metadata: None,
1592 };
1593
1594 let built = TaskInput {
1595 recent_turn_window: Some(10),
1596 }
1597 .build_messages(&State::default(), &request);
1598
1599 assert!(
1600 !built
1601 .iter()
1602 .flat_map(|message| &message.content)
1603 .any(|block| matches!(block, ContentBlock::Reasoning { .. })),
1604 "{built:?}"
1605 );
1606 assert!(
1607 built
1608 .iter()
1609 .any(|message| message.text_content("\n").as_deref() == Some("visible answer"))
1610 );
1611 assert!(built.contains(&tool_call("call-1")));
1612 assert!(built.contains(&tool_result("call-1")));
1613 assert_eq!(built.len(), 7);
1616 assert!(built.iter().all(|message| !message.content.is_empty()));
1617 }
1618
1619 #[test]
1620 fn the_default_path_is_left_unchanged() -> Result<()> {
1621 let judge = capability_judge(None)?;
1624 let request = Request {
1625 llm_request: LlmRequest {
1626 messages: vec![Message::text(Role::User, "the task")],
1627 ..LlmRequest::default()
1628 },
1629 raw_request: None,
1630 metadata: None,
1631 };
1632
1633 let built = judge.build_request(&State::default(), &request);
1634
1635 assert_eq!(built.llm_request.messages.len(), 1);
1638 assert!(!built.llm_request.instructions.is_empty());
1639 assert!(
1640 !built
1641 .llm_request
1642 .messages
1643 .iter()
1644 .filter_map(|message| message.text_content("\n"))
1645 .any(|text| text.contains(TRAILING_ROUTING_INSTRUCTION))
1646 );
1647 Ok(())
1648 }
1649
1650 #[test]
1651 fn capability_judge_builds_a_structured_request() -> Result<()> {
1652 let judge = capability_judge(None)?;
1653 let request = Request {
1654 llm_request: LlmRequest {
1655 model: Some("inbound".to_string()),
1656 messages: vec![
1657 Message::text(Role::System, "client instructions"),
1658 Message::text(Role::Developer, "client developer instructions"),
1659 Message::text(Role::User, "initial task"),
1660 Message::text(Role::Assistant, "old response"),
1661 Message::text(Role::User, "old follow-up"),
1662 Message::text(Role::Assistant, "recent 1"),
1663 Message::text(Role::User, "recent 2"),
1664 Message::text(Role::Assistant, "recent 3"),
1665 Message::text(Role::User, "recent 4"),
1666 Message::text(Role::Assistant, "recent 5"),
1667 ],
1668 ..LlmRequest::default()
1669 },
1670 raw_request: None,
1671 metadata: None,
1672 };
1673 let judge_request = judge.build_request(&State::default(), &request);
1674
1675 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1676 assert_eq!(judge_request.llm_request.instructions.len(), 1);
1677 assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
1678 assert_eq!(
1679 judge_request.llm_request.instructions[0].content,
1680 InstructionBlock {
1681 role: Role::System,
1682 content: Message::text(Role::System, judge.contract().system_prompt()).content,
1683 }
1684 .content,
1685 );
1686 assert_eq!(judge_request.llm_request.messages.len(), 2);
1687 let contents = judge_request
1688 .llm_request
1689 .messages
1690 .iter()
1691 .filter_map(|message| message.text_content("\n"))
1692 .collect::<Vec<_>>();
1693 assert!(contents.contains(&"recent 4".to_string()));
1694 assert!(contents.contains(&"initial task".to_string()));
1695 assert!(!contents.contains(&"recent 5".to_string()));
1696 assert!(!contents.contains(&"client instructions".to_string()));
1697 assert_eq!(
1698 judge_request.llm_request.output.response_format,
1699 Some(judge.contract().response_format().clone())
1700 );
1701 assert_eq!(
1702 judge_request.llm_request.output.max_output_tokens,
1703 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1704 );
1705 Ok(())
1706 }
1707
1708 fn sample_value(spec: &Value) -> Value {
1709 if let Some(first) = spec
1710 .get("enum")
1711 .and_then(Value::as_array)
1712 .and_then(|values| values.first())
1713 {
1714 return first.clone();
1715 }
1716 match spec.get("type").and_then(Value::as_str) {
1717 Some("number") => serde_json::json!(0.5),
1718 Some("boolean") => serde_json::json!(false),
1719 _ => serde_json::json!("sample"),
1720 }
1721 }
1722
1723 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1724 let properties = schema
1725 .pointer("/json_schema/schema/properties")
1726 .and_then(Value::as_object)
1727 .ok_or_else(|| LibsyError::AlgorithmError {
1728 message: "packaged schema declares no properties".to_string(),
1729 })?;
1730 Ok(Value::Object(
1731 properties
1732 .iter()
1733 .map(|(name, spec)| (name.clone(), sample_value(spec)))
1734 .collect(),
1735 )
1736 .to_string())
1737 }
1738
1739 #[test]
1742 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1743 let contract =
1744 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1745 let schema = contract.response_format();
1746 let reply = schema_shaped_verdict(schema)?;
1747 let judge: CapabilityJudge = StructuredJudge::new(
1748 TaskInput {
1749 recent_turn_window: None,
1750 },
1751 contract,
1752 SerdeDecoder::new(),
1753 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1754 );
1755
1756 let verdict = judge.parse(&text_response(None, reply))?;
1757
1758 assert!(verdict.is_valid());
1759 assert!((0.0..=1.0).contains(&verdict.p_solve));
1760 Ok(())
1761 }
1762
1763 #[test]
1764 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1765 let contract =
1766 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1767 let prompt = contract.system_prompt();
1768 let schema_name = contract
1769 .response_format()
1770 .pointer("/json_schema/name")
1771 .and_then(Value::as_str)
1772 .ok_or_else(|| LibsyError::AlgorithmError {
1773 message: "packaged response schema has no name".to_string(),
1774 })?;
1775 assert_eq!(schema_name, "CapabilityClassifierDecision");
1776 assert!(prompt.contains("SUP-1 [supported]"));
1777 assert!(prompt.contains("SUP-5 [supported]"));
1778 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1779 assert!(!prompt.contains("\"type\": \"object\""));
1780 assert!(!prompt.contains("\"json_schema\""));
1781 assert!(!prompt.contains(schema_name));
1782 let rule_values = contract
1783 .response_format()
1784 .pointer("/json_schema/schema/properties/primary_rule/enum")
1785 .and_then(Value::as_array)
1786 .ok_or_else(|| LibsyError::AlgorithmError {
1787 message: "rendered response schema has no primary rule enum".to_string(),
1788 })?;
1789 assert!(
1790 rule_values
1791 .iter()
1792 .any(|value| value.as_str() == Some("SUP-1"))
1793 );
1794 assert!(
1795 rule_values
1796 .iter()
1797 .any(|value| value.as_str() == Some("none"))
1798 );
1799 Ok(())
1800 }
1801}