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