1use std::collections::{BTreeMap, BTreeSet};
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use serde::{Deserialize, Deserializer};
11use serde_json::Value;
12use switchyard_protocol::{Message, Role, SimpleDecision};
13
14use super::fall_through::{DefaultTarget, FallThrough};
15use super::util::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS;
16use super::util::affinity::AffinityRouter;
17use super::util::classifier_contract::{ClassifierContract, ClassifierContractConfig};
18use super::util::escalation::{self, EscalationJudge, EscalationJudgeConfig, EscalationPolicy};
19use super::util::llm_judge::{
20 ClassifierInput, JsonSchemaDecoder, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig,
21 SerdeDecoder, StructuredJudge,
22};
23use super::util::target_selector::TargetSelectorPolicy;
24use crate::core::algorithm::{Algorithm, Driver, LlmTarget, LlmTargetSet};
25use crate::core::classifier::{Classification, Classifier, Score};
26use crate::core::state::{State, StateValue};
27use crate::{LibsyError, Result};
28use switchyard_protocol::{
29 AggLlmResponse, Context, LlmResponse, Request, Response, RoutedLlmClient,
30};
31
32const PROMPT_TEMPLATE: &str = include_str!("../prompts/capability-classifier/prompt.md");
33const SCHEMA_TEMPLATE: &str = include_str!("../prompts/capability-classifier/schema.json");
34const ALGORITHM_NAME: &str = "llm_task_classifier";
36
37#[derive(Deserialize)]
38#[serde(deny_unknown_fields)]
39struct TaskClassifierVerdict {
40 #[serde(rename = "recommended_route")]
41 _recommended_route: String,
42 p_solve: f64,
43 confidence: f64,
44 abstain: bool,
45 capability_boundary: String,
46 #[serde(rename = "primary_rule")]
47 _primary_rule: String,
48 #[serde(rename = "crux")]
49 _crux: String,
50}
51
52impl TaskClassifierVerdict {
53 fn is_valid(&self) -> bool {
56 (0.0..=1.0).contains(&self.p_solve)
57 && (0.0..=1.0).contains(&self.confidence)
58 && matches!(
59 self.capability_boundary.as_str(),
60 "supported" | "uncertain" | "unsupported" | "unmatched"
61 )
62 }
63
64 fn is_capability_elevated(&self) -> bool {
66 matches!(
67 self.capability_boundary.as_str(),
68 "uncertain" | "unsupported" | "unmatched"
69 )
70 }
71}
72
73fn trim_messages(messages: &[Message], recent_turn_window: usize) -> Vec<Message> {
80 let is_instruction = |message: &Message| matches!(message.role, Role::System | Role::Developer);
81 let mut kept: Vec<&Message> = messages.iter().filter(|m| is_instruction(m)).collect();
82 let Some(task) = messages.iter().position(|m| m.role == Role::User) else {
83 return kept.into_iter().cloned().collect();
84 };
85 kept.push(&messages[task]);
86
87 let tail: Vec<&Message> = messages[task + 1..]
88 .iter()
89 .filter(|m| !is_instruction(m))
90 .collect();
91 kept.extend(&tail[tail.len().saturating_sub(recent_turn_window)..]);
92 kept.into_iter().cloned().collect()
93}
94
95struct TaskInput {
97 recent_turn_window: Option<usize>,
98}
99
100impl ClassifierInput for TaskInput {
101 fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
102 match self.recent_turn_window {
105 Some(window) => trim_messages(&request.llm_request.messages, window),
106 None => request
107 .llm_request
108 .messages
109 .iter()
110 .rev()
111 .find(|message| message.role == Role::User)
112 .cloned()
113 .into_iter()
114 .collect(),
115 }
116 }
117}
118
119type CapabilityJudge = StructuredJudge<TaskInput, SerdeDecoder<TaskClassifierVerdict>>;
120
121struct TaskClassifierPolicy {
122 efficient_target: String,
123 capable_target: String,
124 base_threshold: f64,
125 min_confidence: f64,
126 capability_elevated_floor: Option<f64>,
127}
128
129impl TaskClassifierPolicy {
130 fn new(
131 efficient_target: impl Into<String>,
132 capable_target: impl Into<String>,
133 config: &TaskClassifierConfig,
134 ) -> Self {
135 Self {
136 efficient_target: efficient_target.into(),
137 capable_target: capable_target.into(),
138 base_threshold: config.base_threshold,
139 min_confidence: config.min_confidence,
140 capability_elevated_floor: config.capability_elevated_floor,
141 }
142 }
143
144 fn threshold(&self, verdict: &TaskClassifierVerdict) -> f64 {
146 if verdict.is_capability_elevated() {
147 self.capability_elevated_floor
148 .unwrap_or(self.base_threshold)
149 } else {
150 self.base_threshold
151 }
152 }
153}
154
155impl JudgePolicy for TaskClassifierPolicy {
156 type Verdict = TaskClassifierVerdict;
157
158 fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
159 let Some(verdict) =
164 verdict.filter(|v| v.is_valid() && !v.abstain && v.confidence >= self.min_confidence)
165 else {
166 return Classification::Ambiguous(vec![]);
167 };
168 let target = if verdict.p_solve >= self.threshold(verdict) {
171 &self.efficient_target
172 } else {
173 &self.capable_target
174 };
175 Classification::Scores(vec![Score {
176 target: target.clone(),
177 confidence: 1.0,
178 }])
179 }
180}
181
182#[derive(Clone, Debug)]
183pub struct TaskClassifierConfig {
185 pub base_threshold: f64,
187 pub min_confidence: f64,
189 pub capability_elevated_floor: Option<f64>,
191 pub session_affinity: bool,
193 pub message_hash_fallback: bool,
195 pub recent_turn_window: Option<usize>,
202 pub contract: ClassifierContractConfig,
204 pub max_output_tokens: u64,
206}
207
208#[derive(Deserialize)]
210#[serde(deny_unknown_fields)]
211struct TaskClassifierConfigWire {
212 base_threshold: f64,
213 #[serde(default)]
214 min_confidence: f64,
215 #[serde(default)]
216 capability_elevated_floor: Option<f64>,
217 #[serde(default)]
218 session_affinity: bool,
219 #[serde(default)]
220 message_hash_fallback: bool,
221 #[serde(default)]
222 recent_turn_window: Option<usize>,
223 #[serde(default)]
224 prompt: Option<String>,
225 #[serde(default = "default_judge_max_output_tokens")]
226 max_output_tokens: u64,
227}
228
229impl<'de> Deserialize<'de> for TaskClassifierConfig {
230 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
231 where
232 D: Deserializer<'de>,
233 {
234 let wire = TaskClassifierConfigWire::deserialize(deserializer)?;
235 let mut contract = ClassifierContractConfig::default();
236 if let Some(prompt) = wire.prompt {
237 contract = contract.with_prompt(prompt);
238 }
239 Ok(Self {
240 base_threshold: wire.base_threshold,
241 min_confidence: wire.min_confidence,
242 capability_elevated_floor: wire.capability_elevated_floor,
243 session_affinity: wire.session_affinity,
244 message_hash_fallback: wire.message_hash_fallback,
245 recent_turn_window: wire.recent_turn_window,
246 contract,
247 max_output_tokens: wire.max_output_tokens,
248 })
249 }
250}
251
252const fn default_judge_max_output_tokens() -> u64 {
253 DEFAULT_JUDGE_MAX_OUTPUT_TOKENS
254}
255
256impl Default for TaskClassifierConfig {
257 fn default() -> Self {
258 Self {
259 base_threshold: 0.0,
260 min_confidence: 0.0,
261 capability_elevated_floor: None,
262 session_affinity: false,
263 message_hash_fallback: false,
264 recent_turn_window: None,
265 contract: ClassifierContractConfig::default(),
266 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
267 }
268 }
269}
270
271impl TaskClassifierConfig {
272 fn validate(&self) -> Result<()> {
274 if !(0.0..=1.0).contains(&self.base_threshold) {
275 return Err(LibsyError::AlgorithmError {
276 message: format!(
277 "base_threshold must be between 0 and 1, got {}",
278 self.base_threshold
279 ),
280 });
281 }
282 if !(0.0..=1.0).contains(&self.min_confidence) {
283 return Err(LibsyError::AlgorithmError {
284 message: format!(
285 "min_confidence must be between 0 and 1, got {}",
286 self.min_confidence
287 ),
288 });
289 }
290 if let Some(floor) = self.capability_elevated_floor {
291 if !(0.0..=1.0).contains(&floor) {
292 return Err(LibsyError::AlgorithmError {
293 message: format!(
294 "capability_elevated_floor must be between 0 and 1, got {floor}"
295 ),
296 });
297 }
298 if floor <= self.base_threshold {
299 return Err(LibsyError::AlgorithmError {
300 message: format!(
301 "capability_elevated_floor must be greater than base_threshold, got {floor}"
302 ),
303 });
304 }
305 }
306 if self.max_output_tokens == 0 {
307 return Err(LibsyError::AlgorithmError {
308 message: "max_output_tokens must be at least 1".to_string(),
309 });
310 }
311 if self.message_hash_fallback && !self.session_affinity {
312 return Err(LibsyError::AlgorithmError {
313 message: "message_hash_fallback requires session_affinity".to_string(),
314 });
315 }
316 Ok(())
317 }
318}
319
320#[derive(Clone, Debug)]
322pub enum CustomClassifierPolicy {
323 TargetSelector {
325 selector: String,
327 },
328}
329
330impl CustomClassifierPolicy {
331 pub fn target_selector(selector: impl Into<String>) -> Self {
333 Self::TargetSelector {
334 selector: selector.into(),
335 }
336 }
337}
338
339#[derive(Clone, Debug)]
341pub struct CustomClassifierConfig {
342 pub prompt: String,
344 pub response_schema: Value,
346 pub policy: CustomClassifierPolicy,
348 pub session_affinity: bool,
350 pub message_hash_fallback: bool,
352 pub recent_turn_window: Option<usize>,
354 pub max_output_tokens: u64,
356}
357
358impl CustomClassifierConfig {
359 pub fn new(
361 prompt: impl Into<String>,
362 response_schema: Value,
363 policy: CustomClassifierPolicy,
364 ) -> Self {
365 Self {
366 prompt: prompt.into(),
367 response_schema,
368 policy,
369 session_affinity: false,
370 message_hash_fallback: false,
371 recent_turn_window: None,
372 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
373 }
374 }
375
376 fn validate(&self) -> Result<()> {
377 if self.max_output_tokens == 0 {
378 return Err(LibsyError::AlgorithmError {
379 message: "max_output_tokens must be at least 1".to_string(),
380 });
381 }
382 if self.message_hash_fallback && !self.session_affinity {
383 return Err(LibsyError::AlgorithmError {
384 message: "message_hash_fallback requires session_affinity".to_string(),
385 });
386 }
387 Ok(())
388 }
389}
390
391enum CustomPolicyRuntime {
392 TargetSelector(TargetSelectorPolicy),
393}
394
395impl JudgePolicy for CustomPolicyRuntime {
396 type Verdict = Value;
397
398 fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
399 match self {
400 Self::TargetSelector(policy) => policy.to_classification(verdict),
401 }
402 }
403}
404
405struct TaskClassifier {
406 classifier: JudgeClassifier<CapabilityJudge, TaskClassifierPolicy>,
407 efficient_target: String,
408 capable_target: String,
409}
410
411const STREAK_KEY: &str = "escalation_streak";
415
416fn streak(state: &State) -> u32 {
417 match state.extra.get(STREAK_KEY) {
418 Some(StateValue::Count(n)) => *n,
419 _ => 0,
420 }
421}
422
423fn decisive(target: &str) -> Classification {
424 Classification::Scores(vec![Score {
425 target: target.to_string(),
426 confidence: 1.0,
427 }])
428}
429
430fn assistant_message(response: &AggLlmResponse) -> Message {
431 Message {
432 role: Role::Assistant,
433 content: response
434 .first_output()
435 .map(|output| output.content.clone())
436 .unwrap_or_default(),
437 }
438}
439
440struct EscalationClassifier {
444 judge: JudgeClassifier<EscalationJudge, EscalationPolicy>,
445 capable: LlmTarget,
446 efficient: LlmTarget,
447 confirmations: u32,
449}
450
451#[async_trait]
452impl Classifier<State> for EscalationClassifier {
453 fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
454 if self.capable.semantic_name == self.efficient.semantic_name {
455 None
456 } else if selected_model == self.capable.semantic_name {
457 Some("strong")
458 } else if selected_model == self.efficient.semantic_name {
459 Some("weak")
460 } else {
461 None
462 }
463 }
464
465 async fn score(
466 &self,
467 state: &mut State,
468 request: &mut Request,
469 driver: Option<&Driver>,
470 ) -> Result<(Classification, Option<Response>)> {
471 let Some(driver) = driver else {
472 return Err(LibsyError::AlgorithmError {
473 message: "escalation classifier requires a driver".into(),
474 });
475 };
476
477 if streak(state) >= self.confirmations {
479 return Ok((decisive(&self.capable.semantic_name), None));
480 }
481
482 let efficient_response = driver
487 .call_llm_target(
488 Context::default(),
489 &self.efficient,
490 request.clone(),
491 Arc::new(SimpleDecision {
492 selected_model: self.efficient.semantic_name.clone(),
493 reasoning: Some("escalation classifier: efficient tier".into()),
494 }),
495 )
496 .await?;
497 let agg = efficient_response
498 .llm_response
499 .into_agg()
500 .await
501 .map_err(|e| LibsyError::AlgorithmError {
502 message: format!("failed to aggregate efficient response: {e}"),
503 })?;
504 let mut judge_request = request.clone();
506 judge_request
507 .llm_request
508 .messages
509 .push(assistant_message(&agg));
510 let efficient_response = Response {
511 llm_response: if request.llm_request.stream {
512 LlmResponse::Stream(agg.into_stream())
513 } else {
514 LlmResponse::Agg(agg)
515 },
516 metadata: efficient_response.metadata,
517 };
518
519 let (classification, _) = self
520 .judge
521 .score(state, &mut judge_request, Some(driver))
522 .await?;
523
524 let held = streak(state);
525 let best = classification.argmax(false)?;
526 let (escalate, pending) = match &best {
527 Some(score) if score.target == self.capable.semantic_name => (true, held + 1),
528 Some(_) => (false, 0),
529 None => (false, held),
530 };
531 state
532 .extra
533 .insert(STREAK_KEY.to_string(), StateValue::Count(pending));
534
535 if escalate && pending >= self.confirmations {
536 return Ok((decisive(&self.capable.semantic_name), None));
538 }
539
540 Ok((
541 decisive(&self.efficient.semantic_name),
542 Some(efficient_response),
543 ))
544 }
545}
546
547pub struct LlmTaskClassifier {
549 route: FallThrough<State>,
550 inner: Arc<dyn Classifier<State>>,
552}
553
554struct ClassifierRouteConfig {
555 default_target: String,
556 session_affinity: bool,
557 message_hash_fallback: bool,
558}
559
560#[non_exhaustive]
562pub enum LlmClassifierConfig {
563 Capability {
565 judge_target: LlmTarget,
567 efficient_target: LlmTarget,
569 capable_target: LlmTarget,
571 config: TaskClassifierConfig,
573 },
574 Escalation {
576 judge_target: LlmTarget,
578 efficient_target: LlmTarget,
580 capable_target: LlmTarget,
582 contract: ClassifierContractConfig,
584 config: EscalationJudgeConfig,
586 max_output_tokens: u64,
588 },
589 Custom {
591 judge_target: LlmTarget,
593 targets: Vec<(String, LlmTarget)>,
595 default_target: String,
597 config: CustomClassifierConfig,
599 },
600}
601
602impl LlmTaskClassifier {
603 pub fn new(config: LlmClassifierConfig) -> Result<Self> {
610 match config {
611 LlmClassifierConfig::Capability {
612 judge_target,
613 efficient_target,
614 capable_target,
615 config,
616 } => Self::build_capability(judge_target, efficient_target, capable_target, config),
617 LlmClassifierConfig::Escalation {
618 judge_target,
619 efficient_target,
620 capable_target,
621 contract,
622 config,
623 max_output_tokens,
624 } => Self::build_escalation(
625 judge_target,
626 efficient_target,
627 capable_target,
628 contract,
629 config,
630 max_output_tokens,
631 ),
632 LlmClassifierConfig::Custom {
633 judge_target,
634 targets,
635 default_target,
636 config,
637 } => Self::build_custom(judge_target, targets, default_target, config),
638 }
639 }
640
641 fn build_capability(
642 judge_target: LlmTarget,
643 efficient_target: LlmTarget,
644 capable_target: LlmTarget,
645 config: TaskClassifierConfig,
646 ) -> Result<Self> {
647 config.validate()?;
648 let contract = Self::load_capability_contract(&config.contract)?;
649 let targets = LlmTargetSet::new(vec![efficient_target.clone(), capable_target.clone()]);
650 let session_affinity = config.session_affinity;
651 let message_hash_fallback = config.message_hash_fallback;
652 let classifier = Arc::new(TaskClassifier {
653 classifier: JudgeClassifier::new(
654 StructuredJudge::new(
655 TaskInput {
656 recent_turn_window: config.recent_turn_window,
657 },
658 contract,
659 SerdeDecoder::new(),
660 JudgeRuntimeConfig::new(config.max_output_tokens)?,
661 ),
662 judge_target.clone(),
663 TaskClassifierPolicy::new(
664 efficient_target.semantic_name.clone(),
665 capable_target.semantic_name.clone(),
666 &config,
667 ),
668 ),
669 efficient_target: efficient_target.semantic_name.clone(),
670 capable_target: capable_target.semantic_name.clone(),
671 });
672 let inner: Arc<dyn Classifier<State>> = classifier.clone();
673 Self::from_classifier(
674 targets,
675 inner,
676 ClassifierRouteConfig {
677 default_target: classifier.capable_target.clone(),
678 session_affinity,
679 message_hash_fallback,
680 },
681 )
682 }
683
684 fn build_custom(
685 judge_target: LlmTarget,
686 targets: Vec<(String, LlmTarget)>,
687 default_target: String,
688 config: CustomClassifierConfig,
689 ) -> Result<Self> {
690 config.validate()?;
691 if targets.len() < 2 {
692 return Err(LibsyError::AlgorithmError {
693 message: "custom classifier requires at least two targets".to_string(),
694 });
695 }
696
697 let mut labels = BTreeSet::new();
698 let mut semantic_names = BTreeSet::new();
699 let mut target_map = BTreeMap::new();
700 let mut resolved_targets = Vec::with_capacity(targets.len());
701 for (label, target) in targets {
702 if label.trim().is_empty() || label.trim() != label {
703 return Err(LibsyError::AlgorithmError {
704 message: "custom classifier target labels must be non-empty and have no surrounding whitespace"
705 .to_string(),
706 });
707 }
708 if !labels.insert(label.clone()) {
709 return Err(LibsyError::AlgorithmError {
710 message: format!("custom classifier target label {label:?} is duplicated"),
711 });
712 }
713 if !semantic_names.insert(target.semantic_name.clone()) {
714 return Err(LibsyError::AlgorithmError {
715 message: format!(
716 "custom classifier resolved target {:?} is duplicated",
717 target.semantic_name
718 ),
719 });
720 }
721 target_map.insert(label, target.semantic_name.clone());
722 resolved_targets.push(target);
723 }
724 let default_semantic_name =
725 target_map
726 .get(&default_target)
727 .cloned()
728 .ok_or_else(|| LibsyError::AlgorithmError {
729 message: format!(
730 "default_target {default_target:?} must be one of the configured targets"
731 ),
732 })?;
733
734 let CustomClassifierConfig {
735 prompt,
736 response_schema,
737 policy,
738 session_affinity,
739 message_hash_fallback,
740 recent_turn_window,
741 max_output_tokens,
742 } = config;
743 let contract = ClassifierContract::from_inner_schema(&prompt, response_schema)?;
744 let policy = match policy {
745 CustomClassifierPolicy::TargetSelector { selector } => {
746 CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(
747 selector, target_map,
748 )?)
749 }
750 };
751 let classifier: Arc<dyn Classifier<State>> = Arc::new(JudgeClassifier::new(
752 StructuredJudge::new(
753 TaskInput { recent_turn_window },
754 contract,
755 JsonSchemaDecoder::new(),
756 JudgeRuntimeConfig::new(max_output_tokens)?,
757 ),
758 judge_target,
759 policy,
760 ));
761
762 Self::from_classifier(
763 LlmTargetSet::new(resolved_targets),
764 classifier,
765 ClassifierRouteConfig {
766 default_target: default_semantic_name,
767 session_affinity,
768 message_hash_fallback,
769 },
770 )
771 }
772
773 fn build_escalation(
774 judge_target: LlmTarget,
775 efficient_target: LlmTarget,
776 capable_target: LlmTarget,
777 contract_config: ClassifierContractConfig,
778 config: EscalationJudgeConfig,
779 max_output_tokens: u64,
780 ) -> Result<Self> {
781 let capable_name = capable_target.semantic_name.clone();
782 let efficient_name = efficient_target.semantic_name.clone();
783 let confirmations = config.confirmations;
784 let esc = Arc::new(EscalationClassifier {
785 judge: escalation::build_judge(
786 judge_target,
787 capable_name,
788 efficient_name,
789 &contract_config,
790 config,
791 max_output_tokens,
792 )?,
793 capable: capable_target.clone(),
794 efficient: efficient_target.clone(),
795 confirmations,
796 });
797 let inner: Arc<dyn Classifier<State>> = esc.clone();
798 let targets = LlmTargetSet::new(vec![capable_target, efficient_target]);
799 Ok(Self {
800 route: FallThrough::<State>::new_with_state(targets)
801 .with_name(ALGORITHM_NAME)
802 .with_classifier(esc),
803 inner,
804 })
805 }
806
807 fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
809 ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
810 }
811
812 fn from_classifier(
814 targets: LlmTargetSet,
815 inner: Arc<dyn Classifier<State>>,
816 config: ClassifierRouteConfig,
817 ) -> Result<Self> {
818 targets.get_target(&config.default_target)?;
819 if config.message_hash_fallback && !config.session_affinity {
820 return Err(LibsyError::AlgorithmError {
821 message: "message_hash_fallback requires session_affinity".to_string(),
822 });
823 }
824 let mut route = FallThrough::<State>::new_with_state(targets).with_name(ALGORITHM_NAME);
828 if config.session_affinity {
829 let affinity = if config.message_hash_fallback {
830 AffinityRouter::new().with_message_hash_fallback()
831 } else {
832 AffinityRouter::new()
833 };
834 let affinity = Arc::new(affinity);
836 route = route
837 .with_processor(affinity.clone())
838 .with_classifier(affinity);
839 }
840 let fallback = DefaultTarget::new(config.default_target);
841 Ok(Self {
842 route: route
843 .with_classifier(inner.clone())
844 .with_classifier(Arc::new(fallback)),
845 inner,
846 })
847 }
848}
849
850#[async_trait]
851impl Classifier<State> for TaskClassifier {
852 fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
853 if self.efficient_target == self.capable_target {
854 None
855 } else if selected_model == self.efficient_target {
856 Some("weak")
857 } else if selected_model == self.capable_target {
858 Some("strong")
859 } else {
860 None
861 }
862 }
863
864 async fn score(
865 &self,
866 state: &mut State,
867 request: &mut Request,
868 driver: Option<&Driver>,
869 ) -> Result<(Classification, Option<Response>)> {
870 self.classifier.score(state, request, driver).await
871 }
872}
873
874#[async_trait]
875impl Classifier<State> for LlmTaskClassifier {
876 fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
877 self.inner.routing_tier(selected_model)
878 }
879
880 async fn score(
881 &self,
882 state: &mut State,
883 request: &mut Request,
884 driver: Option<&Driver>,
885 ) -> Result<(Classification, Option<Response>)> {
886 self.inner.score(state, request, driver).await
887 }
888}
889
890#[async_trait]
891impl Algorithm for LlmTaskClassifier {
892 fn name(&self) -> &str {
893 "llm_task_classifier"
894 }
895
896 fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
897 self.route.count_tokens_client()
898 }
899
900 async fn create_run_task(
901 self: Arc<Self>,
902 ctx: Context,
903 driver: Driver,
904 request: Request,
905 ) -> Result<Response> {
906 self.route.execute(ctx, driver, request).await
907 }
908}
909
910#[cfg(test)]
911mod tests {
912 use std::sync::Arc;
913
914 use parking_lot::Mutex;
915 use serde_json::Value;
916
917 use super::*;
918 use crate::text::{completion_text, text_request, text_response};
919 use switchyard_protocol::{LlmClientError, LlmRequest, Metadata};
920
921 use crate::algorithms::util::llm_judge::Judge;
922 use crate::core::algorithm::Algorithm;
923 use switchyard_protocol::{Context, LlmResponse, Response, RoutedLlmClient};
924
925 const TEST_THRESHOLD: f64 = 0.5;
926
927 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
928 TaskClassifierConfig {
929 base_threshold,
930 ..TaskClassifierConfig::default()
931 }
932 }
933
934 fn policy() -> TaskClassifierPolicy {
935 TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
936 }
937
938 fn verdict(p_solve: f64, confidence: f64, abstain: bool) -> TaskClassifierVerdict {
940 TaskClassifierVerdict {
941 _recommended_route: "efficient".to_string(),
942 p_solve,
943 confidence,
944 abstain,
945 capability_boundary: "supported".to_string(),
946 _primary_rule: "SUP-1".to_string(),
947 _crux: "test crux".to_string(),
948 }
949 }
950
951 fn selected(
952 policy: &TaskClassifierPolicy,
953 verdict: Option<&TaskClassifierVerdict>,
954 ) -> Result<String> {
955 policy
956 .to_classification(verdict)
957 .argmax(false)?
958 .map(|score| score.target)
959 .ok_or_else(|| LibsyError::AlgorithmError {
960 message: "policy abstained".to_string(),
961 })
962 }
963
964 #[derive(Default)]
965 struct PerRequestClient {
966 calls: Mutex<Vec<String>>,
967 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
968 judge_system_prompts: Mutex<Vec<String>>,
969 }
970
971 impl PerRequestClient {
972 fn calls(&self) -> Vec<String> {
973 self.calls.lock().clone()
974 }
975
976 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
977 self.judge_max_output_tokens.lock().clone()
978 }
979
980 fn judge_system_prompts(&self) -> Vec<String> {
981 self.judge_system_prompts.lock().clone()
982 }
983 }
984
985 #[async_trait]
986 impl RoutedLlmClient for PerRequestClient {
987 async fn call(
988 &self,
989 _ctx: Context,
990 request: Request,
991 decision: Arc<dyn Decision>,
992 ) -> std::result::Result<Response, LlmClientError> {
993 let model = decision.selected_model().to_string();
994 self.calls.lock().push(model.clone());
995 let completion = if model == "judge" {
996 self.judge_max_output_tokens
997 .lock()
998 .push(request.llm_request.output.max_output_tokens);
999 self.judge_system_prompts.lock().extend(
1000 request
1001 .llm_request
1002 .messages
1003 .first()
1004 .and_then(|message| message.text_content("\n")),
1005 );
1006 r#"{"recommended_route":"efficient","p_solve":0.9,"confidence":0.9,"abstain":false,"capability_boundary":"supported","primary_rule":"SUP-1","crux":"bounded task"}"#.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 struct UnreachableJudgeClient;
1018
1019 #[async_trait]
1020 impl RoutedLlmClient for UnreachableJudgeClient {
1021 async fn call(
1022 &self,
1023 _ctx: Context,
1024 request: Request,
1025 decision: Arc<dyn Decision>,
1026 ) -> std::result::Result<Response, LlmClientError> {
1027 let model = decision.selected_model().to_string();
1028 if model == "judge" {
1029 return Err(LlmClientError::Timeout {
1030 source: Box::new(std::io::Error::other("judge unreachable")),
1031 });
1032 }
1033 Ok(Response {
1034 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1035 metadata: request.metadata,
1036 })
1037 }
1038 }
1039
1040 fn router(client: Arc<dyn RoutedLlmClient>) -> Result<Arc<LlmTaskClassifier>> {
1041 let target = |name: &str| LlmTarget {
1042 semantic_name: name.to_string(),
1043 llm_client: Some(client.clone()),
1044 };
1045 Ok(Arc::new(LlmTaskClassifier::new(
1046 LlmClassifierConfig::Capability {
1047 judge_target: target("judge"),
1048 efficient_target: target("efficient"),
1049 capable_target: target("capable"),
1050 config: test_config(TEST_THRESHOLD),
1051 },
1052 )?))
1053 }
1054
1055 fn classify_request() -> Request {
1056 Request {
1057 llm_request: text_request(Some("auto".to_string()), "classify this task"),
1058 raw_request: None,
1059 metadata: None,
1060 }
1061 }
1062
1063 fn classify_session_request() -> Request {
1064 Request {
1065 metadata: Some(Metadata {
1066 session_id: Some("session-1".to_string()),
1067 ..Metadata::default()
1068 }),
1069 ..classify_request()
1070 }
1071 }
1072
1073 fn classify_follow_up_request() -> Request {
1074 let mut request = classify_request();
1075 request
1076 .llm_request
1077 .messages
1078 .push(Message::text(Role::Assistant, "I will add the test."));
1079 request.llm_request.messages.push(Message::text(
1080 Role::User,
1081 "Now run the test suite and report the result.",
1082 ));
1083 request
1084 }
1085
1086 #[tokio::test]
1087 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1088 let router = router(Arc::new(UnreachableJudgeClient))?;
1089
1090 let (trace, response) = router.run(Context::default(), classify_request()).await?;
1091
1092 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1093 assert_eq!(
1094 response.llm_response.as_agg().map(completion_text),
1095 Some("answer from capable".to_string())
1096 );
1097 Ok(())
1098 }
1099
1100 #[tokio::test]
1101 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1102 let client = Arc::new(PerRequestClient::default());
1103 let router = router(client.clone())?;
1104 let request = classify_request;
1105
1106 router.clone().run(Context::default(), request()).await?;
1107 router.clone().run(Context::default(), request()).await?;
1108
1109 assert_eq!(
1110 client.calls(),
1111 vec!["judge", "efficient", "judge", "efficient"]
1112 );
1113 Ok(())
1114 }
1115
1116 #[tokio::test]
1117 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1118 let client = Arc::new(PerRequestClient::default());
1119 let target = |name: &str| LlmTarget {
1120 semantic_name: name.to_string(),
1121 llm_client: Some(client.clone()),
1122 };
1123 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1124 judge_target: target("judge"),
1125 efficient_target: target("efficient"),
1126 capable_target: target("capable"),
1127 config: TaskClassifierConfig {
1128 max_output_tokens: 512,
1129 ..test_config(TEST_THRESHOLD)
1130 },
1131 })?);
1132
1133 router.run(Context::default(), classify_request()).await?;
1134
1135 assert_eq!(client.judge_max_output_tokens(), vec![Some(512)]);
1136 Ok(())
1137 }
1138
1139 #[tokio::test]
1140 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1141 let client = Arc::new(PerRequestClient::default());
1142 let target = |name: &str| LlmTarget {
1143 semantic_name: name.to_string(),
1144 llm_client: Some(client.clone()),
1145 };
1146 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1147 judge_target: target("judge"),
1148 efficient_target: target("efficient"),
1149 capable_target: target("capable"),
1150 config: TaskClassifierConfig {
1151 contract: ClassifierContractConfig::default()
1152 .with_prompt("Custom capability rubric:\n{{RESPONSE_SCHEMA}}"),
1153 ..test_config(TEST_THRESHOLD)
1154 },
1155 })?);
1156
1157 router.run(Context::default(), classify_request()).await?;
1158
1159 let prompts = client.judge_system_prompts();
1160 assert_eq!(prompts.len(), 1);
1161 assert!(prompts[0].starts_with("Custom capability rubric:"));
1162 assert!(prompts[0].contains("\"recommended_route\""));
1163 assert!(!prompts[0].contains("{{RESPONSE_SCHEMA}}"));
1164 Ok(())
1165 }
1166
1167 #[tokio::test]
1168 async fn classifier_config_enables_session_affinity() -> Result<()> {
1169 let client = Arc::new(PerRequestClient::default());
1170 let target = |name: &str| LlmTarget {
1171 semantic_name: name.to_string(),
1172 llm_client: Some(client.clone()),
1173 };
1174 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1175 judge_target: target("judge"),
1176 efficient_target: target("efficient"),
1177 capable_target: target("capable"),
1178 config: TaskClassifierConfig {
1179 session_affinity: true,
1180 ..test_config(TEST_THRESHOLD)
1181 },
1182 })?);
1183
1184 router
1185 .clone()
1186 .run(Context::default(), classify_session_request())
1187 .await?;
1188 router
1189 .clone()
1190 .run(Context::default(), classify_session_request())
1191 .await?;
1192
1193 assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
1194 Ok(())
1195 }
1196
1197 #[tokio::test]
1198 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1199 let client = Arc::new(PerRequestClient::default());
1200 let target = |name: &str| LlmTarget {
1201 semantic_name: name.to_string(),
1202 llm_client: Some(client.clone()),
1203 };
1204 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1205 judge_target: target("judge"),
1206 efficient_target: target("efficient"),
1207 capable_target: target("capable"),
1208 config: TaskClassifierConfig {
1209 session_affinity: true,
1210 message_hash_fallback: true,
1211 recent_turn_window: None,
1212 ..test_config(TEST_THRESHOLD)
1213 },
1214 })?);
1215
1216 router
1217 .clone()
1218 .run(Context::default(), classify_request())
1219 .await?;
1220 router
1221 .clone()
1222 .run(Context::default(), classify_follow_up_request())
1223 .await?;
1224
1225 assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
1226 Ok(())
1227 }
1228
1229 #[test]
1230 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1231 let policy = policy();
1232 let at_threshold = verdict(0.5, 0.0, false);
1233 let below_threshold = verdict(0.49, 1.0, false);
1234 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1235 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1236 Ok(())
1237 }
1238
1239 #[test]
1240 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1241 let borderline = verdict(0.5, 1.0, false);
1242 let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1243 let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1244 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1245 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1246 Ok(())
1247 }
1248
1249 #[test]
1250 fn classifier_config_rejects_unknown_fields() {
1251 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1252 "base_threshold": 0.5,
1253 "classifier_magic": true,
1254 }))
1255 .expect_err("unknown classifier fields must be rejected");
1256
1257 assert!(
1258 error
1259 .to_string()
1260 .contains("unknown field `classifier_magic`"),
1261 "{error}"
1262 );
1263 }
1264
1265 #[test]
1266 fn invalid_classifier_config_is_rejected() -> Result<()> {
1267 let target = |name: &str| LlmTarget {
1268 semantic_name: name.to_string(),
1269 llm_client: None,
1270 };
1271 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1272 assert!(
1273 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1274 judge_target: target("judge"),
1275 efficient_target: target("e"),
1276 capable_target: target("c"),
1277 config: test_config(bad),
1278 })
1279 .is_err(),
1280 "base threshold {bad} should be rejected"
1281 );
1282 }
1283 for config in [
1284 TaskClassifierConfig {
1285 base_threshold: 0.5,
1286 min_confidence: 1.1,
1287 ..TaskClassifierConfig::default()
1288 },
1289 TaskClassifierConfig {
1290 base_threshold: 0.5,
1291 capability_elevated_floor: Some(0.5),
1292 ..TaskClassifierConfig::default()
1293 },
1294 TaskClassifierConfig {
1295 base_threshold: 0.5,
1296 message_hash_fallback: true,
1297 ..TaskClassifierConfig::default()
1298 },
1299 TaskClassifierConfig {
1300 base_threshold: 0.5,
1301 max_output_tokens: 0,
1302 ..TaskClassifierConfig::default()
1303 },
1304 ] {
1305 assert!(
1306 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1307 judge_target: target("judge"),
1308 efficient_target: target("e"),
1309 capable_target: target("c"),
1310 config,
1311 })
1312 .is_err()
1313 );
1314 }
1315 for base_threshold in [0.0, 1.0] {
1316 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1317 judge_target: target("judge"),
1318 efficient_target: target("e"),
1319 capable_target: target("c"),
1320 config: test_config(base_threshold),
1321 })?;
1322 }
1323 Ok(())
1324 }
1325
1326 #[test]
1327 fn an_unusable_verdict_abstains() -> Result<()> {
1328 let policy = policy();
1332 let invalid_boundary = TaskClassifierVerdict {
1333 capability_boundary: "unknown".to_string(),
1334 ..verdict(1.0, 1.0, false)
1335 };
1336 let unusable = [
1337 Some(verdict(1.1, 1.0, false)),
1338 Some(verdict(1.0, 1.0, true)),
1339 Some(invalid_boundary),
1340 None,
1341 ];
1342 for verdict in unusable {
1343 let classification = policy.to_classification(verdict.as_ref());
1344 assert!(matches!(classification, Classification::Ambiguous(_)));
1345 assert!(classification.argmax(false)?.is_none());
1346 assert!(classification.argmax(true)?.is_none());
1347 }
1348 Ok(())
1349 }
1350
1351 #[test]
1352 fn elevated_capability_floor_is_a_targeted_safety_brake() -> Result<()> {
1353 let policy = TaskClassifierPolicy::new(
1354 "efficient",
1355 "capable",
1356 &TaskClassifierConfig {
1357 capability_elevated_floor: Some(0.45),
1358 ..test_config(0.25)
1359 },
1360 );
1361 let supported = verdict(0.30, 1.0, false);
1362 let elevated = TaskClassifierVerdict {
1363 capability_boundary: "uncertain".to_string(),
1364 ..verdict(0.30, 1.0, false)
1365 };
1366 let strong_elevated = TaskClassifierVerdict {
1367 capability_boundary: "unsupported".to_string(),
1368 ..verdict(0.50, 1.0, false)
1369 };
1370
1371 assert_eq!(selected(&policy, Some(&supported))?, "efficient");
1372 assert_eq!(selected(&policy, Some(&elevated))?, "capable");
1373 assert_eq!(selected(&policy, Some(&strong_elevated))?, "efficient");
1374 Ok(())
1375 }
1376
1377 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1380 Ok(StructuredJudge::new(
1381 TaskInput { recent_turn_window },
1382 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1383 SerdeDecoder::new(),
1384 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1385 ))
1386 }
1387
1388 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1389 let judge = capability_judge(Some(recent_turn_window))?;
1390 let request = Request {
1391 llm_request: LlmRequest {
1392 messages: vec![
1393 Message::text(Role::System, "client instructions"),
1394 Message::text(Role::User, "initial task"),
1395 Message::text(Role::Assistant, "old response"),
1396 Message::text(Role::User, "old follow-up"),
1397 Message::text(Role::Assistant, "recent 1"),
1398 Message::text(Role::User, "recent 2"),
1399 ],
1400 ..LlmRequest::default()
1401 },
1402 raw_request: None,
1403 metadata: None,
1404 };
1405 Ok(judge
1406 .build_request(&State::default(), &request)
1407 .llm_request
1408 .messages
1409 .iter()
1410 .filter_map(|message| message.text_content("\n"))
1411 .collect())
1412 }
1413
1414 #[test]
1415 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1416 let contents = judged_contents(2)?;
1418 assert!(contents.contains(&"client instructions".to_string()));
1419 assert!(contents.contains(&"initial task".to_string()));
1420 assert!(contents.contains(&"recent 1".to_string()));
1421 assert!(contents.contains(&"recent 2".to_string()));
1422 assert!(!contents.contains(&"old response".to_string()));
1423 Ok(())
1424 }
1425
1426 #[test]
1427 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1428 let contents = judged_contents(0)?;
1429 assert!(contents.contains(&"client instructions".to_string()));
1430 assert!(contents.contains(&"initial task".to_string()));
1431 assert!(!contents.contains(&"recent 2".to_string()));
1432 Ok(())
1433 }
1434
1435 #[test]
1436 fn capability_judge_builds_a_structured_request() -> Result<()> {
1437 let judge = capability_judge(None)?;
1438 let request = Request {
1439 llm_request: LlmRequest {
1440 model: Some("inbound".to_string()),
1441 messages: vec![
1442 Message::text(Role::System, "client instructions"),
1443 Message::text(Role::Developer, "client developer instructions"),
1444 Message::text(Role::User, "initial task"),
1445 Message::text(Role::Assistant, "old response"),
1446 Message::text(Role::User, "old follow-up"),
1447 Message::text(Role::Assistant, "recent 1"),
1448 Message::text(Role::User, "recent 2"),
1449 Message::text(Role::Assistant, "recent 3"),
1450 Message::text(Role::User, "recent 4"),
1451 Message::text(Role::Assistant, "recent 5"),
1452 ],
1453 ..LlmRequest::default()
1454 },
1455 raw_request: None,
1456 metadata: None,
1457 };
1458 let judge_request = judge.build_request(&State::default(), &request);
1459
1460 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1461 assert_eq!(judge_request.llm_request.messages.len(), 2);
1462 let contents = judge_request
1463 .llm_request
1464 .messages
1465 .iter()
1466 .filter_map(|message| message.text_content("\n"))
1467 .collect::<Vec<_>>();
1468 assert!(contents.contains(&"recent 4".to_string()));
1469 assert!(!contents.contains(&"initial task".to_string()));
1470 assert!(!contents.contains(&"recent 5".to_string()));
1471 assert!(!contents.contains(&"client instructions".to_string()));
1472 assert_eq!(
1473 judge_request.llm_request.output.response_format,
1474 Some(judge.contract().response_format().clone())
1475 );
1476 assert_eq!(
1477 judge_request.llm_request.output.max_output_tokens,
1478 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1479 );
1480 Ok(())
1481 }
1482
1483 fn sample_value(spec: &Value) -> Value {
1484 if let Some(first) = spec
1485 .get("enum")
1486 .and_then(Value::as_array)
1487 .and_then(|values| values.first())
1488 {
1489 return first.clone();
1490 }
1491 match spec.get("type").and_then(Value::as_str) {
1492 Some("number") => serde_json::json!(0.5),
1493 Some("boolean") => serde_json::json!(false),
1494 _ => serde_json::json!("sample"),
1495 }
1496 }
1497
1498 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1499 let properties = schema
1500 .pointer("/json_schema/schema/properties")
1501 .and_then(Value::as_object)
1502 .ok_or_else(|| LibsyError::AlgorithmError {
1503 message: "packaged schema declares no properties".to_string(),
1504 })?;
1505 Ok(Value::Object(
1506 properties
1507 .iter()
1508 .map(|(name, spec)| (name.clone(), sample_value(spec)))
1509 .collect(),
1510 )
1511 .to_string())
1512 }
1513
1514 #[test]
1517 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1518 let contract =
1519 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1520 let schema = contract.response_format();
1521 let reply = schema_shaped_verdict(schema)?;
1522 let judge: CapabilityJudge = StructuredJudge::new(
1523 TaskInput {
1524 recent_turn_window: None,
1525 },
1526 contract,
1527 SerdeDecoder::new(),
1528 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1529 );
1530
1531 let verdict = judge.parse(&text_response(None, reply))?;
1532
1533 assert!(verdict.is_valid());
1534 assert!(!verdict.abstain);
1535 Ok(())
1536 }
1537
1538 #[test]
1539 fn prompt_includes_concrete_rules_and_schema() -> Result<()> {
1540 let contract =
1541 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1542 let prompt = contract.system_prompt();
1543 assert!(prompt.contains("SUP-1 [supported]"));
1544 assert!(!prompt.contains("{{CAPABILITY_RULES}}"));
1545 assert!(!prompt.contains("{{PRIMARY_RULE_VALUES}}"));
1546 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1547 assert!(prompt.contains("\"type\": \"object\""));
1548 assert!(!prompt.contains("\"json_schema\""));
1549 assert!(!prompt.contains("\"CapabilityClassifierDecision\""));
1550 let rule_values = contract
1551 .response_format()
1552 .pointer("/json_schema/schema/properties/primary_rule/enum")
1553 .and_then(Value::as_array)
1554 .ok_or_else(|| LibsyError::AlgorithmError {
1555 message: "rendered response schema has no primary rule enum".to_string(),
1556 })?;
1557 assert!(
1558 rule_values
1559 .iter()
1560 .any(|value| value.as_str() == Some("SUP-1"))
1561 );
1562 assert!(
1563 rule_values
1564 .iter()
1565 .any(|value| value.as_str() == Some("none"))
1566 );
1567 Ok(())
1568 }
1569
1570 use std::collections::VecDeque;
1573
1574 use switchyard_protocol::LlmClientError as ClientError;
1575
1576 use switchyard_protocol::Decision;
1577
1578 struct QueuedClient {
1580 replies: Mutex<VecDeque<String>>,
1581 }
1582
1583 impl QueuedClient {
1584 fn new(replies: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
1585 Arc::new(Self {
1586 replies: Mutex::new(replies.into_iter().map(String::from).collect()),
1587 })
1588 }
1589 }
1590
1591 #[async_trait]
1592 impl RoutedLlmClient for QueuedClient {
1593 async fn call(
1594 &self,
1595 _ctx: Context,
1596 request: Request,
1597 _decision: Arc<dyn Decision>,
1598 ) -> std::result::Result<Response, ClientError> {
1599 let reply = self
1600 .replies
1601 .lock()
1602 .pop_front()
1603 .unwrap_or_else(|| "unexpected call".to_string());
1604 Ok(Response {
1605 llm_response: LlmResponse::Agg(text_response(None, reply)),
1606 metadata: request.metadata,
1607 })
1608 }
1609 }
1610
1611 fn escalation_router(
1613 client: Arc<dyn RoutedLlmClient>,
1614 judge_client: Arc<dyn RoutedLlmClient>,
1615 ) -> Result<Arc<LlmTaskClassifier>> {
1616 let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
1617 semantic_name: name.to_string(),
1618 llm_client: Some(c),
1619 };
1620 Ok(Arc::new(LlmTaskClassifier::new(
1621 LlmClassifierConfig::Escalation {
1622 judge_target: target("judge", judge_client),
1623 efficient_target: target("efficient", client.clone()),
1624 capable_target: target("capable", client),
1625 contract: ClassifierContractConfig::default(),
1626 config: EscalationJudgeConfig {
1627 confirmations: 1,
1628 ..EscalationJudgeConfig::default()
1629 },
1630 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1631 },
1632 )?))
1633 }
1634
1635 #[tokio::test]
1636 async fn escalation_router_serves_efficient_when_judge_declines() -> Result<()> {
1637 let judge_client = QueuedClient::new([r#"{"escalate":false,"reason":"progressing"}"#]);
1639 let model_client = QueuedClient::new(["efficient answer"]);
1640 let router = escalation_router(model_client, judge_client)?;
1641 let request = classify_request();
1642
1643 let (trace, response) = router.run(Context::default(), request).await?;
1644
1645 assert_eq!(trace.last().map(|d| d.selected_model()), Some("efficient"));
1647 assert_eq!(
1648 response.llm_response.as_agg().map(completion_text),
1649 Some("efficient answer".to_string())
1650 );
1651 Ok(())
1652 }
1653
1654 #[tokio::test]
1655 async fn escalation_config_overrides_the_packaged_prompt() -> Result<()> {
1656 let client = Arc::new(PerRequestClient::default());
1657 let target = |name: &str| LlmTarget {
1658 semantic_name: name.to_string(),
1659 llm_client: Some(client.clone()),
1660 };
1661 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1662 judge_target: target("judge"),
1663 efficient_target: target("efficient"),
1664 capable_target: target("capable"),
1665 contract: ClassifierContractConfig::default()
1666 .with_prompt("Custom trajectory rubric:\n{{RESPONSE_SCHEMA}}"),
1667 config: EscalationJudgeConfig {
1668 confirmations: 1,
1669 ..EscalationJudgeConfig::default()
1670 },
1671 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1672 })?);
1673
1674 router.run(Context::default(), classify_request()).await?;
1675
1676 let prompts = client.judge_system_prompts();
1677 assert_eq!(prompts.len(), 1);
1678 assert!(prompts[0].starts_with("Custom trajectory rubric:"));
1679 assert!(prompts[0].contains("\"escalate\""));
1680 assert!(!prompts[0].contains("{{RESPONSE_SCHEMA}}"));
1681 Ok(())
1682 }
1683
1684 #[tokio::test]
1685 async fn escalation_router_upgrades_to_capable_when_judge_escalates() -> Result<()> {
1686 let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck in a loop"}"#]);
1688 let model_client = QueuedClient::new(["efficient draft", "capable answer"]);
1690 let router = escalation_router(model_client, judge_client)?;
1691 let request = classify_request();
1692
1693 let (trace, response) = router.run(Context::default(), request).await?;
1694
1695 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1696 assert_eq!(
1697 response.llm_response.as_agg().map(completion_text),
1698 Some("capable answer".to_string())
1699 );
1700 Ok(())
1701 }
1702
1703 #[tokio::test]
1704 async fn escalation_router_stays_capable_after_latch() -> Result<()> {
1705 let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck"}"#]);
1708 let model_client = QueuedClient::new(["efficient draft", "capable t1", "capable t2"]);
1709 let router = escalation_router(model_client, judge_client)?;
1710
1711 let session_request = classify_session_request();
1712 router
1713 .clone()
1714 .run(Context::default(), session_request.clone())
1715 .await?;
1716 let (trace, _) = router
1717 .clone()
1718 .run(Context::default(), session_request)
1719 .await?;
1720
1721 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1722 Ok(())
1723 }
1724}