Skip to main content

switchyard_libsy/algorithms/
llm_class.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Judge-backed capability, escalation, and custom-policy routing.
5
6use 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");
35/// Telemetry label for this algorithm's spans, metrics, and logs.
36const ALGORITHM_NAME: &str = "llm_task_classifier";
37
38/// Restates the task after the conversation the judge is asked to route.
39///
40/// The rubric leads the request, which works while the payload is a single short
41/// message. Once `recent_turn_window` includes real conversation, those instructions
42/// sit far from the generation point and the judge sometimes *answers* the
43/// conversation instead of classifying it — the reply then fails to parse, the
44/// verdict is unavailable, and the turn silently falls back. Repeating the task at
45/// the end is what reliably prevents that.
46const 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    /// Rejects malformed or internally inconsistent verdicts before policy evaluation.
60    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    /// Returns the number of threshold steps assigned to this capability boundary.
76    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
86/// Keeps the opening task and the last `recent_turn_window` turns after it. A
87/// window of `0` keeps the task alone.
88///
89/// Inbound decoders normalize client system and developer content into
90/// `LlmRequest::instructions`, so it never reaches this list.
91///
92/// Selects by reference and clones only what survives — a coding-agent
93/// conversation carries every tool result, so cloning it whole to keep a window
94/// would copy the transcript on each judged turn.
95fn 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
111/// The first index of the trailing window.
112///
113/// Counting messages alone can start the window between an assistant tool call and the
114/// result answering it, leaving the judge a result whose call id was never introduced. The
115/// start therefore moves back to the nearest one that keeps every tool pair whole.
116///
117/// One newest-to-oldest pass carries the ids still waiting for a call. Direction is what
118/// makes it correct: ids repeat across a conversation, and in this order a call is only
119/// ever seen after the results it could answer, so a later call — already passed — clears
120/// nothing. A result whose call sits before the opening task, which trimming never reaches,
121/// keeps the set non-empty to the end and falls back to the counted start, so an unpairable
122/// result costs one pass and cannot widen the window to the whole conversation.
123fn window_start(tail: &[&Message], recent_turn_window: usize) -> usize {
124    let counted = tail.len().saturating_sub(recent_turn_window);
125    // An empty window holds no result to pair, and the loop below never visits its start.
126    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        // Blocks reverse too, so a call answers a result only when it precedes it inside
132        // one message as well as across messages.
133        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
151/// Keeps the opening task and the latest user follow-up when they differ.
152fn task_messages(messages: &[Message]) -> Vec<Message> {
153    // Decoders also use the user role for tool results. Select ordinary user content
154    // first, so a tool result cannot replace the opening task or latest follow-up.
155    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
184/// Selects the task messages shown to capability and custom-schema classifiers.
185struct 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        // The default preserves the whole-task anchor and latest user update. A
192        // configured window widens that to the surrounding conversation.
193        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        // Reasoning is provider-private and not required to classify the task. Some
198        // upstreams also reject an unsigned reasoning item replayed without the
199        // opaque state it was issued with, so it cannot travel through a windowed
200        // classifier request as ordinary history.
201        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        // Only the windowed path carries assistant turns and tool traffic for the judge
208        // to be distracted by. The default path is user task messages only — the anchor
209        // and the latest follow-up — so there is nothing there to outrank.
210        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    /// Returns the required solve probability for one validated verdict.
234    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        // Judge output is untrusted. An absent, invalid, or inconsistent verdict is
248        // ambiguous so the surrounding router applies its configured fallback.
249        let Some(verdict) = verdict.filter(|verdict| verdict.is_valid()) else {
250            return Ok(Classification::Ambiguous(vec![]));
251        };
252        // A usable verdict below the capability threshold is still a decision: the judge
253        // does not trust the efficient tier with this task.
254        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        // The chosen category may have no models configured. That is ambiguous, not an
265        // error, so the surrounding router applies its configured fallback.
266        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
277/// Maps valid verdicts to scores, invalid verdicts to a reason, and leaves absent verdicts alone.
278fn 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)]
301/// Settings that control capability classifier prompting and routing.
302pub struct TaskClassifierConfig {
303    /// Lowest solve probability that routes a supported task to the efficient target.
304    pub base_threshold: f64,
305    /// Amount added per capability-boundary step.
306    ///
307    /// Supported verdicts use `base_threshold`, uncertain and unmatched verdicts use one
308    /// step, and unsupported verdicts use two steps.
309    pub threshold_step: f64,
310    /// How often the classifier re-decides this session's target.
311    pub classify_trigger: ClassifyTrigger,
312    /// Uses the first user message as the SessionKey for sticky routing when session metadata is unavailable.
313    pub message_hash_fallback: bool,
314    /// Trailing conversation turns the judge sees on top of the client
315    /// instructions and the opening task.
316    ///
317    /// `None` (the default) judges the opening task and latest user follow-up.
318    /// `Some(n)` widens that to the client instructions, the opening task, and
319    /// the last `n` turns after it.
320    pub recent_turn_window: Option<usize>,
321    /// Prompt and verdict contract settings for the classifier judge.
322    pub contract: ClassifierContractConfig,
323    /// Maximum completion tokens available to the classifier verdict.
324    pub max_output_tokens: u64,
325}
326
327/// Flat serialized shape that maps prompt settings into the runtime contract.
328#[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    /// Validates routing thresholds before the classifier is constructed.
391    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        // Only `every_request` is rejected: it retains no target, so a fallback identity has nothing to key on. Both retaining triggers can key the retained target on a message hash when the caller sends no session id.
422        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/// Policy that maps a custom classifier verdict to a routing target.
434#[derive(Clone, Debug)]
435pub enum CustomClassifierPolicy {
436    /// Resolves a JSON Pointer and treats its string value as a model category.
437    TargetSelector {
438        /// JSON Pointer evaluated against each schema-validated verdict.
439        selector: String,
440    },
441}
442
443impl CustomClassifierPolicy {
444    /// Creates a policy that selects a model category through a JSON Pointer.
445    pub fn target_selector(selector: impl Into<String>) -> Self {
446        Self::TargetSelector {
447            selector: selector.into(),
448        }
449    }
450}
451
452/// Settings for a classifier whose JSON Schema and target-selection policy are user supplied.
453#[derive(Clone, Debug)]
454pub struct CustomClassifierConfig {
455    /// System prompt sent to the classifier judge.
456    pub prompt: String,
457    /// Inner JSON Schema placed inside the provider's structured-output wrapper.
458    pub response_schema: Value,
459    /// Deterministic policy applied after the verdict passes schema validation.
460    pub policy: CustomClassifierPolicy,
461    /// How often the classifier re-decides this session's target.
462    pub classify_trigger: ClassifyTrigger,
463    /// Uses the first user message when session metadata is unavailable.
464    pub message_hash_fallback: bool,
465    /// Trailing conversation turns shown to the classifier judge.
466    pub recent_turn_window: Option<usize>,
467    /// Maximum completion tokens available to the classifier verdict.
468    pub max_output_tokens: u64,
469}
470
471impl CustomClassifierConfig {
472    /// Creates a custom-schema classifier contract with conservative runtime defaults.
473    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        // Only `every_request` is rejected: it retains no target, so a fallback identity has nothing to key on. Both retaining triggers can key the retained target on a message hash when the caller sends no session id.
496        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
525/// Builds the affinity router a trigger calls for, if any.
526fn 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
543/// Routes requests through a capability, escalation, or custom classifier mode.
544pub struct LlmTaskClassifier {
545    route: FallThrough<State>,
546    /// Classifier used when this router is embedded in another cascade.
547    inner: Arc<dyn Classifier<State>>,
548}
549
550struct ClassifierRouteConfig {
551    default_target: Category,
552    classify_trigger: ClassifyTrigger,
553    message_hash_fallback: bool,
554}
555
556/// Terminal classifier for a cascade whose classifiers may all abstain.
557/// Closes a cascade with the first runtime model in `category`.
558pub 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/// Complete construction settings for one LLM classifier mode.
582#[derive(Clone)]
583#[non_exhaustive]
584pub enum LlmClassifierConfig {
585    /// Routes between efficient and capable targets from a task-level verdict.
586    Capability {
587        /// Capability classifier settings.
588        config: TaskClassifierConfig,
589    },
590    /// Judges efficient responses and escalates after a confirmed streak.
591    Escalation {
592        /// Prompt and verdict contract settings for the escalation judge.
593        contract: ClassifierContractConfig,
594        /// Escalation policy settings.
595        config: EscalationJudgeConfig,
596        /// Maximum completion tokens available to the escalation verdict.
597        max_output_tokens: u64,
598    },
599    /// Routes among model categories using a user-supplied schema and policy.
600    Custom {
601        /// Category selected when the judge does not produce a usable verdict.
602        default_target: Category,
603        /// Custom classifier settings.
604        config: CustomClassifierConfig,
605    },
606}
607
608impl LlmTaskClassifier {
609    /// Builds the classifier mode described by `config`.
610    ///
611    /// # Errors
612    ///
613    /// Returns an error when the selected mode's targets, contract, policy, or runtime
614    /// settings are invalid.
615    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    /// Loads the packaged capability-classifier contract.
711    fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
712        ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
713    }
714
715    /// Keeps affinity and fallback ordering identical across judge-backed modes.
716    fn from_classifier(
717        inner: Arc<dyn Classifier<State>>,
718        config: ClassifierRouteConfig,
719    ) -> Result<Self> {
720        // Only `every_request` is rejected: it retains no target, so a fallback identity has nothing to key on. Both retaining triggers can key the retained target on a message hash when the caller sends no session id.
721        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        // Affinity comes first so a retained assignment short-circuits the judge call.
730        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            // Both roles must share one `Arc` so the classifier reads what the processor wrote.
735            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    /// Records what each target received; answers the judge with a supported verdict and
853    /// every other target with a plain completion.
854    #[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    /// The judge times out; every other target answers normally.
925    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        // Only `every_request` is rejected: it retains no target, so a fallback
1290        // identity has nothing to key on. Both retaining triggers may key the
1291        // retained target on a message hash when the caller sends no session id.
1292        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    /// The text of each message a judge with `recent_turn_window` would be sent.
1382    /// The no-window case is covered by `capability_judge_builds_a_structured_request`.
1383    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        // Client instructions and the opening task, plus the last two turns.
1421        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    /// A count-based window can begin on a tool result, which leaves the call that
1499    /// introduced its id outside the window and the classifier history invalid.
1500    #[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        // The five-message tail begins exactly on the tool result.
1515        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    /// Ids repeat across a conversation, so a later call must not stand in for the one that
1533    /// answers an earlier result.
1534    #[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        // The four-message tail begins on the first result, whose own call sits one earlier.
1547        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    /// A result whose call precedes the opening task can never be paired, because trimming
1564    /// never reaches behind the task. The window must not widen hunting for it.
1565    #[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        // Last, not merely present: the instruction only works in final position,
1596        // after the conversation content it is meant to outrank.
1597        assert_eq!(
1598            contents.last().map(String::as_str),
1599            Some(TRAILING_ROUTING_INSTRUCTION)
1600        );
1601        Ok(())
1602    }
1603
1604    /// Reasoning is provider-private and some upstreams reject it replayed without the
1605    /// opaque state it was issued with, so it must not reach a windowed classifier
1606    /// request. Visible assistant text and complete tool pairs still do, and a turn
1607    /// whose only content was reasoning is dropped rather than left behind empty.
1608    #[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        // Six of the seven fixtures survive — the reasoning-only turn is gone entirely
1667        // rather than left behind empty — plus the trailing routing instruction.
1668        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        // No window means no conversation to be distracted by, so the default
1675        // request shape stays exactly as it was.
1676        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        // The task message alone; the rubric reaches the judge as an instruction block
1689        // rather than a message, so nothing was dropped by it not being counted here.
1690        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    /// Built from the schema so a property added there fails here rather than silently
1793    /// rejecting every production verdict.
1794    #[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}