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, 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, LlmClientError, LlmResponse, Request, Response,
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 crux: String,
41 primary_rule: String,
42 capability_boundary: String,
43 p_solve: f64,
44}
45
46impl TaskClassifierVerdict {
47 fn is_valid(&self) -> bool {
49 (0.0..=1.0).contains(&self.p_solve)
50 && !self.crux.trim().is_empty()
51 && matches!(
52 (
53 self.primary_rule.as_str(),
54 self.capability_boundary.as_str()
55 ),
56 ("SUP-1" | "SUP-2" | "SUP-3" | "SUP-4" | "SUP-5", "supported")
57 | ("UNC-1" | "UNC-2", "uncertain")
58 | ("LIM-1" | "LIM-2", "unsupported")
59 | ("none", "unmatched")
60 )
61 }
62
63 fn boundary_steps(&self) -> Option<u8> {
65 match self.capability_boundary.as_str() {
66 "supported" => Some(0),
67 "uncertain" | "unmatched" => Some(1),
68 "unsupported" => Some(2),
69 _ => None,
70 }
71 }
72}
73
74fn trim_messages(messages: &[Message], recent_turn_window: usize) -> Vec<Message> {
84 let is_instruction = |message: &Message| matches!(message.role, Role::System | Role::Developer);
85 let mut kept: Vec<&Message> = messages.iter().filter(|m| is_instruction(m)).collect();
86 let Some(task) = messages.iter().position(|m| m.role == Role::User) else {
87 return kept.into_iter().cloned().collect();
88 };
89 kept.push(&messages[task]);
90
91 let tail: Vec<&Message> = messages[task + 1..]
92 .iter()
93 .filter(|m| !is_instruction(m))
94 .collect();
95 kept.extend(&tail[window_start(&tail, recent_turn_window)..]);
96 kept.into_iter().cloned().collect()
97}
98
99fn window_start(tail: &[&Message], recent_turn_window: usize) -> usize {
112 let counted = tail.len().saturating_sub(recent_turn_window);
113 if counted == tail.len() {
115 return counted;
116 }
117 let mut unpaired: HashSet<&str> = HashSet::new();
118 for (start, message) in tail.iter().enumerate().rev() {
119 for block in message.content.iter().rev() {
122 match block {
123 ContentBlock::ToolResult(result) => {
124 unpaired.insert(result.tool_call_id.as_str());
125 }
126 ContentBlock::ToolCall(call) => {
127 unpaired.remove(call.id.as_str());
128 }
129 _ => {}
130 }
131 }
132 if start <= counted && unpaired.is_empty() {
133 return start;
134 }
135 }
136 counted
137}
138
139fn task_messages(messages: &[Message]) -> Vec<Message> {
141 let mut user_messages = messages.iter().filter(|message| message.role == Role::User);
142 let Some(opening_task) = user_messages.next() else {
143 return Vec::new();
144 };
145 match user_messages.next_back() {
146 Some(latest_follow_up) => vec![opening_task.clone(), latest_follow_up.clone()],
147 None => vec![opening_task.clone()],
148 }
149}
150
151struct TaskInput {
153 recent_turn_window: Option<usize>,
154}
155
156impl ClassifierInput for TaskInput {
157 fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
158 match self.recent_turn_window {
161 Some(window) => trim_messages(&request.llm_request.messages, window),
162 None => task_messages(&request.llm_request.messages),
163 }
164 }
165}
166
167type CapabilityJudge = StructuredJudge<TaskInput, SerdeDecoder<TaskClassifierVerdict>>;
168
169struct TaskClassifierPolicy {
170 efficient_target: String,
171 capable_target: String,
172 base_threshold: f64,
173 threshold_step: f64,
174}
175
176impl TaskClassifierPolicy {
177 fn new(
178 efficient_target: impl Into<String>,
179 capable_target: impl Into<String>,
180 config: &TaskClassifierConfig,
181 ) -> Self {
182 Self {
183 efficient_target: efficient_target.into(),
184 capable_target: capable_target.into(),
185 base_threshold: config.base_threshold,
186 threshold_step: config.threshold_step,
187 }
188 }
189
190 fn threshold(&self, verdict: &TaskClassifierVerdict) -> Option<f64> {
192 Some(self.base_threshold + f64::from(verdict.boundary_steps()?) * self.threshold_step)
193 }
194}
195
196impl JudgePolicy for TaskClassifierPolicy {
197 type Verdict = TaskClassifierVerdict;
198
199 fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
200 let Some(verdict) = verdict.filter(|verdict| verdict.is_valid()) else {
203 return Classification::Ambiguous(vec![]);
204 };
205 let Some(threshold) = self.threshold(verdict) else {
208 return Classification::Ambiguous(vec![]);
209 };
210 let target = if verdict.p_solve >= threshold
211 || (threshold - verdict.p_solve).abs() <= f64::EPSILON
212 {
213 &self.efficient_target
214 } else {
215 &self.capable_target
216 };
217 Classification::Scores(vec![Score {
218 target: target.clone(),
219 confidence: 1.0,
220 }])
221 }
222}
223
224#[derive(Clone, Debug)]
225pub struct TaskClassifierConfig {
227 pub base_threshold: f64,
229 pub threshold_step: f64,
234 pub session_affinity: bool,
236 pub message_hash_fallback: bool,
238 pub recent_turn_window: Option<usize>,
245 pub contract: ClassifierContractConfig,
247 pub max_output_tokens: u64,
249}
250
251#[derive(Deserialize)]
253#[serde(deny_unknown_fields)]
254struct TaskClassifierConfigWire {
255 base_threshold: f64,
256 #[serde(default)]
257 threshold_step: f64,
258 #[serde(default)]
259 session_affinity: bool,
260 #[serde(default)]
261 message_hash_fallback: bool,
262 #[serde(default)]
263 recent_turn_window: Option<usize>,
264 #[serde(default)]
265 prompt: Option<String>,
266 #[serde(default = "default_judge_max_output_tokens")]
267 max_output_tokens: u64,
268}
269
270impl<'de> Deserialize<'de> for TaskClassifierConfig {
271 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
272 where
273 D: Deserializer<'de>,
274 {
275 let wire = TaskClassifierConfigWire::deserialize(deserializer)?;
276 let mut contract = ClassifierContractConfig::default();
277 if let Some(prompt) = wire.prompt {
278 contract = contract.with_prompt(prompt);
279 }
280 Ok(Self {
281 base_threshold: wire.base_threshold,
282 threshold_step: wire.threshold_step,
283 session_affinity: wire.session_affinity,
284 message_hash_fallback: wire.message_hash_fallback,
285 recent_turn_window: wire.recent_turn_window,
286 contract,
287 max_output_tokens: wire.max_output_tokens,
288 })
289 }
290}
291
292const fn default_judge_max_output_tokens() -> u64 {
293 DEFAULT_JUDGE_MAX_OUTPUT_TOKENS
294}
295
296impl Default for TaskClassifierConfig {
297 fn default() -> Self {
298 Self {
299 base_threshold: 0.0,
300 threshold_step: 0.0,
301 session_affinity: false,
302 message_hash_fallback: false,
303 recent_turn_window: None,
304 contract: ClassifierContractConfig::default(),
305 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
306 }
307 }
308}
309
310impl TaskClassifierConfig {
311 fn validate(&self) -> Result<()> {
313 if !(0.0..=1.0).contains(&self.base_threshold) {
314 return Err(LibsyError::AlgorithmError {
315 message: format!(
316 "base_threshold must be between 0 and 1, got {}",
317 self.base_threshold
318 ),
319 });
320 }
321 if !self.threshold_step.is_finite() || self.threshold_step < 0.0 {
322 return Err(LibsyError::AlgorithmError {
323 message: format!(
324 "threshold_step must be finite and greater than or equal to 0, got {}",
325 self.threshold_step
326 ),
327 });
328 }
329 let unsupported_threshold = self.base_threshold + 2.0 * self.threshold_step;
330 if unsupported_threshold > 1.0 && unsupported_threshold - 1.0 > f64::EPSILON {
331 return Err(LibsyError::AlgorithmError {
332 message: format!(
333 "base_threshold + 2 * threshold_step must be at most 1, got {unsupported_threshold}"
334 ),
335 });
336 }
337 if self.max_output_tokens == 0 {
338 return Err(LibsyError::AlgorithmError {
339 message: "max_output_tokens must be at least 1".to_string(),
340 });
341 }
342 if self.message_hash_fallback && !self.session_affinity {
343 return Err(LibsyError::AlgorithmError {
344 message: "message_hash_fallback requires session_affinity".to_string(),
345 });
346 }
347 Ok(())
348 }
349}
350
351#[derive(Clone, Debug)]
353pub enum CustomClassifierPolicy {
354 TargetSelector {
356 selector: String,
358 },
359}
360
361impl CustomClassifierPolicy {
362 pub fn target_selector(selector: impl Into<String>) -> Self {
364 Self::TargetSelector {
365 selector: selector.into(),
366 }
367 }
368}
369
370#[derive(Clone, Debug)]
372pub struct CustomClassifierConfig {
373 pub prompt: String,
375 pub response_schema: Value,
377 pub policy: CustomClassifierPolicy,
379 pub session_affinity: bool,
381 pub message_hash_fallback: bool,
383 pub recent_turn_window: Option<usize>,
385 pub max_output_tokens: u64,
387}
388
389impl CustomClassifierConfig {
390 pub fn new(
392 prompt: impl Into<String>,
393 response_schema: Value,
394 policy: CustomClassifierPolicy,
395 ) -> Self {
396 Self {
397 prompt: prompt.into(),
398 response_schema,
399 policy,
400 session_affinity: false,
401 message_hash_fallback: false,
402 recent_turn_window: None,
403 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
404 }
405 }
406
407 fn validate(&self) -> Result<()> {
408 if self.max_output_tokens == 0 {
409 return Err(LibsyError::AlgorithmError {
410 message: "max_output_tokens must be at least 1".to_string(),
411 });
412 }
413 if self.message_hash_fallback && !self.session_affinity {
414 return Err(LibsyError::AlgorithmError {
415 message: "message_hash_fallback requires session_affinity".to_string(),
416 });
417 }
418 Ok(())
419 }
420}
421
422enum CustomPolicyRuntime {
423 TargetSelector(TargetSelectorPolicy),
424}
425
426impl JudgePolicy for CustomPolicyRuntime {
427 type Verdict = Value;
428
429 fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
430 match self {
431 Self::TargetSelector(policy) => policy.to_classification(verdict),
432 }
433 }
434}
435
436struct TaskClassifier {
437 classifier: JudgeClassifier<CapabilityJudge, TaskClassifierPolicy>,
438 efficient_target: String,
439 capable_target: String,
440}
441
442const STREAK_KEY: &str = "escalation_streak";
446
447const RECOVERY_STREAK_KEY: &str = "escalation_recovery_streak";
449
450const RECOVERY_SPENT_KEY: &str = "escalation_recovery_spent";
452
453fn streak(state: &State) -> u32 {
454 match state.extra.get(STREAK_KEY) {
455 Some(StateValue::Count(n)) => *n,
456 _ => 0,
457 }
458}
459
460fn recovery_streak(state: &State) -> u32 {
461 match state.extra.get(RECOVERY_STREAK_KEY) {
462 Some(StateValue::Count(n)) => *n,
463 _ => 0,
464 }
465}
466
467fn recovery_spent(state: &State) -> bool {
468 matches!(
469 state.extra.get(RECOVERY_SPENT_KEY),
470 Some(StateValue::Count(n)) if *n > 0
471 )
472}
473
474fn decisive(target: &str) -> Classification {
475 Classification::Scores(vec![Score {
476 target: target.to_string(),
477 confidence: 1.0,
478 }])
479}
480
481fn assistant_message(response: &AggLlmResponse) -> Message {
482 Message {
483 role: Role::Assistant,
484 content: response
485 .first_output()
486 .map(|output| output.content.clone())
487 .unwrap_or_default(),
488 }
489}
490
491struct EscalationClassifier {
495 judge: JudgeClassifier<EscalationJudge, EscalationPolicy>,
496 recovery_judge: JudgeClassifier<EscalationJudge, EscalationPolicy>,
500 capable: LlmTarget,
501 efficient: LlmTarget,
502 confirmations: u32,
504 recovery_confirmations: u32,
506}
507
508#[async_trait]
509impl Classifier<State> for EscalationClassifier {
510 fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
511 if self.capable.semantic_name == self.efficient.semantic_name {
512 None
513 } else if selected_model == self.capable.semantic_name {
514 Some("strong")
515 } else if selected_model == self.efficient.semantic_name {
516 Some("weak")
517 } else {
518 None
519 }
520 }
521
522 async fn score(
523 &self,
524 state: &mut State,
525 request: &mut Request,
526 driver: Option<&Driver>,
527 ) -> Result<(Classification, Option<Response>)> {
528 let Some(driver) = driver else {
529 return Err(LibsyError::AlgorithmError {
530 message: "escalation classifier requires a driver".into(),
531 });
532 };
533
534 if streak(state) >= self.confirmations {
541 if self.recovery_confirmations == 0 || recovery_spent(state) {
542 return Ok((decisive(&self.capable.semantic_name), None));
543 }
544 let mut judge_request = request.clone();
547 let (classification, _) = self
548 .recovery_judge
549 .score(state, &mut judge_request, Some(driver))
550 .await?;
551 let recovered = match &classification.argmax(false)? {
552 Some(score) if score.target == self.efficient.semantic_name => {
553 recovery_streak(state) + 1
554 }
555 Some(_) => 0,
556 None => recovery_streak(state),
558 };
559 if recovered < self.recovery_confirmations {
560 state.extra.insert(
561 RECOVERY_STREAK_KEY.to_string(),
562 StateValue::Count(recovered),
563 );
564 return Ok((decisive(&self.capable.semantic_name), None));
565 }
566 tracing::info!(
570 recovery_confirmations = self.recovery_confirmations,
571 "escalation recovery: session handed back to the efficient tier"
572 );
573 state
574 .extra
575 .insert(RECOVERY_SPENT_KEY.to_string(), StateValue::Count(1));
576 state
577 .extra
578 .insert(STREAK_KEY.to_string(), StateValue::Count(0));
579 state
580 .extra
581 .insert(RECOVERY_STREAK_KEY.to_string(), StateValue::Count(0));
582 }
583
584 let efficient_response = match driver
593 .call_llm_target(
594 Context::default(),
595 &self.efficient,
596 request.clone(),
597 Arc::new(SimpleDecision {
598 selected_model: self.efficient.semantic_name.clone(),
599 reasoning: Some("escalation classifier: efficient tier".into()),
600 }),
601 )
602 .await
603 {
604 Ok(r) => r,
605 Err(LibsyError::ClientCall {
606 source: LlmClientError::ContextWindowExceeded { .. },
607 ..
608 }) => {
609 if recovery_spent(state) {
614 tracing::info!(
615 "escalation recovery: efficient tier overflowed after hand-back, \
616 re-latching permanently"
617 );
618 state.extra.insert(
619 STREAK_KEY.to_string(),
620 StateValue::Count(self.confirmations),
621 );
622 }
623 return Ok((decisive(&self.capable.semantic_name), None));
624 }
625 Err(e) => return Err(e),
626 };
627 let agg = efficient_response
628 .llm_response
629 .into_agg()
630 .await
631 .map_err(|e| LibsyError::AlgorithmError {
632 message: format!("failed to aggregate efficient response: {e}"),
633 })?;
634 let mut judge_request = request.clone();
636 judge_request
637 .llm_request
638 .messages
639 .push(assistant_message(&agg));
640 let efficient_response = Response {
641 llm_response: if request.llm_request.stream {
642 LlmResponse::Stream(agg.into_stream())
643 } else {
644 LlmResponse::Agg(agg)
645 },
646 metadata: efficient_response.metadata,
647 };
648
649 let (classification, _) = self
650 .judge
651 .score(state, &mut judge_request, Some(driver))
652 .await?;
653
654 let held = streak(state);
655 let best = classification.argmax(false)?;
656 let (escalate, pending) = match &best {
657 Some(score) if score.target == self.capable.semantic_name => (true, held + 1),
658 Some(_) => (false, 0),
659 None => (false, held),
660 };
661 state
662 .extra
663 .insert(STREAK_KEY.to_string(), StateValue::Count(pending));
664
665 let required = if recovery_spent(state) {
669 1
670 } else {
671 self.confirmations
672 };
673 if escalate && pending >= required {
674 if required < self.confirmations {
675 tracing::info!("escalation recovery: probation re-latch, latch is now permanent");
676 }
677 state.extra.insert(
680 STREAK_KEY.to_string(),
681 StateValue::Count(self.confirmations),
682 );
683 return Ok((decisive(&self.capable.semantic_name), None));
684 }
685
686 Ok((
687 decisive(&self.efficient.semantic_name),
688 Some(efficient_response),
689 ))
690 }
691}
692
693pub struct LlmTaskClassifier {
695 route: FallThrough<State>,
696 inner: Arc<dyn Classifier<State>>,
698}
699
700struct ClassifierRouteConfig {
701 default_target: String,
702 session_affinity: bool,
703 message_hash_fallback: bool,
704}
705
706#[non_exhaustive]
708pub enum LlmClassifierConfig {
709 Capability {
711 judge_target: LlmTarget,
713 efficient_target: LlmTarget,
715 capable_target: LlmTarget,
717 config: TaskClassifierConfig,
719 },
720 Escalation {
722 judge_target: LlmTarget,
724 efficient_target: LlmTarget,
726 capable_target: LlmTarget,
728 contract: ClassifierContractConfig,
730 config: EscalationJudgeConfig,
732 max_output_tokens: u64,
734 },
735 Custom {
737 judge_target: LlmTarget,
739 targets: Vec<(String, LlmTarget)>,
741 default_target: String,
743 config: CustomClassifierConfig,
745 },
746}
747
748impl LlmTaskClassifier {
749 pub fn new(config: LlmClassifierConfig) -> Result<Self> {
756 match config {
757 LlmClassifierConfig::Capability {
758 judge_target,
759 efficient_target,
760 capable_target,
761 config,
762 } => Self::build_capability(judge_target, efficient_target, capable_target, config),
763 LlmClassifierConfig::Escalation {
764 judge_target,
765 efficient_target,
766 capable_target,
767 contract,
768 config,
769 max_output_tokens,
770 } => Self::build_escalation(
771 judge_target,
772 efficient_target,
773 capable_target,
774 contract,
775 config,
776 max_output_tokens,
777 ),
778 LlmClassifierConfig::Custom {
779 judge_target,
780 targets,
781 default_target,
782 config,
783 } => Self::build_custom(judge_target, targets, default_target, config),
784 }
785 }
786
787 fn build_capability(
788 judge_target: LlmTarget,
789 efficient_target: LlmTarget,
790 capable_target: LlmTarget,
791 config: TaskClassifierConfig,
792 ) -> Result<Self> {
793 config.validate()?;
794 let contract = Self::load_capability_contract(&config.contract)?;
795 let targets = LlmTargetSet::new(vec![efficient_target.clone(), capable_target.clone()]);
796 let session_affinity = config.session_affinity;
797 let message_hash_fallback = config.message_hash_fallback;
798 let classifier = Arc::new(TaskClassifier {
799 classifier: JudgeClassifier::new(
800 StructuredJudge::new(
801 TaskInput {
802 recent_turn_window: config.recent_turn_window,
803 },
804 contract,
805 SerdeDecoder::new(),
806 JudgeRuntimeConfig::new(config.max_output_tokens)?,
807 ),
808 judge_target.clone(),
809 TaskClassifierPolicy::new(
810 efficient_target.semantic_name.clone(),
811 capable_target.semantic_name.clone(),
812 &config,
813 ),
814 ),
815 efficient_target: efficient_target.semantic_name.clone(),
816 capable_target: capable_target.semantic_name.clone(),
817 });
818 let inner: Arc<dyn Classifier<State>> = classifier.clone();
819 Self::from_classifier(
820 targets,
821 inner,
822 ClassifierRouteConfig {
823 default_target: classifier.capable_target.clone(),
824 session_affinity,
825 message_hash_fallback,
826 },
827 )
828 }
829
830 fn build_custom(
831 judge_target: LlmTarget,
832 targets: Vec<(String, LlmTarget)>,
833 default_target: String,
834 config: CustomClassifierConfig,
835 ) -> Result<Self> {
836 config.validate()?;
837 if targets.len() < 2 {
838 return Err(LibsyError::AlgorithmError {
839 message: "custom classifier requires at least two targets".to_string(),
840 });
841 }
842
843 let mut labels = BTreeSet::new();
844 let mut semantic_names = BTreeSet::new();
845 let mut target_map = BTreeMap::new();
846 let mut resolved_targets = Vec::with_capacity(targets.len());
847 for (label, target) in targets {
848 if label.trim().is_empty() || label.trim() != label {
849 return Err(LibsyError::AlgorithmError {
850 message: "custom classifier target labels must be non-empty and have no surrounding whitespace"
851 .to_string(),
852 });
853 }
854 if !labels.insert(label.clone()) {
855 return Err(LibsyError::AlgorithmError {
856 message: format!("custom classifier target label {label:?} is duplicated"),
857 });
858 }
859 if !semantic_names.insert(target.semantic_name.clone()) {
860 return Err(LibsyError::AlgorithmError {
861 message: format!(
862 "custom classifier resolved target {:?} is duplicated",
863 target.semantic_name
864 ),
865 });
866 }
867 target_map.insert(label, target.semantic_name.clone());
868 resolved_targets.push(target);
869 }
870 let default_semantic_name =
871 target_map
872 .get(&default_target)
873 .cloned()
874 .ok_or_else(|| LibsyError::AlgorithmError {
875 message: format!(
876 "default_target {default_target:?} must be one of the configured targets"
877 ),
878 })?;
879
880 let CustomClassifierConfig {
881 prompt,
882 response_schema,
883 policy,
884 session_affinity,
885 message_hash_fallback,
886 recent_turn_window,
887 max_output_tokens,
888 } = config;
889 let contract = ClassifierContract::from_inner_schema(&prompt, response_schema)?;
890 let policy = match policy {
891 CustomClassifierPolicy::TargetSelector { selector } => {
892 CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(
893 selector, target_map,
894 )?)
895 }
896 };
897 let classifier: Arc<dyn Classifier<State>> = Arc::new(JudgeClassifier::new(
898 StructuredJudge::new(
899 TaskInput { recent_turn_window },
900 contract,
901 JsonSchemaDecoder::new(),
902 JudgeRuntimeConfig::new(max_output_tokens)?,
903 ),
904 judge_target,
905 policy,
906 ));
907
908 Self::from_classifier(
909 LlmTargetSet::new(resolved_targets),
910 classifier,
911 ClassifierRouteConfig {
912 default_target: default_semantic_name,
913 session_affinity,
914 message_hash_fallback,
915 },
916 )
917 }
918
919 fn build_escalation(
920 judge_target: LlmTarget,
921 efficient_target: LlmTarget,
922 capable_target: LlmTarget,
923 contract_config: ClassifierContractConfig,
924 config: EscalationJudgeConfig,
925 max_output_tokens: u64,
926 ) -> Result<Self> {
927 let capable_name = capable_target.semantic_name.clone();
928 let efficient_name = efficient_target.semantic_name.clone();
929 let confirmations = config.confirmations;
930 let recovery_confirmations = config.recovery_confirmations;
931 let esc = Arc::new(EscalationClassifier {
932 judge: escalation::build_judge(
933 judge_target.clone(),
934 capable_name.clone(),
935 efficient_name.clone(),
936 &contract_config,
937 config.clone(),
938 max_output_tokens,
939 )?,
940 recovery_judge: escalation::build_recovery_judge(
941 judge_target,
942 capable_name,
943 efficient_name,
944 config,
945 max_output_tokens,
946 )?,
947 capable: capable_target.clone(),
948 efficient: efficient_target.clone(),
949 confirmations,
950 recovery_confirmations,
951 });
952 let inner: Arc<dyn Classifier<State>> = esc.clone();
953 let targets = LlmTargetSet::new(vec![capable_target, efficient_target]);
954 Ok(Self {
955 route: FallThrough::<State>::new_with_state(targets)
956 .with_name(ALGORITHM_NAME)
957 .with_classifier(esc),
958 inner,
959 })
960 }
961
962 fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
964 ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
965 }
966
967 fn from_classifier(
969 targets: LlmTargetSet,
970 inner: Arc<dyn Classifier<State>>,
971 config: ClassifierRouteConfig,
972 ) -> Result<Self> {
973 targets.get_target(&config.default_target)?;
974 if config.message_hash_fallback && !config.session_affinity {
975 return Err(LibsyError::AlgorithmError {
976 message: "message_hash_fallback requires session_affinity".to_string(),
977 });
978 }
979 let mut route = FallThrough::<State>::new_with_state(targets).with_name(ALGORITHM_NAME);
983 if config.session_affinity {
984 let affinity = if config.message_hash_fallback {
985 AffinityRouter::new().with_message_hash_fallback()
986 } else {
987 AffinityRouter::new()
988 };
989 let affinity = Arc::new(affinity);
991 route = route
992 .with_processor(affinity.clone())
993 .with_classifier(affinity);
994 }
995 let fallback = DefaultTarget::new(config.default_target);
996 Ok(Self {
997 route: route
998 .with_classifier(inner.clone())
999 .with_classifier(Arc::new(fallback)),
1000 inner,
1001 })
1002 }
1003}
1004
1005#[async_trait]
1006impl Classifier<State> for TaskClassifier {
1007 fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
1008 if self.efficient_target == self.capable_target {
1009 None
1010 } else if selected_model == self.efficient_target {
1011 Some("weak")
1012 } else if selected_model == self.capable_target {
1013 Some("strong")
1014 } else {
1015 None
1016 }
1017 }
1018
1019 async fn score(
1020 &self,
1021 state: &mut State,
1022 request: &mut Request,
1023 driver: Option<&Driver>,
1024 ) -> Result<(Classification, Option<Response>)> {
1025 self.classifier.score(state, request, driver).await
1026 }
1027}
1028
1029#[async_trait]
1030impl Classifier<State> for LlmTaskClassifier {
1031 fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
1032 self.inner.routing_tier(selected_model)
1033 }
1034
1035 async fn score(
1036 &self,
1037 state: &mut State,
1038 request: &mut Request,
1039 driver: Option<&Driver>,
1040 ) -> Result<(Classification, Option<Response>)> {
1041 self.inner.score(state, request, driver).await
1042 }
1043}
1044
1045#[async_trait]
1046impl Algorithm for LlmTaskClassifier {
1047 fn name(&self) -> &str {
1048 "llm_task_classifier"
1049 }
1050
1051 async fn create_run_task(
1052 self: Arc<Self>,
1053 ctx: Context,
1054 driver: Driver,
1055 request: Request,
1056 ) -> Result<Response> {
1057 self.route.execute(ctx, driver, request).await
1058 }
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063 use std::sync::Arc;
1064
1065 use parking_lot::Mutex;
1066 use serde_json::Value;
1067
1068 use super::*;
1069 use switchyard_protocol::{
1070 ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, ToolCall, ToolResult,
1071 completion_text, text_request, text_response,
1072 };
1073
1074 use crate::algorithms::util::llm_judge::Judge;
1075 use crate::core::algorithm::Algorithm;
1076 use switchyard_protocol::{Context, LlmResponse, Response, RoutedLlmClient};
1077
1078 const TEST_THRESHOLD: f64 = 0.5;
1079
1080 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
1081 TaskClassifierConfig {
1082 base_threshold,
1083 ..TaskClassifierConfig::default()
1084 }
1085 }
1086
1087 fn policy() -> TaskClassifierPolicy {
1088 TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
1089 }
1090
1091 fn verdict(
1092 p_solve: f64,
1093 capability_boundary: &str,
1094 primary_rule: &str,
1095 ) -> TaskClassifierVerdict {
1096 TaskClassifierVerdict {
1097 crux: "test crux".to_string(),
1098 primary_rule: primary_rule.to_string(),
1099 capability_boundary: capability_boundary.to_string(),
1100 p_solve,
1101 }
1102 }
1103
1104 fn selected(
1105 policy: &TaskClassifierPolicy,
1106 verdict: Option<&TaskClassifierVerdict>,
1107 ) -> Result<String> {
1108 policy
1109 .to_classification(verdict)
1110 .argmax(false)?
1111 .map(|score| score.target)
1112 .ok_or_else(|| LibsyError::AlgorithmError {
1113 message: "policy abstained".to_string(),
1114 })
1115 }
1116
1117 #[derive(Default)]
1118 struct PerRequestClient {
1119 calls: Mutex<Vec<String>>,
1120 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
1121 judge_system_prompts: Mutex<Vec<String>>,
1122 }
1123
1124 impl PerRequestClient {
1125 fn calls(&self) -> Vec<String> {
1126 self.calls.lock().clone()
1127 }
1128
1129 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
1130 self.judge_max_output_tokens.lock().clone()
1131 }
1132
1133 fn judge_system_prompts(&self) -> Vec<String> {
1134 self.judge_system_prompts.lock().clone()
1135 }
1136 }
1137
1138 #[async_trait]
1139 impl RoutedLlmClient for PerRequestClient {
1140 async fn call(
1141 &self,
1142 _ctx: Context,
1143 request: Request,
1144 decision: Arc<dyn Decision>,
1145 ) -> std::result::Result<Response, LlmClientError> {
1146 let model = decision.selected_model().to_string();
1147 self.calls.lock().push(model.clone());
1148 let completion = if model == "judge" {
1149 self.judge_max_output_tokens
1150 .lock()
1151 .push(request.llm_request.output.max_output_tokens);
1152 self.judge_system_prompts.lock().extend(
1153 request
1154 .llm_request
1155 .instructions
1156 .first()
1157 .and_then(|instruction| {
1158 instruction.content.iter().find_map(|b| {
1159 if let ContentBlock::Text { text } = b {
1160 Some(text.clone())
1161 } else {
1162 None
1163 }
1164 })
1165 }),
1166 );
1167 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1168 } else {
1169 format!("answer from {model}")
1170 };
1171 Ok(Response {
1172 llm_response: LlmResponse::Agg(text_response(None, completion)),
1173 metadata: request.metadata,
1174 })
1175 }
1176 }
1177
1178 struct UnreachableJudgeClient;
1179
1180 #[async_trait]
1181 impl RoutedLlmClient for UnreachableJudgeClient {
1182 async fn call(
1183 &self,
1184 _ctx: Context,
1185 request: Request,
1186 decision: Arc<dyn Decision>,
1187 ) -> std::result::Result<Response, LlmClientError> {
1188 let model = decision.selected_model().to_string();
1189 if model == "judge" {
1190 return Err(LlmClientError::Timeout {
1191 source: Box::new(std::io::Error::other("judge unreachable")),
1192 });
1193 }
1194 Ok(Response {
1195 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1196 metadata: request.metadata,
1197 })
1198 }
1199 }
1200
1201 fn router(client: Arc<dyn RoutedLlmClient>) -> Result<Arc<LlmTaskClassifier>> {
1202 let target = |name: &str| LlmTarget {
1203 semantic_name: name.to_string(),
1204 llm_client: Some(client.clone()),
1205 };
1206 Ok(Arc::new(LlmTaskClassifier::new(
1207 LlmClassifierConfig::Capability {
1208 judge_target: target("judge"),
1209 efficient_target: target("efficient"),
1210 capable_target: target("capable"),
1211 config: test_config(TEST_THRESHOLD),
1212 },
1213 )?))
1214 }
1215
1216 fn classify_request() -> Request {
1217 Request {
1218 llm_request: text_request(Some("auto".to_string()), "classify this task"),
1219 raw_request: None,
1220 metadata: None,
1221 }
1222 }
1223
1224 fn classify_session_request() -> Request {
1225 Request {
1226 metadata: Some(Metadata {
1227 session_id: Some("session-1".to_string()),
1228 ..Metadata::default()
1229 }),
1230 ..classify_request()
1231 }
1232 }
1233
1234 fn classify_follow_up_request() -> Request {
1235 let mut request = classify_request();
1236 request
1237 .llm_request
1238 .messages
1239 .push(Message::text(Role::Assistant, "I will add the test."));
1240 request.llm_request.messages.push(Message::text(
1241 Role::User,
1242 "Now run the test suite and report the result.",
1243 ));
1244 request
1245 }
1246
1247 #[tokio::test]
1248 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1249 let router = router(Arc::new(UnreachableJudgeClient))?;
1250
1251 let (trace, response) = router.run(Context::default(), classify_request()).await?;
1252
1253 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1254 assert_eq!(
1255 response.llm_response.as_agg().map(completion_text),
1256 Some("answer from capable".to_string())
1257 );
1258 Ok(())
1259 }
1260
1261 #[tokio::test]
1262 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1263 let client = Arc::new(PerRequestClient::default());
1264 let router = router(client.clone())?;
1265 let request = classify_request;
1266
1267 router.clone().run(Context::default(), request()).await?;
1268 router.clone().run(Context::default(), request()).await?;
1269
1270 assert_eq!(
1271 client.calls(),
1272 vec!["judge", "efficient", "judge", "efficient"]
1273 );
1274 Ok(())
1275 }
1276
1277 #[tokio::test]
1278 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1279 let client = Arc::new(PerRequestClient::default());
1280 let target = |name: &str| LlmTarget {
1281 semantic_name: name.to_string(),
1282 llm_client: Some(client.clone()),
1283 };
1284 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1285 judge_target: target("judge"),
1286 efficient_target: target("efficient"),
1287 capable_target: target("capable"),
1288 config: TaskClassifierConfig {
1289 max_output_tokens: 512,
1290 ..test_config(TEST_THRESHOLD)
1291 },
1292 })?);
1293
1294 router.run(Context::default(), classify_request()).await?;
1295
1296 assert_eq!(client.judge_max_output_tokens(), vec![Some(512)]);
1297 Ok(())
1298 }
1299
1300 #[tokio::test]
1301 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1302 let client = Arc::new(PerRequestClient::default());
1303 let target = |name: &str| LlmTarget {
1304 semantic_name: name.to_string(),
1305 llm_client: Some(client.clone()),
1306 };
1307 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1308 judge_target: target("judge"),
1309 efficient_target: target("efficient"),
1310 capable_target: target("capable"),
1311 config: TaskClassifierConfig {
1312 contract: ClassifierContractConfig::default()
1313 .with_prompt("Custom capability rubric."),
1314 ..test_config(TEST_THRESHOLD)
1315 },
1316 })?);
1317
1318 router.run(Context::default(), classify_request()).await?;
1319
1320 let prompts = client.judge_system_prompts();
1321 assert_eq!(prompts.len(), 1);
1322 assert_eq!(prompts[0], "Custom capability rubric.");
1323 Ok(())
1324 }
1325
1326 #[tokio::test]
1327 async fn classifier_config_enables_session_affinity() -> Result<()> {
1328 let client = Arc::new(PerRequestClient::default());
1329 let target = |name: &str| LlmTarget {
1330 semantic_name: name.to_string(),
1331 llm_client: Some(client.clone()),
1332 };
1333 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1334 judge_target: target("judge"),
1335 efficient_target: target("efficient"),
1336 capable_target: target("capable"),
1337 config: TaskClassifierConfig {
1338 session_affinity: true,
1339 ..test_config(TEST_THRESHOLD)
1340 },
1341 })?);
1342
1343 router
1344 .clone()
1345 .run(Context::default(), classify_session_request())
1346 .await?;
1347 router
1348 .clone()
1349 .run(Context::default(), classify_session_request())
1350 .await?;
1351
1352 assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
1353 Ok(())
1354 }
1355
1356 #[tokio::test]
1357 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1358 let client = Arc::new(PerRequestClient::default());
1359 let target = |name: &str| LlmTarget {
1360 semantic_name: name.to_string(),
1361 llm_client: Some(client.clone()),
1362 };
1363 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1364 judge_target: target("judge"),
1365 efficient_target: target("efficient"),
1366 capable_target: target("capable"),
1367 config: TaskClassifierConfig {
1368 session_affinity: true,
1369 message_hash_fallback: true,
1370 recent_turn_window: None,
1371 ..test_config(TEST_THRESHOLD)
1372 },
1373 })?);
1374
1375 router
1376 .clone()
1377 .run(Context::default(), classify_request())
1378 .await?;
1379 router
1380 .clone()
1381 .run(Context::default(), classify_follow_up_request())
1382 .await?;
1383
1384 assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
1385 Ok(())
1386 }
1387
1388 #[test]
1389 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1390 let policy = policy();
1391 let at_threshold = verdict(0.5, "supported", "SUP-1");
1392 let below_threshold = verdict(0.49, "supported", "SUP-1");
1393 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1394 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1395 Ok(())
1396 }
1397
1398 #[test]
1399 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1400 let borderline = verdict(0.5, "supported", "SUP-1");
1401 let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1402 let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1403 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1404 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1405 Ok(())
1406 }
1407
1408 #[test]
1409 fn classifier_config_rejects_unknown_fields() {
1410 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1411 "base_threshold": 0.5,
1412 "classifier_magic": true,
1413 }))
1414 .expect_err("unknown classifier fields must be rejected");
1415
1416 assert!(
1417 error
1418 .to_string()
1419 .contains("unknown field `classifier_magic`"),
1420 "{error}"
1421 );
1422 }
1423
1424 #[test]
1425 fn invalid_classifier_config_is_rejected() -> Result<()> {
1426 let target = |name: &str| LlmTarget {
1427 semantic_name: name.to_string(),
1428 llm_client: None,
1429 };
1430 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1431 assert!(
1432 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1433 judge_target: target("judge"),
1434 efficient_target: target("e"),
1435 capable_target: target("c"),
1436 config: test_config(bad),
1437 })
1438 .is_err(),
1439 "base threshold {bad} should be rejected"
1440 );
1441 }
1442 for config in [
1443 TaskClassifierConfig {
1444 base_threshold: 0.5,
1445 threshold_step: -0.1,
1446 ..TaskClassifierConfig::default()
1447 },
1448 TaskClassifierConfig {
1449 base_threshold: 0.8,
1450 threshold_step: 0.11,
1451 ..TaskClassifierConfig::default()
1452 },
1453 TaskClassifierConfig {
1454 base_threshold: 0.5,
1455 message_hash_fallback: true,
1456 ..TaskClassifierConfig::default()
1457 },
1458 TaskClassifierConfig {
1459 base_threshold: 0.5,
1460 max_output_tokens: 0,
1461 ..TaskClassifierConfig::default()
1462 },
1463 ] {
1464 assert!(
1465 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1466 judge_target: target("judge"),
1467 efficient_target: target("e"),
1468 capable_target: target("c"),
1469 config,
1470 })
1471 .is_err()
1472 );
1473 }
1474 for base_threshold in [0.0, 1.0] {
1475 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1476 judge_target: target("judge"),
1477 efficient_target: target("e"),
1478 capable_target: target("c"),
1479 config: test_config(base_threshold),
1480 })?;
1481 }
1482 Ok(())
1483 }
1484
1485 #[test]
1486 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1487 let policy = policy();
1488 let inconsistent_rule = TaskClassifierVerdict {
1489 capability_boundary: "uncertain".to_string(),
1490 ..verdict(1.0, "supported", "SUP-1")
1491 };
1492 let empty_crux = TaskClassifierVerdict {
1493 crux: " ".to_string(),
1494 ..verdict(1.0, "supported", "SUP-1")
1495 };
1496 let unusable = [
1497 Some(verdict(1.1, "supported", "SUP-1")),
1498 Some(inconsistent_rule),
1499 Some(empty_crux),
1500 None,
1501 ];
1502 for verdict in unusable {
1503 let classification = policy.to_classification(verdict.as_ref());
1504 assert!(matches!(classification, Classification::Ambiguous(_)));
1505 assert!(classification.argmax(false)?.is_none());
1506 assert!(classification.argmax(true)?.is_none());
1507 }
1508 Ok(())
1509 }
1510
1511 #[test]
1512 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1513 let policy = TaskClassifierPolicy::new(
1514 "efficient",
1515 "capable",
1516 &TaskClassifierConfig {
1517 threshold_step: 0.1,
1518 ..test_config(0.4)
1519 },
1520 );
1521
1522 assert_eq!(
1523 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1524 "efficient"
1525 );
1526 assert_eq!(
1527 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1528 "capable"
1529 );
1530 assert_eq!(
1531 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1532 "efficient"
1533 );
1534 assert_eq!(
1535 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1536 "efficient"
1537 );
1538 assert_eq!(
1539 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1540 "capable"
1541 );
1542 assert_eq!(
1543 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1544 "efficient"
1545 );
1546 Ok(())
1547 }
1548
1549 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1552 Ok(StructuredJudge::new(
1553 TaskInput { recent_turn_window },
1554 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1555 SerdeDecoder::new(),
1556 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1557 ))
1558 }
1559
1560 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1561 let judge = capability_judge(Some(recent_turn_window))?;
1562 let request = Request {
1563 llm_request: LlmRequest {
1564 messages: vec![
1565 Message::text(Role::System, "client instructions"),
1566 Message::text(Role::User, "initial task"),
1567 Message::text(Role::Assistant, "old response"),
1568 Message::text(Role::User, "old follow-up"),
1569 Message::text(Role::Assistant, "recent 1"),
1570 Message::text(Role::User, "recent 2"),
1571 ],
1572 ..LlmRequest::default()
1573 },
1574 raw_request: None,
1575 metadata: None,
1576 };
1577 Ok(judge
1578 .build_request(&State::default(), &request)
1579 .llm_request
1580 .messages
1581 .iter()
1582 .filter_map(|message| message.text_content("\n"))
1583 .collect())
1584 }
1585
1586 #[test]
1587 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1588 let contents = judged_contents(2)?;
1590 assert!(contents.contains(&"client instructions".to_string()));
1591 assert!(contents.contains(&"initial task".to_string()));
1592 assert!(contents.contains(&"recent 1".to_string()));
1593 assert!(contents.contains(&"recent 2".to_string()));
1594 assert!(!contents.contains(&"old response".to_string()));
1595 Ok(())
1596 }
1597
1598 #[test]
1599 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1600 let contents = judged_contents(0)?;
1601 assert!(contents.contains(&"client instructions".to_string()));
1602 assert!(contents.contains(&"initial task".to_string()));
1603 assert!(!contents.contains(&"recent 2".to_string()));
1604 Ok(())
1605 }
1606
1607 fn tool_call(id: &str) -> Message {
1608 Message {
1609 role: Role::Assistant,
1610 content: vec![ContentBlock::ToolCall(ToolCall {
1611 id: id.to_string(),
1612 name: "search".to_string(),
1613 arguments: Value::Null,
1614 })],
1615 }
1616 }
1617
1618 fn tool_result(id: &str) -> Message {
1619 Message {
1620 role: Role::Tool,
1621 content: vec![ContentBlock::ToolResult(ToolResult {
1622 tool_call_id: id.to_string(),
1623 content: vec![ContentBlock::Text {
1624 text: "tool output".to_string(),
1625 }],
1626 is_error: None,
1627 })],
1628 }
1629 }
1630
1631 #[test]
1634 fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1635 let messages = vec![
1636 Message::text(Role::System, "client instructions"),
1637 Message::text(Role::User, "initial task"),
1638 Message::text(Role::Assistant, "old response"),
1639 tool_call("call-1"),
1640 tool_result("call-1"),
1641 Message::text(Role::Assistant, "recent 1"),
1642 Message::text(Role::User, "recent 2"),
1643 Message::text(Role::Assistant, "recent 3"),
1644 Message::text(Role::User, "recent 4"),
1645 ];
1646
1647 let kept = trim_messages(&messages, 5);
1649
1650 assert_eq!(
1651 kept,
1652 vec![
1653 Message::text(Role::System, "client instructions"),
1654 Message::text(Role::User, "initial task"),
1655 tool_call("call-1"),
1656 tool_result("call-1"),
1657 Message::text(Role::Assistant, "recent 1"),
1658 Message::text(Role::User, "recent 2"),
1659 Message::text(Role::Assistant, "recent 3"),
1660 Message::text(Role::User, "recent 4"),
1661 ]
1662 );
1663 }
1664
1665 #[test]
1668 fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1669 let messages = vec![
1670 Message::text(Role::System, "client instructions"),
1671 Message::text(Role::User, "initial task"),
1672 tool_call("x"),
1673 tool_result("x"),
1674 Message::text(Role::Assistant, "later"),
1675 tool_call("x"),
1676 tool_result("x"),
1677 ];
1678
1679 let kept = trim_messages(&messages, 4);
1681
1682 assert_eq!(
1683 kept,
1684 vec![
1685 Message::text(Role::System, "client instructions"),
1686 Message::text(Role::User, "initial task"),
1687 tool_call("x"),
1688 tool_result("x"),
1689 Message::text(Role::Assistant, "later"),
1690 tool_call("x"),
1691 tool_result("x"),
1692 ]
1693 );
1694 }
1695
1696 #[test]
1699 fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1700 let messages = vec![
1701 Message::text(Role::System, "client instructions"),
1702 tool_call("orphan"),
1703 Message::text(Role::User, "initial task"),
1704 Message::text(Role::Assistant, "old response"),
1705 tool_result("orphan"),
1706 Message::text(Role::Assistant, "recent 1"),
1707 Message::text(Role::User, "recent 2"),
1708 ];
1709
1710 let kept = trim_messages(&messages, 3);
1711
1712 assert_eq!(
1713 kept,
1714 vec![
1715 Message::text(Role::System, "client instructions"),
1716 Message::text(Role::User, "initial task"),
1717 tool_result("orphan"),
1718 Message::text(Role::Assistant, "recent 1"),
1719 Message::text(Role::User, "recent 2"),
1720 ]
1721 );
1722 }
1723
1724 #[test]
1725 fn capability_judge_builds_a_structured_request() -> Result<()> {
1726 let judge = capability_judge(None)?;
1727 let request = Request {
1728 llm_request: LlmRequest {
1729 model: Some("inbound".to_string()),
1730 messages: vec![
1731 Message::text(Role::System, "client instructions"),
1732 Message::text(Role::Developer, "client developer instructions"),
1733 Message::text(Role::User, "initial task"),
1734 Message::text(Role::Assistant, "old response"),
1735 Message::text(Role::User, "old follow-up"),
1736 Message::text(Role::Assistant, "recent 1"),
1737 Message::text(Role::User, "recent 2"),
1738 Message::text(Role::Assistant, "recent 3"),
1739 Message::text(Role::User, "recent 4"),
1740 Message::text(Role::Assistant, "recent 5"),
1741 ],
1742 ..LlmRequest::default()
1743 },
1744 raw_request: None,
1745 metadata: None,
1746 };
1747 let judge_request = judge.build_request(&State::default(), &request);
1748
1749 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1750 assert_eq!(judge_request.llm_request.instructions.len(), 1);
1751 assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
1752 assert_eq!(
1753 judge_request.llm_request.instructions[0].content,
1754 InstructionBlock {
1755 role: Role::System,
1756 content: Message::text(Role::System, judge.contract().system_prompt()).content,
1757 }
1758 .content,
1759 );
1760 assert_eq!(judge_request.llm_request.messages.len(), 2);
1761 let contents = judge_request
1762 .llm_request
1763 .messages
1764 .iter()
1765 .filter_map(|message| message.text_content("\n"))
1766 .collect::<Vec<_>>();
1767 assert!(contents.contains(&"recent 4".to_string()));
1768 assert!(contents.contains(&"initial task".to_string()));
1769 assert!(!contents.contains(&"recent 5".to_string()));
1770 assert!(!contents.contains(&"client instructions".to_string()));
1771 assert_eq!(
1772 judge_request.llm_request.output.response_format,
1773 Some(judge.contract().response_format().clone())
1774 );
1775 assert_eq!(
1776 judge_request.llm_request.output.max_output_tokens,
1777 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1778 );
1779 Ok(())
1780 }
1781
1782 fn sample_value(spec: &Value) -> Value {
1783 if let Some(first) = spec
1784 .get("enum")
1785 .and_then(Value::as_array)
1786 .and_then(|values| values.first())
1787 {
1788 return first.clone();
1789 }
1790 match spec.get("type").and_then(Value::as_str) {
1791 Some("number") => serde_json::json!(0.5),
1792 Some("boolean") => serde_json::json!(false),
1793 _ => serde_json::json!("sample"),
1794 }
1795 }
1796
1797 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1798 let properties = schema
1799 .pointer("/json_schema/schema/properties")
1800 .and_then(Value::as_object)
1801 .ok_or_else(|| LibsyError::AlgorithmError {
1802 message: "packaged schema declares no properties".to_string(),
1803 })?;
1804 Ok(Value::Object(
1805 properties
1806 .iter()
1807 .map(|(name, spec)| (name.clone(), sample_value(spec)))
1808 .collect(),
1809 )
1810 .to_string())
1811 }
1812
1813 #[test]
1816 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1817 let contract =
1818 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1819 let schema = contract.response_format();
1820 let reply = schema_shaped_verdict(schema)?;
1821 let judge: CapabilityJudge = StructuredJudge::new(
1822 TaskInput {
1823 recent_turn_window: None,
1824 },
1825 contract,
1826 SerdeDecoder::new(),
1827 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1828 );
1829
1830 let verdict = judge.parse(&text_response(None, reply))?;
1831
1832 assert!(verdict.is_valid());
1833 assert!((0.0..=1.0).contains(&verdict.p_solve));
1834 Ok(())
1835 }
1836
1837 #[test]
1838 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1839 let contract =
1840 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1841 let prompt = contract.system_prompt();
1842 let schema_name = contract
1843 .response_format()
1844 .pointer("/json_schema/name")
1845 .and_then(Value::as_str)
1846 .ok_or_else(|| LibsyError::AlgorithmError {
1847 message: "packaged response schema has no name".to_string(),
1848 })?;
1849 assert_eq!(schema_name, "CapabilityClassifierDecision");
1850 assert!(prompt.contains("SUP-1 [supported]"));
1851 assert!(prompt.contains("SUP-5 [supported]"));
1852 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1853 assert!(!prompt.contains("\"type\": \"object\""));
1854 assert!(!prompt.contains("\"json_schema\""));
1855 assert!(!prompt.contains(schema_name));
1856 let rule_values = contract
1857 .response_format()
1858 .pointer("/json_schema/schema/properties/primary_rule/enum")
1859 .and_then(Value::as_array)
1860 .ok_or_else(|| LibsyError::AlgorithmError {
1861 message: "rendered response schema has no primary rule enum".to_string(),
1862 })?;
1863 assert!(
1864 rule_values
1865 .iter()
1866 .any(|value| value.as_str() == Some("SUP-1"))
1867 );
1868 assert!(
1869 rule_values
1870 .iter()
1871 .any(|value| value.as_str() == Some("none"))
1872 );
1873 Ok(())
1874 }
1875
1876 use std::collections::VecDeque;
1879
1880 use switchyard_protocol::LlmClientError as ClientError;
1881
1882 use switchyard_protocol::Decision;
1883
1884 struct QueuedClient {
1886 replies: Mutex<VecDeque<String>>,
1887 }
1888
1889 impl QueuedClient {
1890 fn new(replies: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
1891 Arc::new(Self {
1892 replies: Mutex::new(replies.into_iter().map(String::from).collect()),
1893 })
1894 }
1895 }
1896
1897 #[async_trait]
1898 impl RoutedLlmClient for QueuedClient {
1899 async fn call(
1900 &self,
1901 _ctx: Context,
1902 request: Request,
1903 _decision: Arc<dyn Decision>,
1904 ) -> std::result::Result<Response, ClientError> {
1905 let reply = self
1906 .replies
1907 .lock()
1908 .pop_front()
1909 .unwrap_or_else(|| "unexpected call".to_string());
1910 Ok(Response {
1911 llm_response: LlmResponse::Agg(text_response(None, reply)),
1912 metadata: request.metadata,
1913 })
1914 }
1915 }
1916
1917 fn escalation_router(
1919 client: Arc<dyn RoutedLlmClient>,
1920 judge_client: Arc<dyn RoutedLlmClient>,
1921 ) -> Result<Arc<LlmTaskClassifier>> {
1922 let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
1923 semantic_name: name.to_string(),
1924 llm_client: Some(c),
1925 };
1926 Ok(Arc::new(LlmTaskClassifier::new(
1927 LlmClassifierConfig::Escalation {
1928 judge_target: target("judge", judge_client),
1929 efficient_target: target("efficient", client.clone()),
1930 capable_target: target("capable", client),
1931 contract: ClassifierContractConfig::default(),
1932 config: EscalationJudgeConfig {
1933 confirmations: 1,
1934 ..EscalationJudgeConfig::default()
1935 },
1936 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1937 },
1938 )?))
1939 }
1940
1941 #[tokio::test]
1942 async fn escalation_router_serves_efficient_when_judge_declines() -> Result<()> {
1943 let judge_client = QueuedClient::new([r#"{"escalate":false,"reason":"progressing"}"#]);
1945 let model_client = QueuedClient::new(["efficient answer"]);
1946 let router = escalation_router(model_client, judge_client)?;
1947 let request = classify_request();
1948
1949 let (trace, response) = router.run(Context::default(), request).await?;
1950
1951 assert_eq!(trace.last().map(|d| d.selected_model()), Some("efficient"));
1953 assert_eq!(
1954 response.llm_response.as_agg().map(completion_text),
1955 Some("efficient answer".to_string())
1956 );
1957 Ok(())
1958 }
1959
1960 #[tokio::test]
1961 async fn escalation_config_overrides_the_packaged_prompt() -> Result<()> {
1962 let client = Arc::new(PerRequestClient::default());
1963 let target = |name: &str| LlmTarget {
1964 semantic_name: name.to_string(),
1965 llm_client: Some(client.clone()),
1966 };
1967 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1968 judge_target: target("judge"),
1969 efficient_target: target("efficient"),
1970 capable_target: target("capable"),
1971 contract: ClassifierContractConfig::default().with_prompt("Custom trajectory rubric."),
1972 config: EscalationJudgeConfig {
1973 confirmations: 1,
1974 ..EscalationJudgeConfig::default()
1975 },
1976 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1977 })?);
1978
1979 router.run(Context::default(), classify_request()).await?;
1980
1981 let prompts = client.judge_system_prompts();
1982 assert_eq!(prompts.len(), 1);
1983 assert_eq!(prompts[0], "Custom trajectory rubric.");
1984 Ok(())
1985 }
1986
1987 #[tokio::test]
1988 async fn escalation_router_upgrades_to_capable_when_judge_escalates() -> Result<()> {
1989 let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck in a loop"}"#]);
1991 let model_client = QueuedClient::new(["efficient draft", "capable answer"]);
1993 let router = escalation_router(model_client, judge_client)?;
1994 let request = classify_request();
1995
1996 let (trace, response) = router.run(Context::default(), request).await?;
1997
1998 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1999 assert_eq!(
2000 response.llm_response.as_agg().map(completion_text),
2001 Some("capable answer".to_string())
2002 );
2003 Ok(())
2004 }
2005
2006 #[tokio::test]
2007 async fn escalation_router_stays_capable_after_latch() -> Result<()> {
2008 let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck"}"#]);
2011 let model_client = QueuedClient::new(["efficient draft", "capable t1", "capable t2"]);
2012 let router = escalation_router(model_client, judge_client)?;
2013
2014 let session_request = classify_session_request();
2015 router
2016 .clone()
2017 .run(Context::default(), session_request.clone())
2018 .await?;
2019 let (trace, _) = router
2020 .clone()
2021 .run(Context::default(), session_request)
2022 .await?;
2023
2024 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2025 Ok(())
2026 }
2027
2028 fn escalation_router_with_recovery(
2031 client: Arc<dyn RoutedLlmClient>,
2032 judge_client: Arc<dyn RoutedLlmClient>,
2033 confirmations: u32,
2034 recovery_confirmations: u32,
2035 ) -> Result<Arc<LlmTaskClassifier>> {
2036 let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
2037 semantic_name: name.to_string(),
2038 llm_client: Some(c),
2039 };
2040 Ok(Arc::new(LlmTaskClassifier::new(
2041 LlmClassifierConfig::Escalation {
2042 judge_target: target("judge", judge_client),
2043 efficient_target: target("efficient", client.clone()),
2044 capable_target: target("capable", client),
2045 contract: ClassifierContractConfig::default(),
2046 config: EscalationJudgeConfig {
2047 confirmations,
2048 recovery_confirmations,
2049 ..EscalationJudgeConfig::default()
2050 },
2051 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
2052 },
2053 )?))
2054 }
2055
2056 #[tokio::test]
2057 async fn escalation_router_delatches_after_recovery_streak() -> Result<()> {
2058 let judge_client = QueuedClient::new([
2063 r#"{"escalate":true,"reason":"stuck"}"#,
2064 r#"{"escalate":false,"reason":"blocker resolved"}"#,
2065 r#"{"escalate":false,"reason":"progressing"}"#,
2066 ]);
2067 let model_client = QueuedClient::new(["efficient draft", "capable t1", "efficient t2"]);
2068 let router = escalation_router_with_recovery(model_client, judge_client, 1, 1)?;
2069
2070 let session_request = classify_session_request();
2071 router
2072 .clone()
2073 .run(Context::default(), session_request.clone())
2074 .await?;
2075 let (trace, response) = router.run(Context::default(), session_request).await?;
2076
2077 assert_eq!(trace.last().map(|d| d.selected_model()), Some("efficient"));
2078 assert_eq!(
2079 response.llm_response.as_agg().map(completion_text),
2080 Some("efficient t2".to_string())
2081 );
2082 Ok(())
2083 }
2084
2085 #[tokio::test]
2086 async fn escalation_router_holds_latch_until_recovery_confirms() -> Result<()> {
2087 let judge_client = QueuedClient::new([
2090 r#"{"escalate":true,"reason":"stuck"}"#,
2091 r#"{"escalate":false,"reason":"looks better"}"#,
2092 ]);
2093 let model_client = QueuedClient::new(["efficient draft", "capable t1", "capable t2"]);
2094 let router = escalation_router_with_recovery(model_client, judge_client, 1, 2)?;
2095
2096 let session_request = classify_session_request();
2097 router
2098 .clone()
2099 .run(Context::default(), session_request.clone())
2100 .await?;
2101 let (trace, _) = router.run(Context::default(), session_request).await?;
2102
2103 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2104 Ok(())
2105 }
2106
2107 #[tokio::test]
2108 async fn escalation_router_probation_relatches_on_one_verdict_then_latch_is_permanent()
2109 -> Result<()> {
2110 let judge_client = QueuedClient::new([
2118 r#"{"escalate":true,"reason":"stuck"}"#,
2119 r#"{"escalate":true,"reason":"still stuck"}"#,
2120 r#"{"escalate":false,"reason":"remaining work is routine"}"#,
2121 r#"{"escalate":true,"reason":"weak model thrashing again"}"#,
2122 r#"{"escalate":false,"reason":"tripwire: must never be consumed"}"#,
2123 ]);
2124 let model_client = QueuedClient::new([
2125 "efficient t1",
2126 "efficient d2",
2127 "capable t2",
2128 "efficient d3",
2129 "capable t3",
2130 "capable t4",
2131 ]);
2132 let router = escalation_router_with_recovery(model_client, judge_client, 2, 1)?;
2133
2134 let session_request = classify_session_request();
2135 let (trace, _) = router
2136 .clone()
2137 .run(Context::default(), session_request.clone())
2138 .await?;
2139 assert_eq!(trace.last().map(|d| d.selected_model()), Some("efficient"));
2140 let (trace, _) = router
2141 .clone()
2142 .run(Context::default(), session_request.clone())
2143 .await?;
2144 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2145 let (trace, response) = router
2146 .clone()
2147 .run(Context::default(), session_request.clone())
2148 .await?;
2149 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2150 assert_eq!(
2151 response.llm_response.as_agg().map(completion_text),
2152 Some("capable t3".to_string())
2153 );
2154 let (trace, response) = router.run(Context::default(), session_request).await?;
2155 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2156 assert_eq!(
2157 response.llm_response.as_agg().map(completion_text),
2158 Some("capable t4".to_string())
2159 );
2160 Ok(())
2161 }
2162
2163 #[tokio::test]
2164 async fn escalation_relatches_permanently_when_efficient_overflows_after_hand_back()
2165 -> Result<()> {
2166 struct OverflowMarkerClient {
2173 replies: Mutex<VecDeque<String>>,
2174 }
2175 #[async_trait]
2176 impl RoutedLlmClient for OverflowMarkerClient {
2177 async fn call(
2178 &self,
2179 _ctx: Context,
2180 request: Request,
2181 decision: Arc<dyn Decision>,
2182 ) -> std::result::Result<Response, ClientError> {
2183 let reply = self.replies.lock().pop_front().expect("queued reply");
2184 if reply == "OVERFLOW" {
2185 return Err(ClientError::ContextWindowExceeded {
2186 model: decision.selected_model().to_string(),
2187 message: "prompt is too long".to_string(),
2188 });
2189 }
2190 Ok(Response {
2191 llm_response: LlmResponse::Agg(text_response(None, reply)),
2192 metadata: request.metadata,
2193 })
2194 }
2195 }
2196 let model_client = Arc::new(OverflowMarkerClient {
2197 replies: Mutex::new(
2198 [
2199 "efficient d1",
2200 "capable t1",
2201 "OVERFLOW",
2202 "capable t2",
2203 "capable t3",
2204 ]
2205 .into_iter()
2206 .map(String::from)
2207 .collect(),
2208 ),
2209 });
2210 let judge_client = QueuedClient::new([
2211 r#"{"escalate":true,"reason":"stuck"}"#,
2212 r#"{"escalate":false,"reason":"remaining work is routine"}"#,
2213 r#"{"escalate":false,"reason":"tripwire: must never be consumed"}"#,
2214 ]);
2215 let router = escalation_router_with_recovery(model_client, judge_client, 1, 1)?;
2216
2217 let session_request = classify_session_request();
2218 let (trace, _) = router
2219 .clone()
2220 .run(Context::default(), session_request.clone())
2221 .await?;
2222 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2223 let (trace, response) = router
2224 .clone()
2225 .run(Context::default(), session_request.clone())
2226 .await?;
2227 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2228 assert_eq!(
2229 response.llm_response.as_agg().map(completion_text),
2230 Some("capable t2".to_string())
2231 );
2232 let (trace, response) = router.run(Context::default(), session_request).await?;
2233 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2234 assert_eq!(
2235 response.llm_response.as_agg().map(completion_text),
2236 Some("capable t3".to_string())
2237 );
2238 Ok(())
2239 }
2240
2241 #[tokio::test]
2242 async fn escalation_recovery_judge_uses_the_packaged_recovery_prompt() -> Result<()> {
2243 struct RecordingJudge {
2247 replies: Mutex<VecDeque<String>>,
2248 prompts: Mutex<Vec<String>>,
2249 }
2250 #[async_trait]
2251 impl RoutedLlmClient for RecordingJudge {
2252 async fn call(
2253 &self,
2254 _ctx: Context,
2255 request: Request,
2256 _decision: Arc<dyn Decision>,
2257 ) -> std::result::Result<Response, ClientError> {
2258 self.prompts
2259 .lock()
2260 .extend(
2261 request
2262 .llm_request
2263 .instructions
2264 .first()
2265 .and_then(|instruction| {
2266 instruction.content.iter().find_map(|b| {
2267 if let ContentBlock::Text { text } = b {
2268 Some(text.clone())
2269 } else {
2270 None
2271 }
2272 })
2273 }),
2274 );
2275 let reply = self.replies.lock().pop_front().expect("queued verdict");
2276 Ok(Response {
2277 llm_response: LlmResponse::Agg(text_response(None, reply)),
2278 metadata: request.metadata,
2279 })
2280 }
2281 }
2282 let judge_client = Arc::new(RecordingJudge {
2283 replies: Mutex::new(
2284 [
2285 r#"{"escalate":true,"reason":"stuck"}"#,
2286 r#"{"escalate":false,"reason":"remaining work is routine"}"#,
2287 r#"{"escalate":false,"reason":"progressing"}"#,
2288 ]
2289 .into_iter()
2290 .map(String::from)
2291 .collect(),
2292 ),
2293 prompts: Mutex::new(Vec::new()),
2294 });
2295 let model_client = QueuedClient::new(["efficient d1", "capable t1", "efficient t2"]);
2296 let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
2297 semantic_name: name.to_string(),
2298 llm_client: Some(c),
2299 };
2300 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
2301 judge_target: target("judge", judge_client.clone()),
2302 efficient_target: target("efficient", model_client.clone()),
2303 capable_target: target("capable", model_client),
2304 contract: ClassifierContractConfig::default().with_prompt("Custom trajectory rubric."),
2305 config: EscalationJudgeConfig {
2306 confirmations: 1,
2307 recovery_confirmations: 1,
2308 ..EscalationJudgeConfig::default()
2309 },
2310 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
2311 })?);
2312
2313 let session_request = classify_session_request();
2314 router
2315 .clone()
2316 .run(Context::default(), session_request.clone())
2317 .await?;
2318 router.run(Context::default(), session_request).await?;
2319
2320 let prompts = judge_client.prompts.lock().clone();
2321 assert_eq!(prompts.len(), 3);
2322 assert_eq!(prompts[0], "Custom trajectory rubric.");
2323 assert!(prompts[1].contains("hand-back judge"));
2324 assert_eq!(prompts[2], "Custom trajectory rubric.");
2325 Ok(())
2326 }
2327
2328 #[tokio::test]
2329 async fn escalation_classifier_falls_back_to_capable_when_efficient_overflows() -> Result<()> {
2330 struct OverflowClient;
2334 #[async_trait]
2335 impl RoutedLlmClient for OverflowClient {
2336 async fn call(
2337 &self,
2338 _ctx: Context,
2339 _request: Request,
2340 decision: Arc<dyn Decision>,
2341 ) -> std::result::Result<Response, LlmClientError> {
2342 Err(LlmClientError::ContextWindowExceeded {
2343 model: decision.selected_model().to_string(),
2344 message: "prompt is too long".to_string(),
2345 })
2346 }
2347 }
2348
2349 let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
2350 semantic_name: name.to_string(),
2351 llm_client: Some(c),
2352 };
2353 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
2354 judge_target: target("judge", QueuedClient::new([])), efficient_target: target("efficient", Arc::new(OverflowClient)),
2356 capable_target: target("capable", QueuedClient::new(["capable answer"])),
2357 contract: ClassifierContractConfig::default(),
2358 config: EscalationJudgeConfig {
2359 confirmations: 1,
2360 ..EscalationJudgeConfig::default()
2361 },
2362 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
2363 })?);
2364
2365 let (trace, response) = router.run(Context::default(), classify_request()).await?;
2366
2367 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2368 assert_eq!(
2369 response.llm_response.as_agg().map(completion_text),
2370 Some("capable answer".to_string())
2371 );
2372 Ok(())
2373 }
2374}