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, ToolResult};
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;
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
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 is_task_content(block: &ContentBlock) -> bool {
153 !matches!(
154 block,
155 ContentBlock::ToolCall(_) | ContentBlock::ToolResult(_) | ContentBlock::Reasoning { .. }
156 )
157}
158
159fn task_messages(messages: &[Message]) -> Vec<&Message> {
161 let mut user_messages = messages.iter().filter(|message| {
162 message.role == Role::User && message.content.iter().any(is_task_content)
163 });
164 let Some(opening_task) = user_messages.next() else {
165 return Vec::new();
166 };
167 [Some(opening_task), user_messages.next_back()]
168 .into_iter()
169 .flatten()
170 .collect()
171}
172
173struct TaskInput {
175 recent_turn_window: Option<usize>,
176 judge_max_images: Option<usize>,
177}
178
179impl ClassifierInput for TaskInput {
180 fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
181 let selected = match self.recent_turn_window {
184 Some(window) => trim_messages(&request.llm_request.messages, window),
185 None => task_messages(&request.llm_request.messages),
186 };
187 let mut remaining = self.judge_max_images;
188 let mut messages: Vec<_> = selected
191 .into_iter()
192 .rev()
193 .filter_map(|message| {
194 let mut content: Vec<_> = message
195 .content
196 .iter()
197 .rev()
198 .filter(|block| {
199 if self.recent_turn_window.is_none() {
200 is_task_content(block)
201 } else {
202 !matches!(block, ContentBlock::Reasoning { .. })
203 }
204 })
205 .map(|block| copy_judge_block(block, &mut remaining))
206 .collect();
207 content.reverse();
208 (!content.is_empty()).then_some(Message {
209 role: message.role,
210 content,
211 })
212 })
213 .collect();
214 messages.reverse();
215 if self.recent_turn_window.is_some() {
219 messages.push(Message::text(
220 Role::User,
221 TRAILING_ROUTING_INSTRUCTION.to_string(),
222 ));
223 }
224 messages
225 }
226}
227
228fn copy_judge_block(block: &ContentBlock, remaining: &mut Option<usize>) -> ContentBlock {
230 match block {
231 ContentBlock::Image { .. } if *remaining == Some(0) => ContentBlock::Text {
232 text: "[Image omitted from judge input.]".to_string(),
233 },
234 ContentBlock::Image { .. } => {
235 if let Some(count) = remaining {
236 *count -= 1;
237 }
238 block.clone()
239 }
240 ContentBlock::ToolResult(result) if remaining.is_some() => {
241 let mut content: Vec<_> = result
242 .content
243 .iter()
244 .rev()
245 .map(|block| copy_judge_block(block, remaining))
246 .collect();
247 content.reverse();
248 ContentBlock::ToolResult(ToolResult {
249 tool_call_id: result.tool_call_id.clone(),
250 content,
251 is_error: result.is_error,
252 })
253 }
254 _ => block.clone(),
255 }
256}
257
258struct TaskClassifierPolicy {
259 base_threshold: f64,
260 threshold_step: f64,
261}
262
263impl TaskClassifierPolicy {
264 fn new(config: &TaskClassifierConfig) -> Self {
265 Self {
266 base_threshold: config.base_threshold,
267 threshold_step: config.threshold_step,
268 }
269 }
270
271 fn threshold(&self, verdict: &TaskClassifierVerdict) -> Option<f64> {
273 Some(self.base_threshold + f64::from(verdict.boundary_steps()?) * self.threshold_step)
274 }
275}
276
277impl JudgePolicy for TaskClassifierPolicy {
278 type Verdict = TaskClassifierVerdict;
279
280 fn to_classification(
281 &self,
282 verdict: Option<&Self::Verdict>,
283 driver: &Driver,
284 ) -> Result<Classification> {
285 let Some(verdict) = verdict.filter(|verdict| verdict.is_valid()) else {
288 return Ok(Classification::Ambiguous(vec![]));
289 };
290 let Some(threshold) = self.threshold(verdict) else {
293 return Ok(Classification::Ambiguous(vec![]));
294 };
295 let category = if verdict.p_solve >= threshold
296 || (threshold - verdict.p_solve).abs() <= f64::EPSILON
297 {
298 Category::Efficient
299 } else {
300 Category::Capable
301 };
302 let Some(target) = driver.models_for(&category).first().cloned() else {
305 return Ok(Classification::Ambiguous(vec![]));
306 };
307 Ok(Classification::Scores(vec![Score {
308 target,
309 confidence: 1.0,
310 category: Some(category),
311 }]))
312 }
313}
314
315fn capability_evidence(
317 policy: &TaskClassifierPolicy,
318 verdict: Option<&TaskClassifierVerdict>,
319) -> Option<Value> {
320 let verdict = verdict?;
321 let Some(threshold) = verdict
322 .is_valid()
323 .then(|| policy.threshold(verdict))
324 .flatten()
325 else {
326 return Some(serde_json::json!({
327 "source": "fail_open",
328 "reason_code": "invalid_verdict",
329 }));
330 };
331 Some(serde_json::json!({
332 "source": "llm-classifier",
333 "score": verdict.p_solve,
334 "threshold": threshold,
335 }))
336}
337
338#[derive(Clone, Debug)]
339pub struct TaskClassifierConfig {
341 pub base_threshold: f64,
343 pub threshold_step: f64,
348 pub classify_trigger: ClassifyTrigger,
350 pub message_hash_fallback: bool,
352 pub recent_turn_window: Option<usize>,
359 pub judge_max_images: Option<usize>,
362 pub contract: ClassifierContractConfig,
364 pub max_output_tokens: u64,
366}
367
368#[derive(Deserialize)]
370#[serde(deny_unknown_fields)]
371struct TaskClassifierConfigWire {
372 base_threshold: f64,
373 #[serde(default)]
374 threshold_step: f64,
375 #[serde(default)]
376 classify_trigger: ClassifyTrigger,
377 #[serde(default)]
378 message_hash_fallback: bool,
379 #[serde(default)]
380 recent_turn_window: Option<usize>,
381 #[serde(default)]
382 judge_max_images: Option<usize>,
383 #[serde(default)]
384 prompt: Option<String>,
385 #[serde(default)]
386 response_format_type: ClassifierResponseFormat,
387 #[serde(default = "default_judge_max_output_tokens")]
388 max_output_tokens: u64,
389}
390
391impl<'de> Deserialize<'de> for TaskClassifierConfig {
392 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
393 where
394 D: Deserializer<'de>,
395 {
396 let wire = TaskClassifierConfigWire::deserialize(deserializer)?;
397 let mut contract = ClassifierContractConfig::default();
398 if let Some(prompt) = wire.prompt {
399 contract = contract.with_prompt(prompt);
400 }
401 contract = contract.with_response_format_type(wire.response_format_type);
402 Ok(Self {
403 base_threshold: wire.base_threshold,
404 threshold_step: wire.threshold_step,
405 classify_trigger: wire.classify_trigger,
406 message_hash_fallback: wire.message_hash_fallback,
407 recent_turn_window: wire.recent_turn_window,
408 judge_max_images: wire.judge_max_images,
409 contract,
410 max_output_tokens: wire.max_output_tokens,
411 })
412 }
413}
414
415const fn default_judge_max_output_tokens() -> u64 {
416 DEFAULT_JUDGE_MAX_OUTPUT_TOKENS
417}
418
419impl Default for TaskClassifierConfig {
420 fn default() -> Self {
421 Self {
422 base_threshold: 0.0,
423 threshold_step: 0.0,
424 classify_trigger: ClassifyTrigger::default(),
425 message_hash_fallback: false,
426 recent_turn_window: None,
427 judge_max_images: None,
428 contract: ClassifierContractConfig::default(),
429 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
430 }
431 }
432}
433
434impl TaskClassifierConfig {
435 fn validate(&self) -> Result<()> {
437 if !(0.0..=1.0).contains(&self.base_threshold) {
438 return Err(LibsyError::AlgorithmError {
439 message: format!(
440 "base_threshold must be between 0 and 1, got {}",
441 self.base_threshold
442 ),
443 });
444 }
445 if !self.threshold_step.is_finite() || self.threshold_step < 0.0 {
446 return Err(LibsyError::AlgorithmError {
447 message: format!(
448 "threshold_step must be finite and greater than or equal to 0, got {}",
449 self.threshold_step
450 ),
451 });
452 }
453 let unsupported_threshold = self.base_threshold + 2.0 * self.threshold_step;
454 if unsupported_threshold > 1.0 && unsupported_threshold - 1.0 > f64::EPSILON {
455 return Err(LibsyError::AlgorithmError {
456 message: format!(
457 "base_threshold + 2 * threshold_step must be at most 1, got {unsupported_threshold}"
458 ),
459 });
460 }
461 if self.max_output_tokens == 0 {
462 return Err(LibsyError::AlgorithmError {
463 message: "max_output_tokens must be at least 1".to_string(),
464 });
465 }
466 if self.message_hash_fallback && self.classify_trigger != ClassifyTrigger::NewSession {
467 return Err(LibsyError::AlgorithmError {
468 message: "message_hash_fallback requires classify_trigger = new_session"
469 .to_string(),
470 });
471 }
472 Ok(())
473 }
474}
475
476#[derive(Clone, Debug)]
478pub enum CustomClassifierPolicy {
479 TargetSelector {
481 selector: String,
483 },
484}
485
486impl CustomClassifierPolicy {
487 pub fn target_selector(selector: impl Into<String>) -> Self {
489 Self::TargetSelector {
490 selector: selector.into(),
491 }
492 }
493}
494
495#[derive(Clone, Debug)]
497pub struct CustomClassifierConfig {
498 pub prompt: String,
500 pub response_schema: Value,
502 pub policy: CustomClassifierPolicy,
504 pub classify_trigger: ClassifyTrigger,
506 pub message_hash_fallback: bool,
508 pub recent_turn_window: Option<usize>,
510 pub judge_max_images: Option<usize>,
513 pub max_output_tokens: u64,
515}
516
517impl CustomClassifierConfig {
518 pub fn new(
520 prompt: impl Into<String>,
521 response_schema: Value,
522 policy: CustomClassifierPolicy,
523 ) -> Self {
524 Self {
525 prompt: prompt.into(),
526 response_schema,
527 policy,
528 classify_trigger: ClassifyTrigger::default(),
529 message_hash_fallback: false,
530 recent_turn_window: None,
531 judge_max_images: None,
532 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
533 }
534 }
535
536 fn validate(&self) -> Result<()> {
537 if self.max_output_tokens == 0 {
538 return Err(LibsyError::AlgorithmError {
539 message: "max_output_tokens must be at least 1".to_string(),
540 });
541 }
542 if self.message_hash_fallback && self.classify_trigger != ClassifyTrigger::NewSession {
543 return Err(LibsyError::AlgorithmError {
544 message: "message_hash_fallback requires classify_trigger = new_session"
545 .to_string(),
546 });
547 }
548 Ok(())
549 }
550}
551
552enum CustomPolicyRuntime {
553 TargetSelector(TargetSelectorPolicy),
554}
555
556impl JudgePolicy for CustomPolicyRuntime {
557 type Verdict = Value;
558
559 fn to_classification(
560 &self,
561 verdict: Option<&Self::Verdict>,
562 driver: &Driver,
563 ) -> Result<Classification> {
564 match self {
565 Self::TargetSelector(policy) => policy.to_classification(verdict, driver),
566 }
567 }
568}
569
570fn affinity_router(
572 trigger: ClassifyTrigger,
573 message_hash_fallback: bool,
574) -> Option<Arc<AffinityRouter>> {
575 let router = match trigger {
576 ClassifyTrigger::EveryRequest => return None,
577 ClassifyTrigger::NewSession => AffinityRouter::new(),
578 ClassifyTrigger::UserTurn => AffinityRouter::new().with_release_on_user_turn(),
579 };
580 let router = if message_hash_fallback {
581 router.with_message_hash_fallback()
582 } else {
583 router
584 };
585 Some(Arc::new(router))
586}
587
588pub struct LlmTaskClassifier {
590 route: FallThrough<State>,
591 inner: Arc<dyn Classifier<State>>,
593}
594
595struct ClassifierRouteConfig {
596 default_target: Category,
597 classify_trigger: ClassifyTrigger,
598 message_hash_fallback: bool,
599}
600
601pub struct DefaultCategoryClassifier(pub Category);
604
605#[async_trait]
606impl<S: Send> Classifier<S> for DefaultCategoryClassifier {
607 async fn score(
608 &self,
609 _state: &mut S,
610 _request: &mut Request,
611 driver: &Driver,
612 ) -> Result<(Classification, Option<Response>)> {
613 let target = driver.first_model_for(&self.0)?;
614 driver.set_evidence_if_empty(serde_json::json!({"source": "fall_open"}));
615 Ok((
616 Classification::Scores(vec![Score {
617 target: target.clone(),
618 confidence: 0.0,
619 category: Some(self.0.clone()),
620 }]),
621 None,
622 ))
623 }
624}
625
626#[derive(Clone)]
628#[non_exhaustive]
629pub enum LlmClassifierConfig {
630 Capability {
632 config: TaskClassifierConfig,
634 },
635 Escalation {
637 contract: ClassifierContractConfig,
639 config: EscalationJudgeConfig,
641 max_output_tokens: u64,
643 },
644 Custom {
646 default_target: Category,
648 config: CustomClassifierConfig,
650 },
651}
652
653impl LlmTaskClassifier {
654 pub fn new(config: LlmClassifierConfig) -> Result<Self> {
661 match config {
662 LlmClassifierConfig::Capability { config } => Self::build_capability(config),
663 LlmClassifierConfig::Escalation {
664 contract,
665 config,
666 max_output_tokens,
667 } => Self::build_escalation(contract, config, max_output_tokens),
668 LlmClassifierConfig::Custom {
669 default_target,
670 config,
671 } => Self::build_custom(default_target, config),
672 }
673 }
674
675 fn build_capability(config: TaskClassifierConfig) -> Result<Self> {
676 config.validate()?;
677 let contract = Self::load_capability_contract(&config.contract)?;
678 let classify_trigger = config.classify_trigger;
679 let message_hash_fallback = config.message_hash_fallback;
680 let classifier: Arc<dyn Classifier<State>> = Arc::new(
681 JudgeClassifier::new(
682 StructuredJudge::new(
683 TaskInput {
684 recent_turn_window: config.recent_turn_window,
685 judge_max_images: config.judge_max_images,
686 },
687 contract,
688 SerdeDecoder::new(),
689 JudgeRuntimeConfig::new(config.max_output_tokens)?,
690 ),
691 TaskClassifierPolicy::new(&config),
692 )
693 .with_evidence(capability_evidence),
694 );
695 Self::from_classifier(
696 classifier,
697 ClassifierRouteConfig {
698 default_target: Category::Capable,
699 classify_trigger,
700 message_hash_fallback,
701 },
702 )
703 }
704
705 fn build_custom(default_target: Category, config: CustomClassifierConfig) -> Result<Self> {
706 config.validate()?;
707 let CustomClassifierConfig {
708 prompt,
709 response_schema,
710 policy,
711 classify_trigger,
712 message_hash_fallback,
713 recent_turn_window,
714 judge_max_images,
715 max_output_tokens,
716 } = config;
717 let contract = ClassifierContract::from_inner_schema(&prompt, response_schema)?;
718 let policy = match policy {
719 CustomClassifierPolicy::TargetSelector { selector } => {
720 CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(selector)?)
721 }
722 };
723 let classifier: Arc<dyn Classifier<State>> = Arc::new(JudgeClassifier::new(
724 StructuredJudge::new(
725 TaskInput {
726 recent_turn_window,
727 judge_max_images,
728 },
729 contract,
730 JsonSchemaDecoder::new(),
731 JudgeRuntimeConfig::new(max_output_tokens)?,
732 ),
733 policy,
734 ));
735
736 Self::from_classifier(
737 classifier,
738 ClassifierRouteConfig {
739 default_target,
740 classify_trigger,
741 message_hash_fallback,
742 },
743 )
744 }
745
746 fn build_escalation(
747 contract_config: ClassifierContractConfig,
748 config: EscalationJudgeConfig,
749 max_output_tokens: u64,
750 ) -> Result<Self> {
751 let inner = escalation::build_classifier(contract_config, config, max_output_tokens)?;
752 Ok(Self {
753 route: FallThrough::<State>::new_with_state()
754 .with_name(ALGORITHM_NAME)
755 .with_classifier(Arc::clone(&inner)),
756 inner,
757 })
758 }
759
760 fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
762 ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
763 }
764
765 fn from_classifier(
767 inner: Arc<dyn Classifier<State>>,
768 config: ClassifierRouteConfig,
769 ) -> Result<Self> {
770 if config.message_hash_fallback && config.classify_trigger != ClassifyTrigger::NewSession {
771 return Err(LibsyError::AlgorithmError {
772 message: "message_hash_fallback requires classify_trigger = new_session"
773 .to_string(),
774 });
775 }
776 let mut route = FallThrough::<State>::new_with_state().with_name(ALGORITHM_NAME);
778 if let Some(affinity) =
779 affinity_router(config.classify_trigger, config.message_hash_fallback).as_ref()
780 {
781 route = route
783 .with_processor(affinity.clone())
784 .with_classifier(affinity.clone());
785 }
786 let fallback = DefaultCategoryClassifier(config.default_target);
787 Ok(Self {
788 route: route
789 .with_classifier(inner.clone())
790 .with_classifier(Arc::new(fallback)),
791 inner,
792 })
793 }
794}
795
796#[async_trait]
797impl Classifier<State> for LlmTaskClassifier {
798 async fn score(
799 &self,
800 state: &mut State,
801 request: &mut Request,
802 driver: &Driver,
803 ) -> Result<(Classification, Option<Response>)> {
804 self.inner.score(state, request, driver).await
805 }
806}
807
808#[async_trait]
809impl Algorithm for LlmTaskClassifier {
810 fn name(&self) -> &str {
811 "llm_task_classifier"
812 }
813
814 async fn route(
815 self: Arc<Self>,
816 driver: Driver,
817 request: Request,
818 ) -> Result<crate::RoutingOutcome> {
819 self.route.execute(driver, request).await
820 }
821}
822
823#[cfg(test)]
824mod tests {
825 use std::collections::HashMap;
826 use std::sync::Arc;
827
828 use parking_lot::Mutex;
829 use serde_json::Value;
830
831 use super::*;
832 use switchyard_protocol::{
833 ContentBlock, ImageSource, InstructionBlock, LlmClientError, LlmRequest, Metadata, ModelId,
834 ToolCall, ToolResult, completion_text, text_request, text_response,
835 };
836
837 use crate::algorithms::util::llm_judge::Judge;
838 use crate::core::testing::{Serve, test_drive_with_models};
839 use switchyard_protocol::{LlmResponse, Response};
840
841 const TEST_THRESHOLD: f64 = 0.5;
842
843 type CapabilityJudge = StructuredJudge<TaskInput, SerdeDecoder<TaskClassifierVerdict>>;
844
845 #[test]
847 fn judge_image_budget_keeps_the_newest_images_and_original_order() -> Result<()> {
848 let image = |name: &str| ContentBlock::Image {
849 source: ImageSource::Url {
850 url: format!("https://example.test/{name}.png"),
851 detail: None,
852 },
853 };
854 let mut request = classify_request();
855 request.llm_request.messages = vec![
856 Message {
857 role: Role::User,
858 content: vec![image("old")],
859 },
860 Message {
861 role: Role::User,
862 content: vec![
863 ContentBlock::Text {
864 text: "Compare the screenshots.".into(),
865 },
866 image("recent"),
867 ContentBlock::ToolResult(ToolResult {
868 tool_call_id: "screenshot".into(),
869 content: vec![image("newest")],
870 is_error: Some(false),
871 }),
872 ],
873 },
874 ];
875 let original = request.llm_request.messages.clone();
876 for (limit, expected) in [
877 (None, 3),
878 (Some(0), 0),
879 (Some(1), 1),
880 (Some(2), 2),
881 (Some(9), 3),
882 ] {
883 let messages = TaskInput {
884 recent_turn_window: Some(10),
885 judge_max_images: limit,
886 }
887 .build_messages(&State::default(), &request);
888 let serialized =
889 serde_json::to_string(&messages).map_err(|e| LibsyError::AlgorithmError {
890 message: e.to_string(),
891 })?;
892 assert_eq!(
893 serialized.matches("https://example.test/").count(),
894 expected
895 );
896 assert_eq!(serialized.contains("/old.png"), expected == 3);
897 assert_eq!(serialized.contains("/recent.png"), expected >= 2);
898 assert_eq!(serialized.contains("/newest.png"), expected >= 1);
899 assert!(serialized.contains("Compare the screenshots."));
900 assert_eq!(messages[0].role, Role::User);
901 assert!(!messages[0].content.is_empty());
902 if expected == 3 {
903 assert_eq!(&messages[..original.len()], &original);
904 }
905 if expected == 2 {
906 assert_eq!(messages[1], original[1]);
907 }
908 assert_eq!(request.llm_request.messages, original);
909 }
910 Ok(())
911 }
912
913 #[test]
915 fn task_only_image_budget_ignores_tool_images() {
916 let image = ContentBlock::Image {
917 source: ImageSource::Url {
918 url: "data:image/jpeg;base64,aW1hZ2U=".into(),
919 detail: Some("low".into()),
920 },
921 };
922 let text = ContentBlock::Text {
923 text: "Inspect this image.".into(),
924 };
925 let mut request = classify_request();
926 request.llm_request.messages = vec![Message {
927 role: Role::User,
928 content: vec![
929 text.clone(),
930 image.clone(),
931 ContentBlock::ToolResult(ToolResult {
932 tool_call_id: "screenshot".into(),
933 content: vec![image.clone()],
934 is_error: Some(true),
935 }),
936 ],
937 }];
938 let original = request.llm_request.messages.clone();
939 let messages = TaskInput {
940 recent_turn_window: None,
941 judge_max_images: Some(1),
942 }
943 .build_messages(&State::default(), &request);
944 assert_eq!(
945 messages,
946 vec![Message {
947 role: Role::User,
948 content: vec![text, image]
949 }]
950 );
951 assert_eq!(request.llm_request.messages, original);
952 }
953
954 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
955 TaskClassifierConfig {
956 base_threshold,
957 ..TaskClassifierConfig::default()
958 }
959 }
960
961 fn policy() -> TaskClassifierPolicy {
962 TaskClassifierPolicy::new(&test_config(TEST_THRESHOLD))
963 }
964
965 fn runtime_models() -> HashMap<Category, Vec<ModelId>> {
966 [
967 (Category::Judge, vec![ModelId::from("judge")]),
968 (Category::Efficient, vec![ModelId::from("efficient")]),
969 (Category::Capable, vec![ModelId::from("capable")]),
970 (
971 Category::Any,
972 vec![ModelId::from("efficient"), ModelId::from("capable")],
973 ),
974 ]
975 .into()
976 }
977
978 fn policy_driver() -> Driver {
979 Driver::new("test", Arc::new(runtime_models().into())).0
980 }
981
982 fn verdict(
983 p_solve: f64,
984 capability_boundary: &str,
985 primary_rule: &str,
986 ) -> TaskClassifierVerdict {
987 TaskClassifierVerdict {
988 crux: "test crux".to_string(),
989 primary_rule: primary_rule.to_string(),
990 capability_boundary: capability_boundary.to_string(),
991 p_solve,
992 }
993 }
994
995 fn selected(
996 policy: &TaskClassifierPolicy,
997 verdict: Option<&TaskClassifierVerdict>,
998 ) -> Result<ModelId> {
999 policy
1000 .to_classification(verdict, &policy_driver())?
1001 .argmax(false)?
1002 .map(|score| score.target)
1003 .ok_or_else(|| LibsyError::AlgorithmError {
1004 message: "policy abstained".to_string(),
1005 })
1006 }
1007
1008 #[derive(Default)]
1011 struct Recorder {
1012 calls: Mutex<Vec<String>>,
1013 call_roles: Mutex<Vec<(String, bool)>>,
1014 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
1015 judge_system_prompts: Mutex<Vec<String>>,
1016 }
1017
1018 impl Recorder {
1019 fn calls(&self) -> Vec<String> {
1020 self.calls.lock().clone()
1021 }
1022
1023 fn call_roles(&self) -> Vec<(String, bool)> {
1024 self.call_roles.lock().clone()
1025 }
1026
1027 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
1028 self.judge_max_output_tokens.lock().clone()
1029 }
1030
1031 fn judge_system_prompts(&self) -> Vec<String> {
1032 self.judge_system_prompts.lock().clone()
1033 }
1034
1035 fn serve(self: &Arc<Self>) -> impl Serve {
1036 let recorder = Arc::clone(self);
1037 move |model: ModelId, request: Request| {
1038 let recorder = Arc::clone(&recorder);
1039 async move {
1040 let model = model.to_string();
1041 recorder.calls.lock().push(model.clone());
1042 recorder
1043 .call_roles
1044 .lock()
1045 .push((model.clone(), model != "judge"));
1046 let completion = if model == "judge" {
1047 recorder
1048 .judge_max_output_tokens
1049 .lock()
1050 .push(request.llm_request.output.max_output_tokens);
1051 recorder.judge_system_prompts.lock().extend(
1052 request
1053 .llm_request
1054 .instructions
1055 .first()
1056 .and_then(|instruction| {
1057 instruction.content.iter().find_map(|b| {
1058 if let ContentBlock::Text { text } = b {
1059 Some(text.clone())
1060 } else {
1061 None
1062 }
1063 })
1064 }),
1065 );
1066 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1067 } else {
1068 format!("answer from {model}")
1069 };
1070 Ok(Response {
1071 llm_response: LlmResponse::Agg(text_response(None, completion)),
1072 metadata: request.metadata,
1073 upstream_headers: http::HeaderMap::new(),
1074 })
1075 }
1076 }
1077 }
1078 }
1079
1080 fn unreachable_judge() -> impl Serve {
1082 |model: ModelId, request: Request| async move {
1083 let model = model.to_string();
1084 if model == "judge" {
1085 return Err(LlmClientError::Timeout {
1086 source: Box::new(std::io::Error::other("judge unreachable")),
1087 });
1088 }
1089 Ok(Response {
1090 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1091 metadata: request.metadata,
1092 upstream_headers: http::HeaderMap::new(),
1093 })
1094 }
1095 }
1096
1097 fn router() -> Result<Arc<LlmTaskClassifier>> {
1098 Ok(Arc::new(LlmTaskClassifier::new(
1099 LlmClassifierConfig::Capability {
1100 config: test_config(TEST_THRESHOLD),
1101 },
1102 )?))
1103 }
1104
1105 fn classify_request() -> Request {
1106 Request {
1107 llm_request: text_request(Some("auto".to_string()), "classify this task"),
1108 raw_request: None,
1109 metadata: None,
1110 }
1111 }
1112
1113 fn classify_session_request() -> Request {
1114 Request {
1115 metadata: Some(Metadata {
1116 session_id: Some("session-1".to_string()),
1117 ..Metadata::default()
1118 }),
1119 ..classify_request()
1120 }
1121 }
1122
1123 fn classify_follow_up_request() -> Request {
1124 let mut request = classify_request();
1125 request
1126 .llm_request
1127 .messages
1128 .push(Message::text(Role::Assistant, "I will add the test."));
1129 request.llm_request.messages.push(Message::text(
1130 Role::User,
1131 "Now run the test suite and report the result.",
1132 ));
1133 request
1134 }
1135
1136 #[tokio::test]
1137 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1138 let router = router()?;
1139
1140 let (selected_model, response) = test_drive_with_models(
1141 router,
1142 classify_request(),
1143 runtime_models(),
1144 unreachable_judge(),
1145 )
1146 .await?;
1147
1148 assert_eq!(selected_model, "capable");
1149 assert_eq!(
1150 response.llm_response.as_agg().map(completion_text),
1151 Some("answer from capable".to_string())
1152 );
1153 Ok(())
1154 }
1155
1156 #[tokio::test]
1157 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1158 let recorder = Arc::new(Recorder::default());
1159 let router = router()?;
1160 let request = classify_request();
1161 let models = runtime_models();
1162
1163 test_drive_with_models(
1164 router.clone(),
1165 request.clone(),
1166 models.clone(),
1167 recorder.serve(),
1168 )
1169 .await?;
1170 test_drive_with_models(router, request, models, recorder.serve()).await?;
1171
1172 assert_eq!(
1173 recorder.calls(),
1174 vec!["judge", "efficient", "judge", "efficient"]
1175 );
1176 assert_eq!(
1177 recorder.call_roles(),
1178 vec![
1179 ("judge".to_string(), false),
1180 ("efficient".to_string(), true),
1181 ("judge".to_string(), false),
1182 ("efficient".to_string(), true),
1183 ]
1184 );
1185 Ok(())
1186 }
1187
1188 #[tokio::test]
1189 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1190 let recorder = Arc::new(Recorder::default());
1191 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1192 config: TaskClassifierConfig {
1193 max_output_tokens: 512,
1194 ..test_config(TEST_THRESHOLD)
1195 },
1196 })?);
1197
1198 test_drive_with_models(
1199 router,
1200 classify_request(),
1201 runtime_models(),
1202 recorder.serve(),
1203 )
1204 .await?;
1205
1206 assert_eq!(recorder.judge_max_output_tokens(), vec![Some(512)]);
1207 Ok(())
1208 }
1209
1210 #[tokio::test]
1211 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1212 let recorder = Arc::new(Recorder::default());
1213 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1214 config: TaskClassifierConfig {
1215 contract: ClassifierContractConfig::default()
1216 .with_prompt("Custom capability rubric."),
1217 ..test_config(TEST_THRESHOLD)
1218 },
1219 })?);
1220
1221 test_drive_with_models(
1222 router,
1223 classify_request(),
1224 runtime_models(),
1225 recorder.serve(),
1226 )
1227 .await?;
1228
1229 let prompts = recorder.judge_system_prompts();
1230 assert_eq!(prompts.len(), 1);
1231 assert_eq!(prompts[0], "Custom capability rubric.");
1232 Ok(())
1233 }
1234
1235 #[tokio::test]
1236 async fn classifier_config_enables_new_session_trigger() -> Result<()> {
1237 let recorder = Arc::new(Recorder::default());
1238 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1239 config: TaskClassifierConfig {
1240 classify_trigger: ClassifyTrigger::NewSession,
1241 ..test_config(TEST_THRESHOLD)
1242 },
1243 })?);
1244
1245 let request = classify_session_request();
1246 let models = runtime_models();
1247 test_drive_with_models(
1248 router.clone(),
1249 request.clone(),
1250 models.clone(),
1251 recorder.serve(),
1252 )
1253 .await?;
1254 test_drive_with_models(router, request, models, recorder.serve()).await?;
1255
1256 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1257 Ok(())
1258 }
1259
1260 #[tokio::test]
1261 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1262 let recorder = Arc::new(Recorder::default());
1263 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1264 config: TaskClassifierConfig {
1265 classify_trigger: ClassifyTrigger::NewSession,
1266 message_hash_fallback: true,
1267 recent_turn_window: None,
1268 ..test_config(TEST_THRESHOLD)
1269 },
1270 })?);
1271
1272 let models = runtime_models();
1273 test_drive_with_models(
1274 router.clone(),
1275 classify_request(),
1276 models.clone(),
1277 recorder.serve(),
1278 )
1279 .await?;
1280 test_drive_with_models(
1281 router,
1282 classify_follow_up_request(),
1283 models,
1284 recorder.serve(),
1285 )
1286 .await?;
1287
1288 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1289 Ok(())
1290 }
1291
1292 #[tokio::test]
1293 async fn one_classifier_uses_each_requests_runtime_models() -> Result<()> {
1294 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1295 config: TaskClassifierConfig {
1296 classify_trigger: ClassifyTrigger::NewSession,
1297 ..test_config(TEST_THRESHOLD)
1298 },
1299 })?);
1300 let calls = Arc::new(Mutex::new(Vec::new()));
1301 let serve = |calls: Arc<Mutex<Vec<String>>>| {
1302 move |model: ModelId, _request: Request| {
1303 let calls = Arc::clone(&calls);
1304 async move {
1305 calls.lock().push(model.to_string());
1306 let text = if model.as_str().starts_with("judge-") {
1307 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1308 } else {
1309 model.to_string()
1310 };
1311 Ok(Response {
1312 llm_response: LlmResponse::Agg(text_response(None, text)),
1313 metadata: None,
1314 upstream_headers: Default::default(),
1315 })
1316 }
1317 }
1318 };
1319 let models = |suffix: &str| -> HashMap<Category, Vec<ModelId>> {
1320 [
1321 (
1322 Category::Judge,
1323 vec![ModelId::from(format!("judge-{suffix}"))],
1324 ),
1325 (
1326 Category::Efficient,
1327 vec![ModelId::from(format!("efficient-{suffix}"))],
1328 ),
1329 (
1330 Category::Capable,
1331 vec![ModelId::from(format!("capable-{suffix}"))],
1332 ),
1333 (
1334 Category::Any,
1335 vec![
1336 ModelId::from(format!("efficient-{suffix}")),
1337 ModelId::from(format!("capable-{suffix}")),
1338 ],
1339 ),
1340 ]
1341 .into()
1342 };
1343 let request = classify_session_request();
1344
1345 let (first, _) = test_drive_with_models(
1346 router.clone(),
1347 request.clone(),
1348 models("a"),
1349 serve(Arc::clone(&calls)),
1350 )
1351 .await?;
1352 let (second, _) =
1353 test_drive_with_models(router, request, models("b"), serve(Arc::clone(&calls))).await?;
1354
1355 assert_eq!(first, "efficient-a");
1356 assert_eq!(second, "efficient-b");
1357 assert_eq!(
1358 &*calls.lock(),
1359 &["judge-a", "efficient-a", "judge-b", "efficient-b"]
1360 );
1361 Ok(())
1362 }
1363
1364 #[test]
1365 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1366 let policy = policy();
1367 let at_threshold = verdict(0.5, "supported", "SUP-1");
1368 let below_threshold = verdict(0.49, "supported", "SUP-1");
1369 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1370 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1371 Ok(())
1372 }
1373
1374 #[test]
1375 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1376 let borderline = verdict(0.5, "supported", "SUP-1");
1377 let strict = TaskClassifierPolicy::new(&test_config(0.9));
1378 let lenient = TaskClassifierPolicy::new(&test_config(0.1));
1379 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1380 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1381 Ok(())
1382 }
1383
1384 #[test]
1385 fn classifier_config_rejects_unknown_fields() {
1386 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1387 "base_threshold": 0.5,
1388 "classifier_magic": true,
1389 }))
1390 .expect_err("unknown classifier fields must be rejected");
1391
1392 assert!(
1393 error
1394 .to_string()
1395 .contains("unknown field `classifier_magic`"),
1396 "{error}"
1397 );
1398 }
1399
1400 #[test]
1401 fn invalid_classifier_config_is_rejected() -> Result<()> {
1402 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1403 assert!(
1404 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1405 config: test_config(bad),
1406 })
1407 .is_err(),
1408 "base threshold {bad} should be rejected"
1409 );
1410 }
1411 for config in [
1412 TaskClassifierConfig {
1413 base_threshold: 0.5,
1414 threshold_step: -0.1,
1415 ..TaskClassifierConfig::default()
1416 },
1417 TaskClassifierConfig {
1418 base_threshold: 0.8,
1419 threshold_step: 0.11,
1420 ..TaskClassifierConfig::default()
1421 },
1422 TaskClassifierConfig {
1423 base_threshold: 0.5,
1424 message_hash_fallback: true,
1425 ..TaskClassifierConfig::default()
1426 },
1427 TaskClassifierConfig {
1428 base_threshold: 0.5,
1429 max_output_tokens: 0,
1430 ..TaskClassifierConfig::default()
1431 },
1432 ] {
1433 assert!(LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).is_err());
1434 }
1435 for base_threshold in [0.0, 1.0] {
1436 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1437 config: test_config(base_threshold),
1438 })?;
1439 }
1440 Ok(())
1441 }
1442
1443 #[test]
1444 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1445 let policy = policy();
1446 let inconsistent_rule = TaskClassifierVerdict {
1447 capability_boundary: "uncertain".to_string(),
1448 ..verdict(1.0, "supported", "SUP-1")
1449 };
1450 let empty_crux = TaskClassifierVerdict {
1451 crux: " ".to_string(),
1452 ..verdict(1.0, "supported", "SUP-1")
1453 };
1454 let unusable = [
1455 Some(verdict(1.1, "supported", "SUP-1")),
1456 Some(inconsistent_rule),
1457 Some(empty_crux),
1458 None,
1459 ];
1460 for verdict in unusable {
1461 let classification = policy.to_classification(verdict.as_ref(), &policy_driver())?;
1462 assert!(matches!(classification, Classification::Ambiguous(_)));
1463 assert!(classification.argmax(false)?.is_none());
1464 assert!(classification.argmax(true)?.is_none());
1465 }
1466 Ok(())
1467 }
1468
1469 #[test]
1470 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1471 let policy = TaskClassifierPolicy::new(&TaskClassifierConfig {
1472 threshold_step: 0.1,
1473 ..test_config(0.4)
1474 });
1475
1476 assert_eq!(
1477 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1478 "efficient"
1479 );
1480 assert_eq!(
1481 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1482 "capable"
1483 );
1484 assert_eq!(
1485 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1486 "efficient"
1487 );
1488 assert_eq!(
1489 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1490 "efficient"
1491 );
1492 assert_eq!(
1493 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1494 "capable"
1495 );
1496 assert_eq!(
1497 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1498 "efficient"
1499 );
1500 Ok(())
1501 }
1502
1503 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1506 Ok(StructuredJudge::new(
1507 TaskInput {
1508 recent_turn_window,
1509 judge_max_images: None,
1510 },
1511 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1512 SerdeDecoder::new(),
1513 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1514 ))
1515 }
1516
1517 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1518 let judge = capability_judge(Some(recent_turn_window))?;
1519 let request = Request {
1520 llm_request: LlmRequest {
1521 messages: vec![
1522 Message::text(Role::System, "client instructions"),
1523 Message::text(Role::User, "initial task"),
1524 Message::text(Role::Assistant, "old response"),
1525 Message::text(Role::User, "old follow-up"),
1526 Message::text(Role::Assistant, "recent 1"),
1527 Message::text(Role::User, "recent 2"),
1528 ],
1529 ..LlmRequest::default()
1530 },
1531 raw_request: None,
1532 metadata: None,
1533 };
1534 Ok(judge
1535 .build_request(&State::default(), &request)
1536 .llm_request
1537 .messages
1538 .iter()
1539 .filter_map(|message| message.text_content("\n"))
1540 .collect())
1541 }
1542
1543 #[test]
1544 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1545 let contents = judged_contents(2)?;
1547 assert!(contents.contains(&"client instructions".to_string()));
1548 assert!(contents.contains(&"initial task".to_string()));
1549 assert!(contents.contains(&"recent 1".to_string()));
1550 assert!(contents.contains(&"recent 2".to_string()));
1551 assert!(!contents.contains(&"old response".to_string()));
1552 Ok(())
1553 }
1554
1555 #[test]
1556 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1557 let contents = judged_contents(0)?;
1558 assert!(contents.contains(&"client instructions".to_string()));
1559 assert!(contents.contains(&"initial task".to_string()));
1560 assert!(!contents.contains(&"recent 2".to_string()));
1561 Ok(())
1562 }
1563
1564 fn tool_call(id: &str) -> Message {
1565 Message {
1566 role: Role::Assistant,
1567 content: vec![ContentBlock::ToolCall(ToolCall {
1568 id: id.to_string(),
1569 name: "search".to_string(),
1570 arguments: Value::Null,
1571 })],
1572 }
1573 }
1574
1575 fn tool_result(id: &str) -> Message {
1576 Message {
1577 role: Role::Tool,
1578 content: vec![ContentBlock::ToolResult(ToolResult {
1579 tool_call_id: id.to_string(),
1580 content: vec![ContentBlock::Text {
1581 text: "tool output".to_string(),
1582 }],
1583 is_error: None,
1584 })],
1585 }
1586 }
1587
1588 #[test]
1589 fn default_task_input_keeps_user_content_around_tool_results() {
1590 let mut result = tool_result("call-1");
1591 result.role = Role::User;
1592 let mut mixed = result.clone();
1593 mixed.content.push(ContentBlock::Text {
1594 text: "latest follow-up".to_string(),
1595 });
1596 let input = TaskInput {
1597 recent_turn_window: None,
1598 judge_max_images: None,
1599 };
1600 let mut request = Request {
1601 llm_request: LlmRequest {
1602 messages: vec![
1603 result.clone(),
1604 Message::text(Role::User, "initial task"),
1605 tool_call("call-1"),
1606 mixed,
1607 result.clone(),
1608 ],
1609 ..LlmRequest::default()
1610 },
1611 ..Request::default()
1612 };
1613 assert_eq!(
1614 input.build_messages(&State::default(), &request),
1615 vec![
1616 Message::text(Role::User, "initial task"),
1617 Message::text(Role::User, "latest follow-up"),
1618 ]
1619 );
1620 request.llm_request.messages = vec![result];
1621 assert!(input.build_messages(&State::default(), &request).is_empty());
1622 }
1623
1624 #[test]
1627 fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1628 let messages = vec![
1629 Message::text(Role::System, "client instructions"),
1630 Message::text(Role::User, "initial task"),
1631 Message::text(Role::Assistant, "old response"),
1632 tool_call("call-1"),
1633 tool_result("call-1"),
1634 Message::text(Role::Assistant, "recent 1"),
1635 Message::text(Role::User, "recent 2"),
1636 Message::text(Role::Assistant, "recent 3"),
1637 Message::text(Role::User, "recent 4"),
1638 ];
1639
1640 let kept: Vec<_> = trim_messages(&messages, 5).into_iter().cloned().collect();
1642
1643 assert_eq!(
1644 kept,
1645 vec![
1646 Message::text(Role::System, "client instructions"),
1647 Message::text(Role::User, "initial task"),
1648 tool_call("call-1"),
1649 tool_result("call-1"),
1650 Message::text(Role::Assistant, "recent 1"),
1651 Message::text(Role::User, "recent 2"),
1652 Message::text(Role::Assistant, "recent 3"),
1653 Message::text(Role::User, "recent 4"),
1654 ]
1655 );
1656 }
1657
1658 #[test]
1661 fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1662 let messages = vec![
1663 Message::text(Role::System, "client instructions"),
1664 Message::text(Role::User, "initial task"),
1665 tool_call("x"),
1666 tool_result("x"),
1667 Message::text(Role::Assistant, "later"),
1668 tool_call("x"),
1669 tool_result("x"),
1670 ];
1671
1672 let kept: Vec<_> = trim_messages(&messages, 4).into_iter().cloned().collect();
1674
1675 assert_eq!(
1676 kept,
1677 vec![
1678 Message::text(Role::System, "client instructions"),
1679 Message::text(Role::User, "initial task"),
1680 tool_call("x"),
1681 tool_result("x"),
1682 Message::text(Role::Assistant, "later"),
1683 tool_call("x"),
1684 tool_result("x"),
1685 ]
1686 );
1687 }
1688
1689 #[test]
1692 fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1693 let messages = vec![
1694 Message::text(Role::System, "client instructions"),
1695 tool_call("orphan"),
1696 Message::text(Role::User, "initial task"),
1697 Message::text(Role::Assistant, "old response"),
1698 tool_result("orphan"),
1699 Message::text(Role::Assistant, "recent 1"),
1700 Message::text(Role::User, "recent 2"),
1701 ];
1702
1703 let kept: Vec<_> = trim_messages(&messages, 3).into_iter().cloned().collect();
1704
1705 assert_eq!(
1706 kept,
1707 vec![
1708 Message::text(Role::System, "client instructions"),
1709 Message::text(Role::User, "initial task"),
1710 tool_result("orphan"),
1711 Message::text(Role::Assistant, "recent 1"),
1712 Message::text(Role::User, "recent 2"),
1713 ]
1714 );
1715 }
1716
1717 #[test]
1718 fn a_window_restates_the_routing_instruction_last() -> Result<()> {
1719 let contents = judged_contents(2)?;
1720
1721 assert_eq!(
1724 contents.last().map(String::as_str),
1725 Some(TRAILING_ROUTING_INSTRUCTION)
1726 );
1727 Ok(())
1728 }
1729
1730 #[test]
1735 fn a_window_drops_reasoning_but_keeps_visible_text_and_tool_pairs() {
1736 let messages = vec![
1737 Message::text(Role::System, "client instructions"),
1738 Message::text(Role::User, "initial task"),
1739 Message {
1740 role: Role::Assistant,
1741 content: vec![
1742 ContentBlock::Reasoning {
1743 text: "private chain of thought".to_string(),
1744 signature: None,
1745 details: Vec::new(),
1746 },
1747 ContentBlock::Text {
1748 text: "visible answer".to_string(),
1749 },
1750 ],
1751 },
1752 tool_call("call-1"),
1753 tool_result("call-1"),
1754 Message {
1755 role: Role::Assistant,
1756 content: vec![ContentBlock::Reasoning {
1757 text: "reasoning-only turn".to_string(),
1758 signature: None,
1759 details: Vec::new(),
1760 }],
1761 },
1762 Message::text(Role::User, "follow-up"),
1763 ];
1764 let request = Request {
1765 llm_request: LlmRequest {
1766 messages,
1767 ..LlmRequest::default()
1768 },
1769 raw_request: None,
1770 metadata: None,
1771 };
1772
1773 let built = TaskInput {
1774 recent_turn_window: Some(10),
1775 judge_max_images: None,
1776 }
1777 .build_messages(&State::default(), &request);
1778
1779 assert!(
1780 !built
1781 .iter()
1782 .flat_map(|message| &message.content)
1783 .any(|block| matches!(block, ContentBlock::Reasoning { .. })),
1784 "{built:?}"
1785 );
1786 assert!(
1787 built
1788 .iter()
1789 .any(|message| message.text_content("\n").as_deref() == Some("visible answer"))
1790 );
1791 assert!(built.contains(&tool_call("call-1")));
1792 assert!(built.contains(&tool_result("call-1")));
1793 assert_eq!(built.len(), 7);
1796 assert!(built.iter().all(|message| !message.content.is_empty()));
1797 }
1798
1799 #[test]
1800 fn the_default_path_is_left_unchanged() -> Result<()> {
1801 let judge = capability_judge(None)?;
1804 let request = Request {
1805 llm_request: LlmRequest {
1806 messages: vec![Message::text(Role::User, "the task")],
1807 ..LlmRequest::default()
1808 },
1809 raw_request: None,
1810 metadata: None,
1811 };
1812
1813 let built = judge.build_request(&State::default(), &request);
1814
1815 assert_eq!(built.llm_request.messages.len(), 1);
1818 assert!(!built.llm_request.instructions.is_empty());
1819 assert!(
1820 !built
1821 .llm_request
1822 .messages
1823 .iter()
1824 .filter_map(|message| message.text_content("\n"))
1825 .any(|text| text.contains(TRAILING_ROUTING_INSTRUCTION))
1826 );
1827 Ok(())
1828 }
1829
1830 #[test]
1831 fn capability_judge_builds_a_structured_request() -> Result<()> {
1832 let judge = capability_judge(None)?;
1833 let request = Request {
1834 llm_request: LlmRequest {
1835 model: Some("inbound".to_string()),
1836 messages: vec![
1837 Message::text(Role::System, "client instructions"),
1838 Message::text(Role::Developer, "client developer instructions"),
1839 Message::text(Role::User, "initial task"),
1840 Message::text(Role::Assistant, "old response"),
1841 Message::text(Role::User, "old follow-up"),
1842 Message::text(Role::Assistant, "recent 1"),
1843 Message::text(Role::User, "recent 2"),
1844 Message::text(Role::Assistant, "recent 3"),
1845 Message::text(Role::User, "recent 4"),
1846 Message::text(Role::Assistant, "recent 5"),
1847 ],
1848 ..LlmRequest::default()
1849 },
1850 raw_request: None,
1851 metadata: None,
1852 };
1853 let judge_request = judge.build_request(&State::default(), &request);
1854
1855 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1856 assert_eq!(judge_request.llm_request.instructions.len(), 1);
1857 assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
1858 assert_eq!(
1859 judge_request.llm_request.instructions[0].content,
1860 InstructionBlock {
1861 role: Role::System,
1862 content: Message::text(Role::System, judge.contract().system_prompt()).content,
1863 }
1864 .content,
1865 );
1866 assert_eq!(judge_request.llm_request.messages.len(), 2);
1867 let contents = judge_request
1868 .llm_request
1869 .messages
1870 .iter()
1871 .filter_map(|message| message.text_content("\n"))
1872 .collect::<Vec<_>>();
1873 assert!(contents.contains(&"recent 4".to_string()));
1874 assert!(contents.contains(&"initial task".to_string()));
1875 assert!(!contents.contains(&"recent 5".to_string()));
1876 assert!(!contents.contains(&"client instructions".to_string()));
1877 assert_eq!(
1878 judge_request.llm_request.output.response_format,
1879 Some(judge.contract().response_format().clone())
1880 );
1881 assert_eq!(
1882 judge_request.llm_request.output.max_output_tokens,
1883 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1884 );
1885 Ok(())
1886 }
1887
1888 fn sample_value(spec: &Value) -> Value {
1889 if let Some(first) = spec
1890 .get("enum")
1891 .and_then(Value::as_array)
1892 .and_then(|values| values.first())
1893 {
1894 return first.clone();
1895 }
1896 match spec.get("type").and_then(Value::as_str) {
1897 Some("number") => serde_json::json!(0.5),
1898 Some("boolean") => serde_json::json!(false),
1899 _ => serde_json::json!("sample"),
1900 }
1901 }
1902
1903 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1904 let properties = schema
1905 .pointer("/json_schema/schema/properties")
1906 .and_then(Value::as_object)
1907 .ok_or_else(|| LibsyError::AlgorithmError {
1908 message: "packaged schema declares no properties".to_string(),
1909 })?;
1910 Ok(Value::Object(
1911 properties
1912 .iter()
1913 .map(|(name, spec)| (name.clone(), sample_value(spec)))
1914 .collect(),
1915 )
1916 .to_string())
1917 }
1918
1919 #[test]
1922 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1923 let contract =
1924 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1925 let schema = contract.response_format();
1926 let reply = schema_shaped_verdict(schema)?;
1927 let judge: CapabilityJudge = StructuredJudge::new(
1928 TaskInput {
1929 recent_turn_window: None,
1930 judge_max_images: None,
1931 },
1932 contract,
1933 SerdeDecoder::new(),
1934 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1935 );
1936
1937 let verdict = judge.parse(&text_response(None, reply))?;
1938
1939 assert!(verdict.is_valid());
1940 assert!((0.0..=1.0).contains(&verdict.p_solve));
1941 Ok(())
1942 }
1943
1944 #[test]
1945 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1946 let contract =
1947 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1948 let prompt = contract.system_prompt();
1949 let schema_name = contract
1950 .response_format()
1951 .pointer("/json_schema/name")
1952 .and_then(Value::as_str)
1953 .ok_or_else(|| LibsyError::AlgorithmError {
1954 message: "packaged response schema has no name".to_string(),
1955 })?;
1956 assert_eq!(schema_name, "CapabilityClassifierDecision");
1957 assert!(prompt.contains("SUP-1 [supported]"));
1958 assert!(prompt.contains("SUP-5 [supported]"));
1959 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1960 assert!(!prompt.contains("\"type\": \"object\""));
1961 assert!(!prompt.contains("\"json_schema\""));
1962 assert!(!prompt.contains(schema_name));
1963 let rule_values = contract
1964 .response_format()
1965 .pointer("/json_schema/schema/properties/primary_rule/enum")
1966 .and_then(Value::as_array)
1967 .ok_or_else(|| LibsyError::AlgorithmError {
1968 message: "rendered response schema has no primary rule enum".to_string(),
1969 })?;
1970 assert!(
1971 rule_values
1972 .iter()
1973 .any(|value| value.as_str() == Some("SUP-1"))
1974 );
1975 assert!(
1976 rule_values
1977 .iter()
1978 .any(|value| value.as_str() == Some("none"))
1979 );
1980 Ok(())
1981 }
1982}