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::EveryRequest {
423 return Err(LibsyError::AlgorithmError {
424 message:
425 "message_hash_fallback requires classify_trigger = new_session or user_turn"
426 .to_string(),
427 });
428 }
429 Ok(())
430 }
431}
432
433#[derive(Clone, Debug)]
435pub enum CustomClassifierPolicy {
436 TargetSelector {
438 selector: String,
440 },
441}
442
443impl CustomClassifierPolicy {
444 pub fn target_selector(selector: impl Into<String>) -> Self {
446 Self::TargetSelector {
447 selector: selector.into(),
448 }
449 }
450}
451
452#[derive(Clone, Debug)]
454pub struct CustomClassifierConfig {
455 pub prompt: String,
457 pub response_schema: Value,
459 pub policy: CustomClassifierPolicy,
461 pub classify_trigger: ClassifyTrigger,
463 pub message_hash_fallback: bool,
465 pub recent_turn_window: Option<usize>,
467 pub max_output_tokens: u64,
469}
470
471impl CustomClassifierConfig {
472 pub fn new(
474 prompt: impl Into<String>,
475 response_schema: Value,
476 policy: CustomClassifierPolicy,
477 ) -> Self {
478 Self {
479 prompt: prompt.into(),
480 response_schema,
481 policy,
482 classify_trigger: ClassifyTrigger::default(),
483 message_hash_fallback: false,
484 recent_turn_window: None,
485 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
486 }
487 }
488
489 fn validate(&self) -> Result<()> {
490 if self.max_output_tokens == 0 {
491 return Err(LibsyError::AlgorithmError {
492 message: "max_output_tokens must be at least 1".to_string(),
493 });
494 }
495 if self.message_hash_fallback && self.classify_trigger == ClassifyTrigger::EveryRequest {
497 return Err(LibsyError::AlgorithmError {
498 message:
499 "message_hash_fallback requires classify_trigger = new_session or user_turn"
500 .to_string(),
501 });
502 }
503 Ok(())
504 }
505}
506
507enum CustomPolicyRuntime {
508 TargetSelector(TargetSelectorPolicy),
509}
510
511impl JudgePolicy for CustomPolicyRuntime {
512 type Verdict = Value;
513
514 fn to_classification(
515 &self,
516 verdict: Option<&Self::Verdict>,
517 driver: &Driver,
518 ) -> Result<Classification> {
519 match self {
520 Self::TargetSelector(policy) => policy.to_classification(verdict, driver),
521 }
522 }
523}
524
525fn affinity_router(
527 trigger: ClassifyTrigger,
528 message_hash_fallback: bool,
529) -> Option<Arc<AffinityRouter>> {
530 let router = match trigger {
531 ClassifyTrigger::EveryRequest => return None,
532 ClassifyTrigger::NewSession => AffinityRouter::new(),
533 ClassifyTrigger::UserTurn => AffinityRouter::new().with_release_on_user_turn(),
534 };
535 let router = if message_hash_fallback {
536 router.with_message_hash_fallback()
537 } else {
538 router
539 };
540 Some(Arc::new(router))
541}
542
543pub struct LlmTaskClassifier {
545 route: FallThrough<State>,
546 inner: Arc<dyn Classifier<State>>,
548}
549
550struct ClassifierRouteConfig {
551 default_target: Category,
552 classify_trigger: ClassifyTrigger,
553 message_hash_fallback: bool,
554}
555
556pub struct DefaultCategoryClassifier(pub Category);
559
560#[async_trait]
561impl<S: Send> Classifier<S> for DefaultCategoryClassifier {
562 async fn score(
563 &self,
564 _state: &mut S,
565 _request: &mut Request,
566 driver: &Driver,
567 ) -> Result<(Classification, Option<Response>)> {
568 let target = driver.first_model_for(&self.0)?;
569 driver.set_evidence_if_empty(serde_json::json!({"source": "fall_open"}));
570 Ok((
571 Classification::Scores(vec![Score {
572 target: target.clone(),
573 confidence: 0.0,
574 category: Some(self.0.clone()),
575 }]),
576 None,
577 ))
578 }
579}
580
581#[derive(Clone)]
583#[non_exhaustive]
584pub enum LlmClassifierConfig {
585 Capability {
587 config: TaskClassifierConfig,
589 },
590 Escalation {
592 contract: ClassifierContractConfig,
594 config: EscalationJudgeConfig,
596 max_output_tokens: u64,
598 },
599 Custom {
601 default_target: Category,
603 config: CustomClassifierConfig,
605 },
606}
607
608impl LlmTaskClassifier {
609 pub fn new(config: LlmClassifierConfig) -> Result<Self> {
616 match config {
617 LlmClassifierConfig::Capability { config } => Self::build_capability(config),
618 LlmClassifierConfig::Escalation {
619 contract,
620 config,
621 max_output_tokens,
622 } => Self::build_escalation(contract, config, max_output_tokens),
623 LlmClassifierConfig::Custom {
624 default_target,
625 config,
626 } => Self::build_custom(default_target, config),
627 }
628 }
629
630 fn build_capability(config: TaskClassifierConfig) -> Result<Self> {
631 config.validate()?;
632 let contract = Self::load_capability_contract(&config.contract)?;
633 let classify_trigger = config.classify_trigger;
634 let message_hash_fallback = config.message_hash_fallback;
635 let classifier: Arc<dyn Classifier<State>> = Arc::new(
636 JudgeClassifier::new(
637 StructuredJudge::new(
638 TaskInput {
639 recent_turn_window: config.recent_turn_window,
640 },
641 contract,
642 SerdeDecoder::new(),
643 JudgeRuntimeConfig::new(config.max_output_tokens)?,
644 ),
645 TaskClassifierPolicy::new(&config),
646 )
647 .with_evidence(capability_evidence),
648 );
649 Self::from_classifier(
650 classifier,
651 ClassifierRouteConfig {
652 default_target: Category::Capable,
653 classify_trigger,
654 message_hash_fallback,
655 },
656 )
657 }
658
659 fn build_custom(default_target: Category, config: CustomClassifierConfig) -> Result<Self> {
660 config.validate()?;
661 let CustomClassifierConfig {
662 prompt,
663 response_schema,
664 policy,
665 classify_trigger,
666 message_hash_fallback,
667 recent_turn_window,
668 max_output_tokens,
669 } = config;
670 let contract = ClassifierContract::from_inner_schema(&prompt, response_schema)?;
671 let policy = match policy {
672 CustomClassifierPolicy::TargetSelector { selector } => {
673 CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(selector)?)
674 }
675 };
676 let classifier: Arc<dyn Classifier<State>> = Arc::new(JudgeClassifier::new(
677 StructuredJudge::new(
678 TaskInput { recent_turn_window },
679 contract,
680 JsonSchemaDecoder::new(),
681 JudgeRuntimeConfig::new(max_output_tokens)?,
682 ),
683 policy,
684 ));
685
686 Self::from_classifier(
687 classifier,
688 ClassifierRouteConfig {
689 default_target,
690 classify_trigger,
691 message_hash_fallback,
692 },
693 )
694 }
695
696 fn build_escalation(
697 contract_config: ClassifierContractConfig,
698 config: EscalationJudgeConfig,
699 max_output_tokens: u64,
700 ) -> Result<Self> {
701 let inner = escalation::build_classifier(contract_config, config, max_output_tokens)?;
702 Ok(Self {
703 route: FallThrough::<State>::new_with_state()
704 .with_name(ALGORITHM_NAME)
705 .with_classifier(Arc::clone(&inner)),
706 inner,
707 })
708 }
709
710 fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
712 ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
713 }
714
715 fn from_classifier(
717 inner: Arc<dyn Classifier<State>>,
718 config: ClassifierRouteConfig,
719 ) -> Result<Self> {
720 if config.message_hash_fallback && config.classify_trigger == ClassifyTrigger::EveryRequest
722 {
723 return Err(LibsyError::AlgorithmError {
724 message:
725 "message_hash_fallback requires classify_trigger = new_session or user_turn"
726 .to_string(),
727 });
728 }
729 let mut route = FallThrough::<State>::new_with_state().with_name(ALGORITHM_NAME);
731 if let Some(affinity) =
732 affinity_router(config.classify_trigger, config.message_hash_fallback).as_ref()
733 {
734 route = route
736 .with_processor(affinity.clone())
737 .with_classifier(affinity.clone());
738 }
739 let fallback = DefaultCategoryClassifier(config.default_target);
740 Ok(Self {
741 route: route
742 .with_classifier(inner.clone())
743 .with_classifier(Arc::new(fallback)),
744 inner,
745 })
746 }
747}
748
749#[async_trait]
750impl Classifier<State> for LlmTaskClassifier {
751 async fn score(
752 &self,
753 state: &mut State,
754 request: &mut Request,
755 driver: &Driver,
756 ) -> Result<(Classification, Option<Response>)> {
757 self.inner.score(state, request, driver).await
758 }
759}
760
761#[async_trait]
762impl Algorithm for LlmTaskClassifier {
763 fn name(&self) -> &str {
764 "llm_task_classifier"
765 }
766
767 async fn route(
768 self: Arc<Self>,
769 driver: Driver,
770 request: Request,
771 ) -> Result<crate::RoutingOutcome> {
772 self.route.execute(driver, request).await
773 }
774}
775
776#[cfg(test)]
777mod tests {
778 use std::collections::HashMap;
779 use std::sync::Arc;
780
781 use parking_lot::Mutex;
782 use serde_json::Value;
783
784 use super::*;
785 use switchyard_protocol::{
786 ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, ModelId, ToolCall,
787 ToolResult, completion_text, text_request, text_response,
788 };
789
790 use crate::algorithms::util::llm_judge::Judge;
791 use crate::core::testing::{Serve, test_drive_with_models};
792 use switchyard_protocol::{LlmResponse, Response};
793
794 const TEST_THRESHOLD: f64 = 0.5;
795
796 type CapabilityJudge = StructuredJudge<TaskInput, SerdeDecoder<TaskClassifierVerdict>>;
797
798 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
799 TaskClassifierConfig {
800 base_threshold,
801 ..TaskClassifierConfig::default()
802 }
803 }
804
805 fn policy() -> TaskClassifierPolicy {
806 TaskClassifierPolicy::new(&test_config(TEST_THRESHOLD))
807 }
808
809 fn runtime_models() -> HashMap<Category, Vec<ModelId>> {
810 [
811 (Category::Judge, vec![ModelId::from("judge")]),
812 (Category::Efficient, vec![ModelId::from("efficient")]),
813 (Category::Capable, vec![ModelId::from("capable")]),
814 (
815 Category::Any,
816 vec![ModelId::from("efficient"), ModelId::from("capable")],
817 ),
818 ]
819 .into()
820 }
821
822 fn policy_driver() -> Driver {
823 Driver::new("test", Arc::new(runtime_models().into())).0
824 }
825
826 fn verdict(
827 p_solve: f64,
828 capability_boundary: &str,
829 primary_rule: &str,
830 ) -> TaskClassifierVerdict {
831 TaskClassifierVerdict {
832 crux: "test crux".to_string(),
833 primary_rule: primary_rule.to_string(),
834 capability_boundary: capability_boundary.to_string(),
835 p_solve,
836 }
837 }
838
839 fn selected(
840 policy: &TaskClassifierPolicy,
841 verdict: Option<&TaskClassifierVerdict>,
842 ) -> Result<ModelId> {
843 policy
844 .to_classification(verdict, &policy_driver())?
845 .argmax(false)?
846 .map(|score| score.target)
847 .ok_or_else(|| LibsyError::AlgorithmError {
848 message: "policy abstained".to_string(),
849 })
850 }
851
852 #[derive(Default)]
855 struct Recorder {
856 calls: Mutex<Vec<String>>,
857 call_roles: Mutex<Vec<(String, bool)>>,
858 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
859 judge_system_prompts: Mutex<Vec<String>>,
860 }
861
862 impl Recorder {
863 fn calls(&self) -> Vec<String> {
864 self.calls.lock().clone()
865 }
866
867 fn call_roles(&self) -> Vec<(String, bool)> {
868 self.call_roles.lock().clone()
869 }
870
871 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
872 self.judge_max_output_tokens.lock().clone()
873 }
874
875 fn judge_system_prompts(&self) -> Vec<String> {
876 self.judge_system_prompts.lock().clone()
877 }
878
879 fn serve(self: &Arc<Self>) -> impl Serve {
880 let recorder = Arc::clone(self);
881 move |model: ModelId, request: Request| {
882 let recorder = Arc::clone(&recorder);
883 async move {
884 let model = model.to_string();
885 recorder.calls.lock().push(model.clone());
886 recorder
887 .call_roles
888 .lock()
889 .push((model.clone(), model != "judge"));
890 let completion = if model == "judge" {
891 recorder
892 .judge_max_output_tokens
893 .lock()
894 .push(request.llm_request.output.max_output_tokens);
895 recorder.judge_system_prompts.lock().extend(
896 request
897 .llm_request
898 .instructions
899 .first()
900 .and_then(|instruction| {
901 instruction.content.iter().find_map(|b| {
902 if let ContentBlock::Text { text } = b {
903 Some(text.clone())
904 } else {
905 None
906 }
907 })
908 }),
909 );
910 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
911 } else {
912 format!("answer from {model}")
913 };
914 Ok(Response {
915 llm_response: LlmResponse::Agg(text_response(None, completion)),
916 metadata: request.metadata,
917 upstream_headers: http::HeaderMap::new(),
918 })
919 }
920 }
921 }
922 }
923
924 fn unreachable_judge() -> impl Serve {
926 |model: ModelId, request: Request| async move {
927 let model = model.to_string();
928 if model == "judge" {
929 return Err(LlmClientError::Timeout {
930 source: Box::new(std::io::Error::other("judge unreachable")),
931 });
932 }
933 Ok(Response {
934 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
935 metadata: request.metadata,
936 upstream_headers: http::HeaderMap::new(),
937 })
938 }
939 }
940
941 fn router() -> Result<Arc<LlmTaskClassifier>> {
942 Ok(Arc::new(LlmTaskClassifier::new(
943 LlmClassifierConfig::Capability {
944 config: test_config(TEST_THRESHOLD),
945 },
946 )?))
947 }
948
949 fn classify_request() -> Request {
950 Request {
951 llm_request: text_request(Some("auto".to_string()), "classify this task"),
952 raw_request: None,
953 metadata: None,
954 }
955 }
956
957 fn classify_session_request() -> Request {
958 Request {
959 metadata: Some(Metadata {
960 session_id: Some("session-1".to_string()),
961 ..Metadata::default()
962 }),
963 ..classify_request()
964 }
965 }
966
967 fn classify_follow_up_request() -> Request {
968 let mut request = classify_request();
969 request
970 .llm_request
971 .messages
972 .push(Message::text(Role::Assistant, "I will add the test."));
973 request.llm_request.messages.push(Message::text(
974 Role::User,
975 "Now run the test suite and report the result.",
976 ));
977 request
978 }
979
980 #[tokio::test]
981 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
982 let router = router()?;
983
984 let (selected_model, response) = test_drive_with_models(
985 router,
986 classify_request(),
987 runtime_models(),
988 unreachable_judge(),
989 )
990 .await?;
991
992 assert_eq!(selected_model, "capable");
993 assert_eq!(
994 response.llm_response.as_agg().map(completion_text),
995 Some("answer from capable".to_string())
996 );
997 Ok(())
998 }
999
1000 #[tokio::test]
1001 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1002 let recorder = Arc::new(Recorder::default());
1003 let router = router()?;
1004 let request = classify_request();
1005 let models = runtime_models();
1006
1007 test_drive_with_models(
1008 router.clone(),
1009 request.clone(),
1010 models.clone(),
1011 recorder.serve(),
1012 )
1013 .await?;
1014 test_drive_with_models(router, request, models, recorder.serve()).await?;
1015
1016 assert_eq!(
1017 recorder.calls(),
1018 vec!["judge", "efficient", "judge", "efficient"]
1019 );
1020 assert_eq!(
1021 recorder.call_roles(),
1022 vec![
1023 ("judge".to_string(), false),
1024 ("efficient".to_string(), true),
1025 ("judge".to_string(), false),
1026 ("efficient".to_string(), true),
1027 ]
1028 );
1029 Ok(())
1030 }
1031
1032 #[tokio::test]
1033 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1034 let recorder = Arc::new(Recorder::default());
1035 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1036 config: TaskClassifierConfig {
1037 max_output_tokens: 512,
1038 ..test_config(TEST_THRESHOLD)
1039 },
1040 })?);
1041
1042 test_drive_with_models(
1043 router,
1044 classify_request(),
1045 runtime_models(),
1046 recorder.serve(),
1047 )
1048 .await?;
1049
1050 assert_eq!(recorder.judge_max_output_tokens(), vec![Some(512)]);
1051 Ok(())
1052 }
1053
1054 #[tokio::test]
1055 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1056 let recorder = Arc::new(Recorder::default());
1057 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1058 config: TaskClassifierConfig {
1059 contract: ClassifierContractConfig::default()
1060 .with_prompt("Custom capability rubric."),
1061 ..test_config(TEST_THRESHOLD)
1062 },
1063 })?);
1064
1065 test_drive_with_models(
1066 router,
1067 classify_request(),
1068 runtime_models(),
1069 recorder.serve(),
1070 )
1071 .await?;
1072
1073 let prompts = recorder.judge_system_prompts();
1074 assert_eq!(prompts.len(), 1);
1075 assert_eq!(prompts[0], "Custom capability rubric.");
1076 Ok(())
1077 }
1078
1079 #[tokio::test]
1080 async fn classifier_config_enables_new_session_trigger() -> Result<()> {
1081 let recorder = Arc::new(Recorder::default());
1082 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1083 config: TaskClassifierConfig {
1084 classify_trigger: ClassifyTrigger::NewSession,
1085 ..test_config(TEST_THRESHOLD)
1086 },
1087 })?);
1088
1089 let request = classify_session_request();
1090 let models = runtime_models();
1091 test_drive_with_models(
1092 router.clone(),
1093 request.clone(),
1094 models.clone(),
1095 recorder.serve(),
1096 )
1097 .await?;
1098 test_drive_with_models(router, request, models, recorder.serve()).await?;
1099
1100 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1101 Ok(())
1102 }
1103
1104 #[tokio::test]
1105 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1106 let recorder = Arc::new(Recorder::default());
1107 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1108 config: TaskClassifierConfig {
1109 classify_trigger: ClassifyTrigger::NewSession,
1110 message_hash_fallback: true,
1111 recent_turn_window: None,
1112 ..test_config(TEST_THRESHOLD)
1113 },
1114 })?);
1115
1116 let models = runtime_models();
1117 test_drive_with_models(
1118 router.clone(),
1119 classify_request(),
1120 models.clone(),
1121 recorder.serve(),
1122 )
1123 .await?;
1124 test_drive_with_models(
1125 router,
1126 classify_follow_up_request(),
1127 models,
1128 recorder.serve(),
1129 )
1130 .await?;
1131
1132 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1133 Ok(())
1134 }
1135
1136 #[tokio::test]
1137 async fn one_classifier_uses_each_requests_runtime_models() -> Result<()> {
1138 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1139 config: TaskClassifierConfig {
1140 classify_trigger: ClassifyTrigger::NewSession,
1141 ..test_config(TEST_THRESHOLD)
1142 },
1143 })?);
1144 let calls = Arc::new(Mutex::new(Vec::new()));
1145 let serve = |calls: Arc<Mutex<Vec<String>>>| {
1146 move |model: ModelId, _request: Request| {
1147 let calls = Arc::clone(&calls);
1148 async move {
1149 calls.lock().push(model.to_string());
1150 let text = if model.as_str().starts_with("judge-") {
1151 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1152 } else {
1153 model.to_string()
1154 };
1155 Ok(Response {
1156 llm_response: LlmResponse::Agg(text_response(None, text)),
1157 metadata: None,
1158 upstream_headers: Default::default(),
1159 })
1160 }
1161 }
1162 };
1163 let models = |suffix: &str| -> HashMap<Category, Vec<ModelId>> {
1164 [
1165 (
1166 Category::Judge,
1167 vec![ModelId::from(format!("judge-{suffix}"))],
1168 ),
1169 (
1170 Category::Efficient,
1171 vec![ModelId::from(format!("efficient-{suffix}"))],
1172 ),
1173 (
1174 Category::Capable,
1175 vec![ModelId::from(format!("capable-{suffix}"))],
1176 ),
1177 (
1178 Category::Any,
1179 vec![
1180 ModelId::from(format!("efficient-{suffix}")),
1181 ModelId::from(format!("capable-{suffix}")),
1182 ],
1183 ),
1184 ]
1185 .into()
1186 };
1187 let request = classify_session_request();
1188
1189 let (first, _) = test_drive_with_models(
1190 router.clone(),
1191 request.clone(),
1192 models("a"),
1193 serve(Arc::clone(&calls)),
1194 )
1195 .await?;
1196 let (second, _) =
1197 test_drive_with_models(router, request, models("b"), serve(Arc::clone(&calls))).await?;
1198
1199 assert_eq!(first, "efficient-a");
1200 assert_eq!(second, "efficient-b");
1201 assert_eq!(
1202 &*calls.lock(),
1203 &["judge-a", "efficient-a", "judge-b", "efficient-b"]
1204 );
1205 Ok(())
1206 }
1207
1208 #[test]
1209 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1210 let policy = policy();
1211 let at_threshold = verdict(0.5, "supported", "SUP-1");
1212 let below_threshold = verdict(0.49, "supported", "SUP-1");
1213 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1214 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1215 Ok(())
1216 }
1217
1218 #[test]
1219 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1220 let borderline = verdict(0.5, "supported", "SUP-1");
1221 let strict = TaskClassifierPolicy::new(&test_config(0.9));
1222 let lenient = TaskClassifierPolicy::new(&test_config(0.1));
1223 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1224 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1225 Ok(())
1226 }
1227
1228 #[test]
1229 fn classifier_config_rejects_unknown_fields() {
1230 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1231 "base_threshold": 0.5,
1232 "classifier_magic": true,
1233 }))
1234 .expect_err("unknown classifier fields must be rejected");
1235
1236 assert!(
1237 error
1238 .to_string()
1239 .contains("unknown field `classifier_magic`"),
1240 "{error}"
1241 );
1242 }
1243
1244 #[test]
1245 fn invalid_classifier_config_is_rejected() -> Result<()> {
1246 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1247 assert!(
1248 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1249 config: test_config(bad),
1250 })
1251 .is_err(),
1252 "base threshold {bad} should be rejected"
1253 );
1254 }
1255 for config in [
1256 TaskClassifierConfig {
1257 base_threshold: 0.5,
1258 threshold_step: -0.1,
1259 ..TaskClassifierConfig::default()
1260 },
1261 TaskClassifierConfig {
1262 base_threshold: 0.8,
1263 threshold_step: 0.11,
1264 ..TaskClassifierConfig::default()
1265 },
1266 TaskClassifierConfig {
1267 base_threshold: 0.5,
1268 message_hash_fallback: true,
1269 ..TaskClassifierConfig::default()
1270 },
1271 TaskClassifierConfig {
1272 base_threshold: 0.5,
1273 max_output_tokens: 0,
1274 ..TaskClassifierConfig::default()
1275 },
1276 ] {
1277 assert!(LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).is_err());
1278 }
1279 for base_threshold in [0.0, 1.0] {
1280 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1281 config: test_config(base_threshold),
1282 })?;
1283 }
1284 Ok(())
1285 }
1286
1287 #[test]
1288 fn message_hash_fallback_accepts_retaining_triggers() -> Result<()> {
1289 for trigger in [ClassifyTrigger::NewSession, ClassifyTrigger::UserTurn] {
1293 let config = TaskClassifierConfig {
1294 base_threshold: 0.5,
1295 classify_trigger: trigger,
1296 message_hash_fallback: true,
1297 ..TaskClassifierConfig::default()
1298 };
1299 LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).map_err(
1300 |error| LibsyError::AlgorithmError {
1301 message: format!("{trigger:?} with message_hash_fallback rejected: {error}"),
1302 },
1303 )?;
1304 }
1305 let every_request = TaskClassifierConfig {
1306 base_threshold: 0.5,
1307 classify_trigger: ClassifyTrigger::EveryRequest,
1308 message_hash_fallback: true,
1309 ..TaskClassifierConfig::default()
1310 };
1311 assert!(
1312 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1313 config: every_request
1314 })
1315 .is_err(),
1316 "every_request with message_hash_fallback should stay rejected"
1317 );
1318 Ok(())
1319 }
1320
1321 #[test]
1322 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1323 let policy = policy();
1324 let inconsistent_rule = TaskClassifierVerdict {
1325 capability_boundary: "uncertain".to_string(),
1326 ..verdict(1.0, "supported", "SUP-1")
1327 };
1328 let empty_crux = TaskClassifierVerdict {
1329 crux: " ".to_string(),
1330 ..verdict(1.0, "supported", "SUP-1")
1331 };
1332 let unusable = [
1333 Some(verdict(1.1, "supported", "SUP-1")),
1334 Some(inconsistent_rule),
1335 Some(empty_crux),
1336 None,
1337 ];
1338 for verdict in unusable {
1339 let classification = policy.to_classification(verdict.as_ref(), &policy_driver())?;
1340 assert!(matches!(classification, Classification::Ambiguous(_)));
1341 assert!(classification.argmax(false)?.is_none());
1342 assert!(classification.argmax(true)?.is_none());
1343 }
1344 Ok(())
1345 }
1346
1347 #[test]
1348 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1349 let policy = TaskClassifierPolicy::new(&TaskClassifierConfig {
1350 threshold_step: 0.1,
1351 ..test_config(0.4)
1352 });
1353
1354 assert_eq!(
1355 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1356 "efficient"
1357 );
1358 assert_eq!(
1359 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1360 "capable"
1361 );
1362 assert_eq!(
1363 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1364 "efficient"
1365 );
1366 assert_eq!(
1367 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1368 "efficient"
1369 );
1370 assert_eq!(
1371 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1372 "capable"
1373 );
1374 assert_eq!(
1375 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1376 "efficient"
1377 );
1378 Ok(())
1379 }
1380
1381 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1384 Ok(StructuredJudge::new(
1385 TaskInput { recent_turn_window },
1386 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1387 SerdeDecoder::new(),
1388 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1389 ))
1390 }
1391
1392 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1393 let judge = capability_judge(Some(recent_turn_window))?;
1394 let request = Request {
1395 llm_request: LlmRequest {
1396 messages: vec![
1397 Message::text(Role::System, "client instructions"),
1398 Message::text(Role::User, "initial task"),
1399 Message::text(Role::Assistant, "old response"),
1400 Message::text(Role::User, "old follow-up"),
1401 Message::text(Role::Assistant, "recent 1"),
1402 Message::text(Role::User, "recent 2"),
1403 ],
1404 ..LlmRequest::default()
1405 },
1406 raw_request: None,
1407 metadata: None,
1408 };
1409 Ok(judge
1410 .build_request(&State::default(), &request)
1411 .llm_request
1412 .messages
1413 .iter()
1414 .filter_map(|message| message.text_content("\n"))
1415 .collect())
1416 }
1417
1418 #[test]
1419 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1420 let contents = judged_contents(2)?;
1422 assert!(contents.contains(&"client instructions".to_string()));
1423 assert!(contents.contains(&"initial task".to_string()));
1424 assert!(contents.contains(&"recent 1".to_string()));
1425 assert!(contents.contains(&"recent 2".to_string()));
1426 assert!(!contents.contains(&"old response".to_string()));
1427 Ok(())
1428 }
1429
1430 #[test]
1431 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1432 let contents = judged_contents(0)?;
1433 assert!(contents.contains(&"client instructions".to_string()));
1434 assert!(contents.contains(&"initial task".to_string()));
1435 assert!(!contents.contains(&"recent 2".to_string()));
1436 Ok(())
1437 }
1438
1439 fn tool_call(id: &str) -> Message {
1440 Message {
1441 role: Role::Assistant,
1442 content: vec![ContentBlock::ToolCall(ToolCall {
1443 id: id.to_string(),
1444 name: "search".to_string(),
1445 arguments: Value::Null,
1446 })],
1447 }
1448 }
1449
1450 fn tool_result(id: &str) -> Message {
1451 Message {
1452 role: Role::Tool,
1453 content: vec![ContentBlock::ToolResult(ToolResult {
1454 tool_call_id: id.to_string(),
1455 content: vec![ContentBlock::Text {
1456 text: "tool output".to_string(),
1457 }],
1458 is_error: None,
1459 })],
1460 }
1461 }
1462
1463 #[test]
1464 fn default_task_input_keeps_user_content_around_tool_results() {
1465 let mut result = tool_result("call-1");
1466 result.role = Role::User;
1467 let mut mixed = result.clone();
1468 mixed.content.push(ContentBlock::Text {
1469 text: "latest follow-up".to_string(),
1470 });
1471 let input = TaskInput {
1472 recent_turn_window: None,
1473 };
1474 let mut request = Request {
1475 llm_request: LlmRequest {
1476 messages: vec![
1477 result.clone(),
1478 Message::text(Role::User, "initial task"),
1479 tool_call("call-1"),
1480 mixed,
1481 result.clone(),
1482 ],
1483 ..LlmRequest::default()
1484 },
1485 ..Request::default()
1486 };
1487 assert_eq!(
1488 input.build_messages(&State::default(), &request),
1489 vec![
1490 Message::text(Role::User, "initial task"),
1491 Message::text(Role::User, "latest follow-up"),
1492 ]
1493 );
1494 request.llm_request.messages = vec![result];
1495 assert!(input.build_messages(&State::default(), &request).is_empty());
1496 }
1497
1498 #[test]
1501 fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1502 let messages = vec![
1503 Message::text(Role::System, "client instructions"),
1504 Message::text(Role::User, "initial task"),
1505 Message::text(Role::Assistant, "old response"),
1506 tool_call("call-1"),
1507 tool_result("call-1"),
1508 Message::text(Role::Assistant, "recent 1"),
1509 Message::text(Role::User, "recent 2"),
1510 Message::text(Role::Assistant, "recent 3"),
1511 Message::text(Role::User, "recent 4"),
1512 ];
1513
1514 let kept = trim_messages(&messages, 5);
1516
1517 assert_eq!(
1518 kept,
1519 vec![
1520 Message::text(Role::System, "client instructions"),
1521 Message::text(Role::User, "initial task"),
1522 tool_call("call-1"),
1523 tool_result("call-1"),
1524 Message::text(Role::Assistant, "recent 1"),
1525 Message::text(Role::User, "recent 2"),
1526 Message::text(Role::Assistant, "recent 3"),
1527 Message::text(Role::User, "recent 4"),
1528 ]
1529 );
1530 }
1531
1532 #[test]
1535 fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1536 let messages = vec![
1537 Message::text(Role::System, "client instructions"),
1538 Message::text(Role::User, "initial task"),
1539 tool_call("x"),
1540 tool_result("x"),
1541 Message::text(Role::Assistant, "later"),
1542 tool_call("x"),
1543 tool_result("x"),
1544 ];
1545
1546 let kept = trim_messages(&messages, 4);
1548
1549 assert_eq!(
1550 kept,
1551 vec![
1552 Message::text(Role::System, "client instructions"),
1553 Message::text(Role::User, "initial task"),
1554 tool_call("x"),
1555 tool_result("x"),
1556 Message::text(Role::Assistant, "later"),
1557 tool_call("x"),
1558 tool_result("x"),
1559 ]
1560 );
1561 }
1562
1563 #[test]
1566 fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1567 let messages = vec![
1568 Message::text(Role::System, "client instructions"),
1569 tool_call("orphan"),
1570 Message::text(Role::User, "initial task"),
1571 Message::text(Role::Assistant, "old response"),
1572 tool_result("orphan"),
1573 Message::text(Role::Assistant, "recent 1"),
1574 Message::text(Role::User, "recent 2"),
1575 ];
1576
1577 let kept = trim_messages(&messages, 3);
1578
1579 assert_eq!(
1580 kept,
1581 vec![
1582 Message::text(Role::System, "client instructions"),
1583 Message::text(Role::User, "initial task"),
1584 tool_result("orphan"),
1585 Message::text(Role::Assistant, "recent 1"),
1586 Message::text(Role::User, "recent 2"),
1587 ]
1588 );
1589 }
1590
1591 #[test]
1592 fn a_window_restates_the_routing_instruction_last() -> Result<()> {
1593 let contents = judged_contents(2)?;
1594
1595 assert_eq!(
1598 contents.last().map(String::as_str),
1599 Some(TRAILING_ROUTING_INSTRUCTION)
1600 );
1601 Ok(())
1602 }
1603
1604 #[test]
1609 fn a_window_drops_reasoning_but_keeps_visible_text_and_tool_pairs() {
1610 let messages = vec![
1611 Message::text(Role::System, "client instructions"),
1612 Message::text(Role::User, "initial task"),
1613 Message {
1614 role: Role::Assistant,
1615 content: vec![
1616 ContentBlock::Reasoning {
1617 text: "private chain of thought".to_string(),
1618 signature: None,
1619 details: Vec::new(),
1620 },
1621 ContentBlock::Text {
1622 text: "visible answer".to_string(),
1623 },
1624 ],
1625 },
1626 tool_call("call-1"),
1627 tool_result("call-1"),
1628 Message {
1629 role: Role::Assistant,
1630 content: vec![ContentBlock::Reasoning {
1631 text: "reasoning-only turn".to_string(),
1632 signature: None,
1633 details: Vec::new(),
1634 }],
1635 },
1636 Message::text(Role::User, "follow-up"),
1637 ];
1638 let request = Request {
1639 llm_request: LlmRequest {
1640 messages,
1641 ..LlmRequest::default()
1642 },
1643 raw_request: None,
1644 metadata: None,
1645 };
1646
1647 let built = TaskInput {
1648 recent_turn_window: Some(10),
1649 }
1650 .build_messages(&State::default(), &request);
1651
1652 assert!(
1653 !built
1654 .iter()
1655 .flat_map(|message| &message.content)
1656 .any(|block| matches!(block, ContentBlock::Reasoning { .. })),
1657 "{built:?}"
1658 );
1659 assert!(
1660 built
1661 .iter()
1662 .any(|message| message.text_content("\n").as_deref() == Some("visible answer"))
1663 );
1664 assert!(built.contains(&tool_call("call-1")));
1665 assert!(built.contains(&tool_result("call-1")));
1666 assert_eq!(built.len(), 7);
1669 assert!(built.iter().all(|message| !message.content.is_empty()));
1670 }
1671
1672 #[test]
1673 fn the_default_path_is_left_unchanged() -> Result<()> {
1674 let judge = capability_judge(None)?;
1677 let request = Request {
1678 llm_request: LlmRequest {
1679 messages: vec![Message::text(Role::User, "the task")],
1680 ..LlmRequest::default()
1681 },
1682 raw_request: None,
1683 metadata: None,
1684 };
1685
1686 let built = judge.build_request(&State::default(), &request);
1687
1688 assert_eq!(built.llm_request.messages.len(), 1);
1691 assert!(!built.llm_request.instructions.is_empty());
1692 assert!(
1693 !built
1694 .llm_request
1695 .messages
1696 .iter()
1697 .filter_map(|message| message.text_content("\n"))
1698 .any(|text| text.contains(TRAILING_ROUTING_INSTRUCTION))
1699 );
1700 Ok(())
1701 }
1702
1703 #[test]
1704 fn capability_judge_builds_a_structured_request() -> Result<()> {
1705 let judge = capability_judge(None)?;
1706 let request = Request {
1707 llm_request: LlmRequest {
1708 model: Some("inbound".to_string()),
1709 messages: vec![
1710 Message::text(Role::System, "client instructions"),
1711 Message::text(Role::Developer, "client developer instructions"),
1712 Message::text(Role::User, "initial task"),
1713 Message::text(Role::Assistant, "old response"),
1714 Message::text(Role::User, "old follow-up"),
1715 Message::text(Role::Assistant, "recent 1"),
1716 Message::text(Role::User, "recent 2"),
1717 Message::text(Role::Assistant, "recent 3"),
1718 Message::text(Role::User, "recent 4"),
1719 Message::text(Role::Assistant, "recent 5"),
1720 ],
1721 ..LlmRequest::default()
1722 },
1723 raw_request: None,
1724 metadata: None,
1725 };
1726 let judge_request = judge.build_request(&State::default(), &request);
1727
1728 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1729 assert_eq!(judge_request.llm_request.instructions.len(), 1);
1730 assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
1731 assert_eq!(
1732 judge_request.llm_request.instructions[0].content,
1733 InstructionBlock {
1734 role: Role::System,
1735 content: Message::text(Role::System, judge.contract().system_prompt()).content,
1736 }
1737 .content,
1738 );
1739 assert_eq!(judge_request.llm_request.messages.len(), 2);
1740 let contents = judge_request
1741 .llm_request
1742 .messages
1743 .iter()
1744 .filter_map(|message| message.text_content("\n"))
1745 .collect::<Vec<_>>();
1746 assert!(contents.contains(&"recent 4".to_string()));
1747 assert!(contents.contains(&"initial task".to_string()));
1748 assert!(!contents.contains(&"recent 5".to_string()));
1749 assert!(!contents.contains(&"client instructions".to_string()));
1750 assert_eq!(
1751 judge_request.llm_request.output.response_format,
1752 Some(judge.contract().response_format().clone())
1753 );
1754 assert_eq!(
1755 judge_request.llm_request.output.max_output_tokens,
1756 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1757 );
1758 Ok(())
1759 }
1760
1761 fn sample_value(spec: &Value) -> Value {
1762 if let Some(first) = spec
1763 .get("enum")
1764 .and_then(Value::as_array)
1765 .and_then(|values| values.first())
1766 {
1767 return first.clone();
1768 }
1769 match spec.get("type").and_then(Value::as_str) {
1770 Some("number") => serde_json::json!(0.5),
1771 Some("boolean") => serde_json::json!(false),
1772 _ => serde_json::json!("sample"),
1773 }
1774 }
1775
1776 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1777 let properties = schema
1778 .pointer("/json_schema/schema/properties")
1779 .and_then(Value::as_object)
1780 .ok_or_else(|| LibsyError::AlgorithmError {
1781 message: "packaged schema declares no properties".to_string(),
1782 })?;
1783 Ok(Value::Object(
1784 properties
1785 .iter()
1786 .map(|(name, spec)| (name.clone(), sample_value(spec)))
1787 .collect(),
1788 )
1789 .to_string())
1790 }
1791
1792 #[test]
1795 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1796 let contract =
1797 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1798 let schema = contract.response_format();
1799 let reply = schema_shaped_verdict(schema)?;
1800 let judge: CapabilityJudge = StructuredJudge::new(
1801 TaskInput {
1802 recent_turn_window: None,
1803 },
1804 contract,
1805 SerdeDecoder::new(),
1806 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1807 );
1808
1809 let verdict = judge.parse(&text_response(None, reply))?;
1810
1811 assert!(verdict.is_valid());
1812 assert!((0.0..=1.0).contains(&verdict.p_solve));
1813 Ok(())
1814 }
1815
1816 #[test]
1817 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1818 let contract =
1819 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1820 let prompt = contract.system_prompt();
1821 let schema_name = contract
1822 .response_format()
1823 .pointer("/json_schema/name")
1824 .and_then(Value::as_str)
1825 .ok_or_else(|| LibsyError::AlgorithmError {
1826 message: "packaged response schema has no name".to_string(),
1827 })?;
1828 assert_eq!(schema_name, "CapabilityClassifierDecision");
1829 assert!(prompt.contains("SUP-1 [supported]"));
1830 assert!(prompt.contains("SUP-5 [supported]"));
1831 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1832 assert!(!prompt.contains("\"type\": \"object\""));
1833 assert!(!prompt.contains("\"json_schema\""));
1834 assert!(!prompt.contains(schema_name));
1835 let rule_values = contract
1836 .response_format()
1837 .pointer("/json_schema/schema/properties/primary_rule/enum")
1838 .and_then(Value::as_array)
1839 .ok_or_else(|| LibsyError::AlgorithmError {
1840 message: "rendered response schema has no primary rule enum".to_string(),
1841 })?;
1842 assert!(
1843 rule_values
1844 .iter()
1845 .any(|value| value.as_str() == Some("SUP-1"))
1846 );
1847 assert!(
1848 rule_values
1849 .iter()
1850 .any(|value| value.as_str() == Some("none"))
1851 );
1852 Ok(())
1853 }
1854}