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