Skip to main content

switchyard_runner/
algorithm.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Schema-neutral algorithm configuration and construction.
5
6use std::collections::{BTreeMap, BTreeSet, HashMap};
7use std::error::Error;
8use std::fmt::{Display, Formatter};
9use std::path::PathBuf;
10use std::sync::Arc;
11
12use libsy::{
13    AdvisorGate, AdvisorGateConfig, Algorithm, BreakerConfig, CapabilityJudgeConfig,
14    ClassifierContractConfig, ClassifierResponseFormat, ClassifyTrigger, CompositeJudgeConfig,
15    CompositeRouter, CompositeRouterConfig, CustomClassifierConfig, CustomClassifierPolicy,
16    DEFAULT_UNMATCHED_STEPS, DecisionChoice, DecisionJudgeConfig, DirectClassifierConfig,
17    EscalationJudgeConfig, GateTrigger, HandoffNoteConfig, LlmCapabilityConfig,
18    LlmClassifierConfig, LlmFallback, LlmTaskClassifier, Noop, Passthrough, PickerMode, Random,
19    RuntimeModels, StageRouter, StageRouterConfig, SubagentRouter, SubagentRouterConfig,
20    TaskClassifierConfig, ToolSemantics, Vgr, VgrConfig,
21};
22use serde::Deserialize;
23use strum_macros::IntoStaticStr;
24use switchyard_protocol::{Category, ModelId};
25
26/// Error returned when an algorithm description cannot be constructed.
27#[derive(Debug)]
28pub struct AlgorithmConfigError {
29    message: String,
30    source: Option<Box<dyn Error + Send + Sync>>,
31}
32
33impl AlgorithmConfigError {
34    fn new(message: impl Into<String>) -> Self {
35        Self {
36            message: message.into(),
37            source: None,
38        }
39    }
40
41    fn with_source(message: impl Into<String>, source: impl Error + Send + Sync + 'static) -> Self {
42        Self {
43            message: message.into(),
44            source: Some(Box::new(source)),
45        }
46    }
47}
48
49impl Display for AlgorithmConfigError {
50    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
51        formatter.write_str(&self.message)
52    }
53}
54
55impl Error for AlgorithmConfigError {
56    fn source(&self) -> Option<&(dyn Error + 'static)> {
57        self.source.as_deref().map(|source| source as _)
58    }
59}
60
61type AlgorithmResult<T> = Result<T, AlgorithmConfigError>;
62
63/// How a custom classifier turns the judge's JSON verdict into a target.
64#[derive(Clone, Debug, Deserialize)]
65#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
66pub enum ClassifierPolicyConfig {
67    /// Reads the target name straight out of the judge's verdict.
68    TargetSelector {
69        /// JSON Pointer to the name, such as `/decision/target`.
70        selector: String,
71    },
72}
73
74/// Which of the four `llm_classifier` behaviors a route uses.
75#[derive(Clone, Copy, Debug, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum ClassifierMode {
78    /// Judges the request first, then serves the strong or weak target.
79    Capability,
80    /// Serves the weak target first and judges the finished turn, moving to the
81    /// strong target once the session latches.
82    Escalation,
83    /// Judges against your own JSON schema and routes to any configured target.
84    Custom,
85    /// Asks a decision target to select one configured model group.
86    Direct,
87}
88
89impl ClassifierPolicyConfig {
90    fn into_libsy(self) -> CustomClassifierPolicy {
91        match self {
92            Self::TargetSelector { selector } => CustomClassifierPolicy::target_selector(selector),
93        }
94    }
95}
96
97#[derive(Clone, Debug)]
98enum LlmClassifierModeConfig {
99    Capability(CapabilityClassifierRouteConfig),
100    Escalation(EscalationClassifierRouteConfig),
101    Custom(CustomClassifierRouteConfig),
102    Direct {
103        models: CategoryModelConfig,
104        default_target: Category,
105        config: DirectClassifierConfig,
106    },
107}
108
109#[derive(Clone, Debug)]
110struct CapabilityClassifierRouteConfig {
111    classifier_target: String,
112    fail_open: bool,
113    strong_target: String,
114    weak_target: String,
115    judge: CapabilityJudgeRouteConfig,
116    classify_trigger: ClassifyTrigger,
117    message_hash_fallback: bool,
118    recent_turn_window: Option<usize>,
119}
120
121impl CapabilityClassifierRouteConfig {
122    fn into_task_config(self) -> TaskClassifierConfig {
123        let judge = match self.judge {
124            CapabilityJudgeRouteConfig::Llm(judge) => CapabilityJudgeConfig::Llm(judge),
125            CapabilityJudgeRouteConfig::Decision(judge) => {
126                let defaults = DecisionJudgeConfig::default();
127                CapabilityJudgeConfig::Decision(DecisionJudgeConfig {
128                    cutoff: judge.cutoff,
129                    instructions: judge.instructions,
130                    choices: judge.choices.unwrap_or(defaults.choices),
131                    apply_cutoff_to: judge.apply_cutoff_to.unwrap_or(defaults.apply_cutoff_to),
132                })
133            }
134        };
135        TaskClassifierConfig {
136            judge,
137            fail_open: self.fail_open,
138            classify_trigger: self.classify_trigger,
139            message_hash_fallback: self.message_hash_fallback,
140            recent_turn_window: self.recent_turn_window,
141        }
142    }
143}
144
145#[derive(Clone, Debug)]
146enum CapabilityJudgeRouteConfig {
147    Llm(LlmCapabilityConfig),
148    Decision(DecisionJudgeRouteConfig),
149}
150
151#[derive(Clone, Debug)]
152struct EscalationClassifierRouteConfig {
153    classifier_target: String,
154    strong_target: String,
155    weak_target: String,
156    prompt: Option<String>,
157    response_format_type: ClassifierResponseFormat,
158    max_output_tokens: u64,
159    judge_deadline_ms: Option<u64>,
160    judge: EscalationJudgeConfig,
161}
162
163#[derive(Clone, Debug)]
164struct CustomClassifierRouteConfig {
165    models: CategoryModelConfig,
166    default_target: Category,
167    prompt: String,
168    response_schema: String,
169    policy: ClassifierPolicyConfig,
170    classify_trigger: ClassifyTrigger,
171    message_hash_fallback: bool,
172    recent_turn_window: Option<usize>,
173    max_output_tokens: u64,
174    judge_deadline_ms: Option<u64>,
175}
176
177/// Runtime model groups for a custom classifier, keyed by group name.
178///
179/// `any` and `judge` are required; `capable` and `efficient` carry tier meaning
180/// when present. Any other key is a deployment-defined group the policy may
181/// select by name, which is what lets one route choose between more than two
182/// models. Ordered so error messages and derived target lists do not depend on
183/// hash iteration.
184#[derive(Clone, Debug, Default, Deserialize)]
185#[serde(transparent)]
186pub struct CategoryModelConfig(BTreeMap<String, Vec<String>>);
187
188impl CategoryModelConfig {
189    fn restrict_to(&mut self, allowed: &BTreeSet<&str>) {
190        for models in self.0.values_mut() {
191            models.retain(|model| allowed.contains(model.as_str()));
192        }
193    }
194
195    fn validate(&self, route_name: &str, default_target: &Category) -> AlgorithmResult<()> {
196        for category in [Category::Any, Category::Judge] {
197            if self.get(&category).is_empty() {
198                return Err(AlgorithmConfigError::new(format!(
199                    "llm_classifier route {route_name} models.{} must contain at least one target",
200                    category.as_str()
201                )));
202            }
203        }
204        if self.get(default_target).is_empty() {
205            return Err(AlgorithmConfigError::new(format!(
206                "llm_classifier route {route_name} models.{} must contain at least one target because it is the default_target",
207                default_target.as_str()
208            )));
209        }
210        // Every group name parses, so a typo would otherwise build fine and then
211        // abstain on every request. `any` is the fallback pool the router checks
212        // the selected target against, so a group outside it can never be served.
213        let any = self.get(&Category::Any);
214        for (name, models) in &self.0 {
215            if name == Category::Judge.as_str() || name == Category::Any.as_str() {
216                continue;
217            }
218            if let Some(missing) = models.iter().find(|model| !any.contains(model)) {
219                return Err(AlgorithmConfigError::new(format!(
220                    "llm_classifier route {route_name} models.{name} lists target {missing}, which must also appear in models.any"
221                )));
222            }
223        }
224        Ok(())
225    }
226
227    fn get(&self, category: &Category) -> &[String] {
228        self.0.get(category.as_str()).map_or(&[], Vec::as_slice)
229    }
230
231    /// Every configured target name, judge included.
232    fn all_names(&self) -> Vec<&str> {
233        Self::deduped(self.0.values().flatten())
234    }
235
236    /// The completion targets: every group except the judge's own candidates.
237    ///
238    /// `any` leads because it is the deployment's stated fallback order, and
239    /// callers read this order to pick a route's representative target.
240    fn routing_names(&self) -> Vec<&str> {
241        let others = self
242            .0
243            .iter()
244            .filter(|(name, _)| {
245                *name != Category::Judge.as_str() && *name != Category::Any.as_str()
246            })
247            .flat_map(|(_, models)| models);
248        Self::deduped(self.get(&Category::Any).iter().chain(others))
249    }
250
251    fn deduped<'a>(names: impl Iterator<Item = &'a String>) -> Vec<&'a str> {
252        let mut seen = BTreeSet::new();
253        names
254            .map(String::as_str)
255            .filter(|name| seen.insert(*name))
256            .collect()
257    }
258
259    fn groups(&self) -> impl Iterator<Item = (Category, Vec<String>)> + '_ {
260        self.0
261            .iter()
262            .filter_map(|(name, models)| Some((name.parse::<Category>().ok()?, models.clone())))
263    }
264}
265
266/// Routing by a decision model's choice probability.
267#[derive(Clone, Debug, Default, Deserialize)]
268#[serde(deny_unknown_fields)]
269pub struct DecisionJudgeRouteConfig {
270    /// Route capable when the `apply_cutoff_to` probability is strictly above
271    /// this cutoff. Uses the library default when omitted.
272    pub cutoff: Option<f64>,
273    /// Replaces the default decision instructions.
274    pub instructions: Option<String>,
275    /// Replaces the default `capable` and `efficient` choices.
276    pub choices: Option<Vec<DecisionChoice>>,
277    /// Choice ID whose probability is compared with the cutoff. Defaults to `capable`.
278    pub apply_cutoff_to: Option<String>,
279}
280
281/// Settings for an `llm_classifier` route. Which fields are required depends on
282/// the [`ClassifierMode`]; using a field from the wrong mode is an error.
283#[derive(Clone, Debug, Default, Deserialize)]
284#[serde(default, deny_unknown_fields)]
285pub struct LlmClassifierRouteConfig {
286    /// Target the judge is called through. Never a routing destination itself.
287    pub classifier_target: String,
288    /// Mode to run. Defaults to escalation when `escalation` is set, otherwise capability.
289    pub mode: Option<ClassifierMode>,
290    /// Capability and escalation modes: the capable tier.
291    pub strong_target: Option<String>,
292    /// Capability and escalation modes: the efficient tier.
293    pub weak_target: Option<String>,
294    /// Capability mode: lowest solve probability that still routes to the weak
295    /// target, from 0 to 1.
296    pub base_threshold: Option<f64>,
297    /// Capability mode: how much to raise the threshold when the judge is
298    /// uncertain. Added once for an uncertain verdict and twice for unsupported.
299    pub threshold_step: Option<f64>,
300    /// Capability mode: threshold steps for an unmatched verdict (no capability rule
301    /// applies). 1 by default, the same as uncertain; 2 treats it like unsupported.
302    pub unmatched_steps: Option<u8>,
303    /// Capability and direct modes: routes to the capable tier or `default_target` when
304    /// the judge call fails. Defaults to true in these modes.
305    pub fail_open: Option<bool>,
306    /// How often the judge runs: every request, once per user turn, or once per session.
307    pub classify_trigger: ClassifyTrigger,
308    /// Reuses the session's target by hashing the first user message when no
309    /// session ID is available. Needs `classify_trigger = "new_session"`.
310    pub message_hash_fallback: bool,
311    /// How many trailing turns the judge sees. Unset shows it the opening task
312    /// and the latest user follow-up only.
313    pub recent_turn_window: Option<usize>,
314    /// Replaces the packaged judge prompt. Required in custom mode.
315    pub prompt: Option<String>,
316    /// How the judge is asked for structured output. Use `json_object` when the
317    /// provider cannot do JSON Schema.
318    pub response_format_type: ClassifierResponseFormat,
319    /// Most completion tokens the judge verdict may use.
320    #[serde(default = "default_classifier_max_output_tokens")]
321    pub max_output_tokens: u64,
322    /// Whole-consultation bound on the judge call in milliseconds; expiry follows the
323    /// route's `fail_open` setting. Unset leaves the consultation unbounded.
324    #[serde(default)]
325    pub judge_deadline_ms: Option<u64>,
326    /// Escalation mode: how many escalate verdicts latch the session, and how
327    /// much of the transcript the judge sees.
328    pub escalation: Option<EscalationJudgeConfig>,
329    /// Capability mode: use a decision target as the judge, with a capable-or-efficient choice.
330    pub decision: Option<DecisionJudgeRouteConfig>,
331    /// Custom and direct modes: runtime model groups. In direct mode, every non-empty
332    /// group except `judge` and `any` is an option the decision target may select.
333    pub models: Option<CategoryModelConfig>,
334    /// Custom and direct modes: category used when the judge fails or its verdict
335    /// cannot be routed.
336    pub default_target: Option<String>,
337    /// Custom mode: JSON Schema the verdict must match, written as a string.
338    pub response_schema: Option<String>,
339    /// Custom mode: how to read the chosen target out of the verdict.
340    pub policy: Option<ClassifierPolicyConfig>,
341}
342
343/// Routing policy applied only to delegated sub-agent work, nested inside a
344/// `passthrough` or `stage_router` route.
345#[derive(Clone, Debug, Deserialize)]
346#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
347pub enum SubagentRouteConfig {
348    /// Sends all sub-agent work to one target.
349    Passthrough {
350        /// Target that serves sub-agent requests.
351        target: String,
352    },
353    /// Judges each sub-agent request. Only [`ClassifierMode::Custom`] is supported here.
354    LlmClassifier(Box<LlmClassifierRouteConfig>),
355}
356
357impl SubagentRouteConfig {
358    fn routing_target_names(&self) -> Vec<&str> {
359        match self {
360            Self::Passthrough { target } => vec![target],
361            Self::LlmClassifier(classifier) => classifier
362                .models
363                .as_ref()
364                .map(CategoryModelConfig::routing_names)
365                .unwrap_or_default(),
366        }
367    }
368
369    fn judge_target_names(&self) -> Vec<&str> {
370        match self {
371            Self::Passthrough { .. } => Vec::new(),
372            Self::LlmClassifier(classifier) => classifier
373                .models
374                .as_ref()
375                .map(|models| models.get(&Category::Judge))
376                .unwrap_or_default()
377                .iter()
378                .map(String::as_str)
379                .collect(),
380        }
381    }
382}
383
384/// A routing algorithm described by configured target names. Converting it to `&str` gives
385/// the route's `type`, such as `stage_router`.
386#[derive(Clone, Debug, Deserialize, IntoStaticStr)]
387#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
388#[strum(serialize_all = "snake_case")]
389pub enum AlgorithmSpec {
390    /// Replies `OK` without calling any model. Useful for smoke tests.
391    Noop {},
392    /// Splits traffic across several targets.
393    Random {
394        /// Target names to choose from.
395        targets: Vec<String>,
396        /// Relative weights in `targets` order. Equal weights when unset.
397        weights: Option<Vec<f64>>,
398        /// Makes the sequence of choices repeatable.
399        seed: Option<u64>,
400    },
401    /// Sends every request to one target.
402    Passthrough {
403        /// Target that serves the request.
404        target: String,
405        /// Separate policy for delegated sub-agent work.
406        #[serde(default)]
407        subagents: Option<SubagentRouteConfig>,
408    },
409    /// Asks a judge model which target should serve the request.
410    LlmClassifier {
411        /// Judge and tier settings, written directly in the route table.
412        #[serde(flatten)]
413        config: LlmClassifierRouteConfig,
414    },
415    /// Picks a tier per turn by scoring signals from recent tool results.
416    StageRouter {
417        capable_target: String,
418        efficient_target: String,
419        #[serde(default)]
420        capable_first_threshold: f64,
421        #[serde(flatten)]
422        settings: StageSettingsConfig,
423        /// Tier to use when the signals are not confident.
424        picker: PickerMode,
425        /// Judge consulted for turns the tool signals cannot decide.
426        #[serde(default)]
427        classifier: Option<StageClassifierConfig>,
428        /// Separate policy for delegated sub-agent work.
429        #[serde(default)]
430        subagents: Option<SubagentRouteConfig>,
431    },
432    /// Picks a routing strategy automatically, preset with recommended knobs.
433    /// Currently a `stage_router` with `picker = "efficient_first"` and
434    /// `confidence_threshold = 0.5`; change `build_algorithm`'s `Auto` arm to
435    /// repoint it at a different algorithm or preset.
436    Auto {
437        /// The capable tier.
438        capable_target: String,
439        /// The efficient tier.
440        efficient_target: String,
441    },
442    /// A judge picks the tier at each user turn; a stage router runs the turns within it.
443    Composite {
444        /// Judge that picks the tier. Called through its own target.
445        classifier: StageClassifierConfig,
446        /// The stage router the judge hands off to.
447        stage: StageTierConfig,
448        /// Settings for the profile selected by an efficient verdict.
449        #[serde(default)]
450        efficient: Option<Box<StageProfileConfig>>,
451        /// Settings for the profile selected by a capable verdict.
452        #[serde(default)]
453        capable: Option<Box<StageProfileConfig>>,
454        /// Separate policy for delegated sub-agent work.
455        #[serde(default)]
456        subagents: Option<SubagentRouteConfig>,
457    },
458    /// Serves every turn from one target, and has a second model review some of
459    /// those turns before the caller sees them.
460    Advisor {
461        /// Serves every client-visible turn.
462        executor_target: String,
463        /// Reviews gated turns. Never a routing destination.
464        advisor_target: String,
465        /// Replaces the built-in APPROVE/REDO reviewer prompt.
466        #[serde(default)]
467        reviewer_system_prompt: Option<String>,
468        /// Replaces the built-in text put in front of a REDO plan.
469        #[serde(default)]
470        redo_feedback_prefix: Option<String>,
471        /// What fires a review.
472        #[serde(default)]
473        gate_trigger: AdvisorTriggerConfig,
474        /// Regular expression for the `pattern` trigger. Required by it, and unused otherwise.
475        #[serde(default)]
476        gate_trigger_pattern: Option<String>,
477        /// How many reviews one session may spend.
478        #[serde(default = "default_max_reviews")]
479        max_reviews: u32,
480        /// Reviews a turn after this many assistant turns, as a mid-task
481        /// checkpoint. Zero turns the checkpoint off.
482        #[serde(default)]
483        gate_stall_turns: u32,
484        /// Tool results a turn needs before it can be reviewed. Skips early chatty turns.
485        #[serde(default)]
486        gate_min_tool_results: u32,
487        /// Most output tokens one review may use.
488        #[serde(default = "default_advisor_max_tokens")]
489        advisor_max_tokens: u64,
490        /// Sampling temperature for reviews. Left off the request when unset.
491        #[serde(default)]
492        advisor_temperature: Option<f64>,
493        /// Size cap on the transcript sent to the advisor. Longer transcripts
494        /// are trimmed from the middle.
495        #[serde(default = "default_transcript_max_chars")]
496        transcript_max_chars: usize,
497        /// Lets the turn through when the advisor fails, instead of erroring.
498        #[serde(default = "default_fail_open")]
499        fail_open: bool,
500    },
501    Vgr {
502        #[serde(flatten)]
503        config: VgrRouteConfig,
504    },
505    /// Routes using a checkpoint-backed prefill classifier.
506    PrefillRouter {
507        /// Target names in checkpoint output order.
508        targets: Vec<String>,
509        /// Tensor-only router checkpoint path.
510        checkpoint: PathBuf,
511        /// PyTorch device used for encoder inference, such as `cpu`, `cuda`, or
512        /// `cuda:0`. Auto-detected when omitted.
513        device: Option<String>,
514        /// Directory where Hugging Face caches the downloaded encoder and tokenizer.
515        cache_dir: Option<PathBuf>,
516        /// Maximum tokenized encoder input length; longer prompts are truncated.
517        max_length: Option<usize>,
518        /// Maximum prompts per encoder forward pass.
519        batch_size: Option<usize>,
520    },
521}
522
523#[derive(Clone, Debug, Deserialize)]
524#[serde(deny_unknown_fields)]
525pub struct VgrRouteConfig {
526    pub efficient_target: String,
527    pub capable_target: String,
528    /// Defaults to `efficient_target`.
529    #[serde(default)]
530    pub judge_target: Option<String>,
531    #[serde(default)]
532    pub capable_judge_target: Option<String>,
533    #[serde(default)]
534    pub mode: VgrModeConfig,
535    #[serde(default)]
536    pub active_approval: Option<String>,
537    #[serde(default = "default_vgr_deadline")]
538    pub deadline_seconds: f64,
539    /// Enforces VGR decision and efficient turn budgets. Defaults to true.
540    #[serde(default = "default_true")]
541    pub bounded_efficient: bool,
542    #[serde(default = "default_true")]
543    pub task_typing: bool,
544    #[serde(default = "default_vgr_breaker_threshold")]
545    pub breaker_threshold: u32,
546    #[serde(default = "default_vgr_breaker_cooldown")]
547    pub breaker_cooldown_seconds: f64,
548    /// Clean trailing tool results that let a long run recover on the capable judge.
549    #[serde(default)]
550    pub confirmed_recovery_min_clean_tail: Option<u32>,
551    /// Tells the capable tier it inherits unverified tool-using work.
552    #[serde(default)]
553    pub agentic_handoff: bool,
554    /// Condenses history from the efficient tier at handoff. Requires `agentic_handoff`.
555    #[serde(default)]
556    pub compact_handoff: bool,
557    /// Wall-clock budget for the efficient tier within one user turn.
558    #[serde(default)]
559    pub efficient_turn_budget_seconds: Option<f64>,
560    /// Holds escalations across later user turns.
561    #[serde(default)]
562    pub latch_escalation: bool,
563}
564
565#[derive(Clone, Debug, Default, Deserialize)]
566#[serde(rename_all = "snake_case")]
567pub enum VgrModeConfig {
568    #[default]
569    Off,
570    Evaluate,
571    Shadow,
572    Active,
573}
574
575/// What fires an advisor route's review.
576#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
577#[serde(rename_all = "snake_case")]
578pub enum AdvisorTriggerConfig {
579    /// The executor's first turn without tool calls.
580    #[default]
581    NoToolCall,
582    /// The first turn whose text matches `gate_trigger_pattern`.
583    Pattern,
584}
585
586/// The judge a `stage_router` route falls through to, and how it routes.
587#[derive(Clone, Debug, Deserialize)]
588#[serde(deny_unknown_fields)]
589pub struct StageClassifierConfig {
590    /// Target the judge is called through. Not a routing destination.
591    pub target: String,
592    /// Composite accepts custom-schema judges as well as capability judges.
593    #[serde(default)]
594    pub mode: Option<ClassifierMode>,
595    #[serde(default)]
596    pub response_schema: Option<String>,
597    #[serde(default)]
598    pub policy: Option<ClassifierPolicyConfig>,
599    #[serde(default)]
600    pub decision: Option<DecisionJudgeRouteConfig>,
601    #[serde(default)]
602    pub fail_open: Option<bool>,
603    /// Lowest solve probability that still routes to the efficient tier, from 0 to 1.
604    pub base_threshold: Option<f64>,
605    /// How much to raise the threshold when the judge is uncertain. Added once
606    /// for an uncertain verdict and twice for unsupported.
607    #[serde(default)]
608    pub threshold_step: Option<f64>,
609    /// How often the judge runs. Composite honors it. A standalone stage route
610    /// rejects any value other than `every_request`, because its fallback judge
611    /// runs on every ambiguous turn and retains no verdict.
612    #[serde(default)]
613    pub classify_trigger: ClassifyTrigger,
614    /// Reuses the session's target by hashing the first user message when no
615    /// session ID is available.
616    #[serde(default)]
617    pub message_hash_fallback: bool,
618    /// How many trailing turns the judge sees. Unset shows it the opening task
619    /// and the latest user follow-up only.
620    #[serde(default)]
621    pub recent_turn_window: Option<usize>,
622    /// Replaces the packaged judge prompt.
623    #[serde(default)]
624    pub prompt: Option<String>,
625    /// How the judge is asked for structured output. Use `json_object` when the
626    /// provider cannot do JSON Schema.
627    #[serde(default)]
628    pub response_format_type: ClassifierResponseFormat,
629    /// Most completion tokens the judge verdict may use.
630    #[serde(default = "default_classifier_max_output_tokens")]
631    pub max_output_tokens: u64,
632    /// Whole-consultation bound on the judge call in milliseconds; expiry follows the
633    /// route's `fail_open` setting. Unset leaves the consultation unbounded.
634    #[serde(default)]
635    pub judge_deadline_ms: Option<u64>,
636}
637
638/// The shared model pair and defaults for composite stage profiles.
639#[derive(Clone, Debug, Deserialize)]
640#[serde(deny_unknown_fields)]
641pub struct StageTierConfig {
642    pub capable_target: String,
643    pub efficient_target: String,
644    #[serde(default)]
645    pub capable_first_threshold: f64,
646    #[serde(flatten)]
647    pub settings: StageSettingsConfig,
648}
649
650/// Settings shared by standalone stage routes and composite profiles.
651#[derive(Clone, Debug, Default, Deserialize)]
652#[serde(default, deny_unknown_fields)]
653pub struct StageSettingsConfig {
654    #[serde(alias = "efficient_first_threshold")]
655    pub confidence_threshold: Option<f64>,
656    pub recent_turn_window: Option<usize>,
657    pub tool_semantics: Option<ToolSemantics>,
658    pub capable_hold_turns: Option<i64>,
659    pub efficient_hold_turns: Option<i64>,
660    pub handoff_notes: Option<HandoffNoteConfig>,
661    pub capable_system_prompt: Option<String>,
662    pub efficient_system_prompt: Option<String>,
663}
664
665#[derive(Clone, Debug, Default, Deserialize)]
666#[serde(deny_unknown_fields)]
667pub struct StageProfileConfig {
668    pub classifier: Option<StageClassifierConfig>,
669    pub picker: Option<PickerMode>,
670    #[serde(flatten)]
671    pub settings: StageSettingsConfig,
672}
673
674impl StageSettingsConfig {
675    fn stage_config(&self, shared: &Self, mode: PickerMode, threshold: f64) -> StageRouterConfig {
676        let mut config =
677            StageRouterConfig::new(mode, self.confidence_threshold.unwrap_or(threshold));
678        config.recent_window = self.recent_turn_window.or(shared.recent_turn_window);
679        config.tool_semantics = self
680            .tool_semantics
681            .as_ref()
682            .or(shared.tool_semantics.as_ref())
683            .cloned()
684            .unwrap_or_default();
685        config.capable_hold_turns = self
686            .capable_hold_turns
687            .or(shared.capable_hold_turns)
688            .unwrap_or(2);
689        config.efficient_hold_turns = self.efficient_hold_turns.or(shared.efficient_hold_turns);
690        config.handoff_notes = self
691            .handoff_notes
692            .as_ref()
693            .or(shared.handoff_notes.as_ref())
694            .cloned();
695        config.capable_system_prompt = self
696            .capable_system_prompt
697            .as_ref()
698            .or(shared.capable_system_prompt.as_ref())
699            .cloned();
700        config.efficient_system_prompt = self
701            .efficient_system_prompt
702            .as_ref()
703            .or(shared.efficient_system_prompt.as_ref())
704            .cloned();
705        config
706    }
707}
708
709impl StageTierConfig {
710    /// Warns when both tiers name one target; see [`warn_identical_tiers`].
711    fn warn_identical_tiers(&self, route_name: &str) {
712        warn_identical_tiers(route_name, &self.capable_target, &self.efficient_target);
713    }
714}
715
716impl StageClassifierConfig {
717    fn task_classifier_config(
718        &self,
719        route_name: &str,
720        capable_target: &str,
721        efficient_target: &str,
722    ) -> AlgorithmResult<TaskClassifierConfig> {
723        if self
724            .mode
725            .is_some_and(|mode| !matches!(mode, ClassifierMode::Capability))
726            || self.response_schema.is_some()
727            || self.policy.is_some()
728        {
729            return Err(AlgorithmConfigError::new(
730                "custom classifier settings require composite with mode = custom",
731            ));
732        }
733        let config = LlmClassifierRouteConfig {
734            classifier_target: self.target.clone(),
735            strong_target: Some(capable_target.to_string()),
736            weak_target: Some(efficient_target.to_string()),
737            base_threshold: self.base_threshold,
738            threshold_step: self.threshold_step,
739            fail_open: self.fail_open,
740            decision: self.decision.clone(),
741            classify_trigger: self.classify_trigger,
742            message_hash_fallback: self.message_hash_fallback,
743            recent_turn_window: self.recent_turn_window,
744            prompt: self.prompt.clone(),
745            response_format_type: self.response_format_type,
746            max_output_tokens: self.max_output_tokens,
747            judge_deadline_ms: self.judge_deadline_ms,
748            ..Default::default()
749        };
750        match config.validated_classifier_mode(route_name)? {
751            LlmClassifierModeConfig::Capability(config) => Ok(config.into_task_config()),
752            _ => Err(AlgorithmConfigError::new("expected a capability judge")),
753        }
754    }
755
756    fn infer_decision(
757        &mut self,
758        infer: &mut impl FnMut(&str, &mut Option<DecisionJudgeRouteConfig>),
759    ) {
760        if matches!(self.mode, None | Some(ClassifierMode::Capability)) {
761            infer(&self.target, &mut self.decision);
762        }
763    }
764
765    fn stage_fallback(
766        &self,
767        route_name: &str,
768        capable: &str,
769        efficient: &str,
770        targets: &BTreeMap<String, ModelId>,
771    ) -> AlgorithmResult<LlmFallback> {
772        Ok(LlmFallback {
773            target: Some(resolve_target_model_id(route_name, &self.target, targets)?),
774            config: self.task_classifier_config(route_name, capable, efficient)?,
775        })
776    }
777
778    fn custom_classifier_config(
779        &self,
780        route_name: &str,
781    ) -> AlgorithmResult<CustomClassifierConfig> {
782        if self.fail_open == Some(false) {
783            return Err(AlgorithmConfigError::new(
784                "composite custom classifiers always fail open; remove fail_open = false",
785            ));
786        }
787        if self.decision.is_some()
788            || self.base_threshold.is_some()
789            || self.threshold_step.is_some()
790            || self.response_format_type != ClassifierResponseFormat::JsonSchema
791        {
792            return Err(AlgorithmConfigError::new(
793                "composite custom classifier cannot use capability thresholds, decision settings, or response_format_type",
794            ));
795        }
796        let schema =
797            required_classifier_field(route_name, "response_schema", &self.response_schema)?;
798        let schema = serde_json::from_str(&schema).map_err(|error| {
799            AlgorithmConfigError::with_source(
800                format!("composite route {route_name}: response_schema is invalid JSON"),
801                error,
802            )
803        })?;
804        let mut config = CustomClassifierConfig::new(
805            required_classifier_field(route_name, "prompt", &self.prompt)?,
806            schema,
807            required_classifier_field(route_name, "policy", &self.policy)?.into_libsy(),
808        );
809        config.classify_trigger = self.classify_trigger;
810        config.message_hash_fallback = self.message_hash_fallback;
811        config.recent_turn_window = self.recent_turn_window;
812        config.max_output_tokens = self.max_output_tokens;
813        Ok(config)
814    }
815}
816
817impl AlgorithmSpec {
818    /// Filters configured pools without replacing required targets or changing their roles.
819    pub(crate) fn restricted_to(&self, allowed: &BTreeSet<&str>) -> AlgorithmResult<Self> {
820        let mut restricted = self.clone();
821        match &mut restricted {
822            Self::Random {
823                targets, weights, ..
824            } => {
825                if let Some(weights) = weights {
826                    if weights.len() != targets.len() {
827                        return Err(AlgorithmConfigError::new(format!(
828                            "random expected {} weights, got {}",
829                            targets.len(),
830                            weights.len()
831                        )));
832                    }
833                    // Keep weight positions paired with the original target order.
834                    let mut index = 0;
835                    weights.retain(|_| {
836                        let keep = allowed.contains(targets[index].as_str());
837                        index += 1;
838                        keep
839                    });
840                }
841                targets.retain(|target| allowed.contains(target.as_str()));
842                if targets.is_empty() {
843                    return Err(AlgorithmConfigError::new(
844                        "target requirements leave random without an eligible target".to_string(),
845                    ));
846                }
847            }
848            Self::LlmClassifier { config } => {
849                if let Some(models) = &mut config.models {
850                    models.restrict_to(allowed);
851                }
852            }
853            _ => {}
854        }
855        let subagents = match &mut restricted {
856            Self::Passthrough { subagents, .. }
857            | Self::StageRouter { subagents, .. }
858            | Self::Composite { subagents, .. } => subagents.as_mut(),
859            _ => None,
860        };
861        if let Some(SubagentRouteConfig::LlmClassifier(config)) = subagents
862            && let Some(models) = &mut config.models
863        {
864            models.restrict_to(allowed);
865        }
866        if let Some(target) = restricted
867            .callable_target_names()
868            .into_iter()
869            .find(|target| !allowed.contains(target))
870        {
871            return Err(AlgorithmConfigError::new(format!(
872                "required target {target} does not satisfy target requirements"
873            )));
874        }
875        Ok(restricted)
876    }
877
878    pub(crate) const fn supports_target_restrictions(&self) -> bool {
879        !matches!(self, Self::PrefillRouter { .. })
880    }
881
882    /// This iterator yields tier classifier targets and their optional decision settings.
883    /// It includes classifiers in nested Composite profiles.
884    /// Standalone custom and direct modes use model groups and are excluded.
885    pub(crate) fn classifier_targets(
886        &self,
887    ) -> impl Iterator<Item = (&str, Option<&DecisionJudgeRouteConfig>)> {
888        let (primary, profiles) = match self {
889            Self::LlmClassifier { config, .. }
890                if !matches!(
891                    config.classifier_mode(),
892                    ClassifierMode::Custom | ClassifierMode::Direct
893                ) =>
894            {
895                (
896                    Some((config.classifier_target.as_str(), config.decision.as_ref())),
897                    [None, None],
898                )
899            }
900            Self::Composite {
901                classifier,
902                efficient,
903                capable,
904                ..
905            } => (
906                Some((classifier.target.as_str(), classifier.decision.as_ref())),
907                [efficient.as_deref(), capable.as_deref()],
908            ),
909            Self::StageRouter { classifier, .. } => (
910                classifier
911                    .as_ref()
912                    .map(|c| (c.target.as_str(), c.decision.as_ref())),
913                [None, None],
914            ),
915            _ => (None, [None, None]),
916        };
917        primary.into_iter().chain(
918            profiles
919                .into_iter()
920                .flatten()
921                .filter_map(|profile| profile.classifier.as_ref())
922                .map(|c| (c.target.as_str(), c.decision.as_ref())),
923        )
924    }
925
926    /// Gives capability judges default decision settings when their target is a decision target.
927    pub(crate) fn infer_decision_judges(&mut self, is_decision_target: impl Fn(&str) -> bool) {
928        let mut infer = |target: &str, decision: &mut Option<DecisionJudgeRouteConfig>| {
929            if is_decision_target(target) {
930                decision.get_or_insert_with(Default::default);
931            }
932        };
933        match self {
934            Self::LlmClassifier { config }
935                if matches!(config.classifier_mode(), ClassifierMode::Capability) =>
936            {
937                infer(&config.classifier_target, &mut config.decision);
938            }
939            Self::StageRouter {
940                classifier: Some(classifier),
941                ..
942            } => classifier.infer_decision(&mut infer),
943            Self::Composite {
944                classifier,
945                efficient,
946                capable,
947                ..
948            } => {
949                classifier.infer_decision(&mut infer);
950                for profile in [efficient, capable].into_iter().flatten() {
951                    if let Some(classifier) = &mut profile.classifier {
952                        classifier.infer_decision(&mut infer);
953                    }
954                }
955            }
956            _ => {}
957        }
958    }
959
960    /// Judge targets that must be decision targets.
961    pub(crate) fn decision_target_names(&self) -> Vec<&str> {
962        let mut names: Vec<&str> = self
963            .classifier_targets()
964            .filter(|(_, decision)| decision.is_some())
965            .map(|(target, _)| target)
966            .collect();
967        if let Self::LlmClassifier { config, .. } = self
968            && matches!(config.classifier_mode(), ClassifierMode::Direct)
969            && let Some(models) = &config.models
970        {
971            names.extend(models.get(&Category::Judge).iter().map(String::as_str));
972        }
973        names
974    }
975
976    /// Completion targets in algorithm order; judge-only targets are excluded.
977    pub fn routing_target_names(&self) -> Vec<&str> {
978        match self {
979            Self::Noop { .. } => Vec::new(),
980            Self::Random { targets, .. } => targets.iter().map(String::as_str).collect(),
981            Self::Passthrough {
982                target, subagents, ..
983            } => {
984                let mut names = vec![target.as_str()];
985                if let Some(subagents) = subagents {
986                    names.extend(subagents.routing_target_names());
987                }
988                names
989            }
990            Self::LlmClassifier { config, .. } => match config.classifier_mode() {
991                ClassifierMode::Capability => config
992                    .weak_target
993                    .iter()
994                    .chain(&config.strong_target)
995                    .map(String::as_str)
996                    .collect(),
997                ClassifierMode::Escalation => config
998                    .strong_target
999                    .iter()
1000                    .chain(&config.weak_target)
1001                    .map(String::as_str)
1002                    .collect(),
1003                ClassifierMode::Custom | ClassifierMode::Direct => config
1004                    .models
1005                    .as_ref()
1006                    .map(CategoryModelConfig::routing_names)
1007                    .unwrap_or_default(),
1008            },
1009            Self::StageRouter {
1010                capable_target,
1011                efficient_target,
1012                subagents,
1013                ..
1014            } => {
1015                let mut names = vec![capable_target.as_str(), efficient_target.as_str()];
1016                if let Some(subagents) = subagents {
1017                    names.extend(subagents.routing_target_names());
1018                }
1019                names
1020            }
1021            Self::Auto {
1022                capable_target,
1023                efficient_target,
1024            } => vec![capable_target.as_str(), efficient_target.as_str()],
1025            Self::Composite {
1026                stage, subagents, ..
1027            } => {
1028                let mut names = vec![
1029                    stage.capable_target.as_str(),
1030                    stage.efficient_target.as_str(),
1031                ];
1032                if let Some(subagents) = subagents {
1033                    names.extend(subagents.routing_target_names());
1034                }
1035                names
1036            }
1037            // The advisor is judge-only: reviews go through its own client,
1038            // so it is not a completion (or count_tokens) destination.
1039            Self::Advisor {
1040                executor_target, ..
1041            } => vec![executor_target],
1042            Self::Vgr { config } => {
1043                vec![
1044                    config.efficient_target.as_str(),
1045                    config.capable_target.as_str(),
1046                ]
1047            }
1048            Self::PrefillRouter { targets, .. } => targets.iter().map(String::as_str).collect(),
1049        }
1050    }
1051
1052    /// Every target the algorithm may call, including judge-only targets.
1053    ///
1054    /// [`routing_target_names`](Self::routing_target_names) covers completion destinations;
1055    /// a classifier also calls its judge, and that call needs a client too.
1056    pub fn callable_target_names(&self) -> Vec<&str> {
1057        let mut names = self.routing_target_names();
1058        names.extend(self.judge_target_names());
1059        names
1060    }
1061
1062    /// Targets the algorithm calls only to decide, such as a judge or an advisor. A target
1063    /// that also answers requests appears in [`routing_target_names`](Self::routing_target_names) too.
1064    pub fn judge_target_names(&self) -> Vec<&str> {
1065        let mut names = Vec::new();
1066        match self {
1067            // Custom and direct modes name their judge in `models.judge`; the other
1068            // two modes use the top-level `classifier_target`.
1069            Self::LlmClassifier { config, .. } => {
1070                if matches!(
1071                    config.classifier_mode(),
1072                    ClassifierMode::Custom | ClassifierMode::Direct
1073                ) {
1074                    names.extend(
1075                        config
1076                            .models
1077                            .as_ref()
1078                            .map(|models| models.get(&Category::Judge))
1079                            .unwrap_or_default()
1080                            .iter()
1081                            .map(String::as_str),
1082                    );
1083                } else {
1084                    names.push(config.classifier_target.as_str());
1085                }
1086            }
1087            Self::StageRouter { .. } | Self::Composite { .. } => {
1088                names.extend(self.classifier_targets().map(|(target, _)| target));
1089            }
1090            Self::Advisor { advisor_target, .. } => names.push(advisor_target),
1091            Self::Vgr { config } => {
1092                names.extend(config.judge_target.as_deref());
1093                names.extend(config.capable_judge_target.as_deref());
1094            }
1095            _ => {}
1096        }
1097        // A sub-agent classifier calls its own judge, which is never a completion target.
1098        if let Self::Passthrough {
1099            subagents: Some(subagents),
1100            ..
1101        }
1102        | Self::StageRouter {
1103            subagents: Some(subagents),
1104            ..
1105        }
1106        | Self::Composite {
1107            subagents: Some(subagents),
1108            ..
1109        } = self
1110        {
1111            names.extend(subagents.judge_target_names());
1112        }
1113        names
1114    }
1115
1116    /// Target names grouped as the runtime [`Driver`](libsy::Driver) expects them.
1117    fn runtime_model_names(&self, route_name: &str) -> AlgorithmResult<RuntimeModelNames> {
1118        let parent = match self {
1119            Self::Noop { .. } => HashMap::new(),
1120            Self::Random { targets, .. } | Self::PrefillRouter { targets, .. } => {
1121                category_models([(Category::Any, targets.clone())])
1122            }
1123            Self::Passthrough { target, .. } => {
1124                category_models([(Category::Any, vec![target.clone()])])
1125            }
1126            Self::LlmClassifier { config } => {
1127                classifier_runtime_model_names(config.validated_classifier_mode(route_name)?)
1128            }
1129            Self::StageRouter {
1130                capable_target,
1131                efficient_target,
1132                classifier,
1133                ..
1134            } => {
1135                let mut models = category_models([
1136                    (Category::Capable, vec![capable_target.clone()]),
1137                    (Category::Efficient, vec![efficient_target.clone()]),
1138                    (
1139                        Category::Any,
1140                        vec![capable_target.clone(), efficient_target.clone()],
1141                    ),
1142                ]);
1143                if let Some(classifier) = classifier {
1144                    models.insert(Category::Judge, vec![classifier.target.clone()]);
1145                }
1146                models
1147            }
1148            Self::Auto {
1149                capable_target,
1150                efficient_target,
1151            } => category_models([
1152                (Category::Capable, vec![capable_target.clone()]),
1153                (Category::Efficient, vec![efficient_target.clone()]),
1154                (
1155                    Category::Any,
1156                    vec![capable_target.clone(), efficient_target.clone()],
1157                ),
1158            ]),
1159            Self::Composite { stage, .. } => category_models([
1160                (
1161                    Category::Judge,
1162                    self.judge_target_names()
1163                        .into_iter()
1164                        .map(str::to_string)
1165                        .collect(),
1166                ),
1167                (Category::Capable, vec![stage.capable_target.clone()]),
1168                (Category::Efficient, vec![stage.efficient_target.clone()]),
1169                (
1170                    Category::Any,
1171                    vec![stage.capable_target.clone(), stage.efficient_target.clone()],
1172                ),
1173            ]),
1174            Self::Advisor {
1175                executor_target,
1176                advisor_target,
1177                ..
1178            } => category_models([
1179                (Category::Efficient, vec![executor_target.clone()]),
1180                (Category::Any, vec![executor_target.clone()]),
1181                (Category::Judge, vec![advisor_target.clone()]),
1182            ]),
1183            Self::Vgr { config } => {
1184                let judge = config
1185                    .judge_target
1186                    .as_ref()
1187                    .unwrap_or(&config.efficient_target);
1188                let judges = std::iter::once(judge)
1189                    .chain(&config.capable_judge_target)
1190                    .cloned()
1191                    .collect();
1192                category_models([
1193                    (Category::Efficient, vec![config.efficient_target.clone()]),
1194                    (Category::Capable, vec![config.capable_target.clone()]),
1195                    (
1196                        Category::Any,
1197                        vec![
1198                            config.efficient_target.clone(),
1199                            config.capable_target.clone(),
1200                        ],
1201                    ),
1202                    (Category::Judge, judges),
1203                ])
1204            }
1205        };
1206
1207        let subagents = match self {
1208            Self::Passthrough { subagents, .. }
1209            | Self::StageRouter { subagents, .. }
1210            | Self::Composite { subagents, .. } => subagents.as_ref(),
1211            _ => None,
1212        };
1213        // Sub-agent groups stay separate from the parent's. Merging them would let a
1214        // sub-agent's `capable` resolve to the parent's, and would put models only the
1215        // sub-agents were given into the parent's `Any` fallback list.
1216        let subagent = subagents
1217            .map(|subagents| subagent_runtime_model_names(subagents, route_name))
1218            .transpose()?;
1219        Ok(RuntimeModelNames { parent, subagent })
1220    }
1221
1222    /// Response target and routing-only dependency for routers that answer while routing.
1223    #[cfg(not(target_os = "wasi"))]
1224    pub(crate) fn routing_response_and_dependency(&self) -> Option<(&str, &str)> {
1225        match self {
1226            Self::LlmClassifier { config, .. }
1227                if matches!(config.classifier_mode(), ClassifierMode::Escalation) =>
1228            {
1229                Some((
1230                    config.weak_target.as_deref()?,
1231                    config.classifier_target.as_str(),
1232                ))
1233            }
1234            Self::Advisor {
1235                executor_target,
1236                advisor_target,
1237                ..
1238            } => Some((executor_target, advisor_target)),
1239            Self::Vgr { config } => Some((
1240                &config.efficient_target,
1241                config
1242                    .judge_target
1243                    .as_deref()
1244                    .unwrap_or(&config.efficient_target),
1245            )),
1246            Self::Noop { .. }
1247            | Self::Random { .. }
1248            | Self::Passthrough { .. }
1249            | Self::LlmClassifier { .. }
1250            | Self::StageRouter { .. }
1251            | Self::Auto { .. }
1252            | Self::Composite { .. }
1253            | Self::PrefillRouter { .. } => None,
1254        }
1255    }
1256
1257    /// Builds this algorithm after resolving configured target names.
1258    pub fn build(
1259        &self,
1260        route_name: &str,
1261        targets: &BTreeMap<String, ModelId>,
1262    ) -> AlgorithmResult<Arc<dyn Algorithm>> {
1263        build_algorithm(route_name, self, targets)
1264    }
1265
1266    /// Resolves target names to host model IDs and builds the algorithm and runtime groups.
1267    pub fn build_with_runtime_models(
1268        &self,
1269        route_name: &str,
1270        targets: &BTreeMap<String, ModelId>,
1271    ) -> AlgorithmResult<(Arc<dyn Algorithm>, RuntimeModels)> {
1272        let algorithm = self.build(route_name, targets)?;
1273        let names = self.runtime_model_names(route_name)?;
1274        let mut models =
1275            RuntimeModels::new(resolve_runtime_models(route_name, names.parent, targets)?);
1276        if let Some(subagent) = names.subagent {
1277            models = models.with_subagent(resolve_runtime_models(route_name, subagent, targets)?);
1278        }
1279        Ok((algorithm, models))
1280    }
1281}
1282
1283/// One route's target names, grouped by category and by routing scope.
1284struct RuntimeModelNames {
1285    /// Groups the algorithm itself routes over.
1286    parent: HashMap<Category, Vec<String>>,
1287    /// Groups delegated sub-agent work routes over, when the route has a `subagents` table.
1288    subagent: Option<HashMap<Category, Vec<String>>>,
1289}
1290
1291fn category_models(
1292    entries: impl IntoIterator<Item = (Category, Vec<String>)>,
1293) -> HashMap<Category, Vec<String>> {
1294    entries.into_iter().collect()
1295}
1296
1297fn custom_runtime_model_names(config: &CategoryModelConfig) -> HashMap<Category, Vec<String>> {
1298    config.groups().collect()
1299}
1300
1301fn classifier_runtime_model_names(
1302    config: LlmClassifierModeConfig,
1303) -> HashMap<Category, Vec<String>> {
1304    match config {
1305        LlmClassifierModeConfig::Capability(config) => category_models([
1306            (Category::Judge, vec![config.classifier_target]),
1307            (Category::Efficient, vec![config.weak_target.clone()]),
1308            (Category::Capable, vec![config.strong_target.clone()]),
1309            (
1310                Category::Any,
1311                vec![config.weak_target, config.strong_target],
1312            ),
1313        ]),
1314        LlmClassifierModeConfig::Escalation(config) => category_models([
1315            (Category::Judge, vec![config.classifier_target]),
1316            (Category::Efficient, vec![config.weak_target.clone()]),
1317            (Category::Capable, vec![config.strong_target.clone()]),
1318            (
1319                Category::Any,
1320                vec![config.strong_target, config.weak_target],
1321            ),
1322        ]),
1323        LlmClassifierModeConfig::Custom(config) => custom_runtime_model_names(&config.models),
1324        LlmClassifierModeConfig::Direct { models, .. } => custom_runtime_model_names(&models),
1325    }
1326}
1327
1328fn subagent_runtime_model_names(
1329    config: &SubagentRouteConfig,
1330    route_name: &str,
1331) -> AlgorithmResult<HashMap<Category, Vec<String>>> {
1332    match config {
1333        SubagentRouteConfig::Passthrough { target } => {
1334            Ok(category_models([(Category::Any, vec![target.clone()])]))
1335        }
1336        SubagentRouteConfig::LlmClassifier(config) => {
1337            let LlmClassifierModeConfig::Custom(config) =
1338                config.validated_classifier_mode(route_name)?
1339            else {
1340                return Err(AlgorithmConfigError::new(format!(
1341                    "route {route_name}: subagents llm_classifier only supports mode custom"
1342                )));
1343            };
1344            Ok(custom_runtime_model_names(&config.models))
1345        }
1346    }
1347}
1348impl LlmClassifierRouteConfig {
1349    fn classifier_mode(&self) -> ClassifierMode {
1350        let default = if self.escalation.is_some() {
1351            ClassifierMode::Escalation
1352        } else {
1353            ClassifierMode::Capability
1354        };
1355        self.mode.unwrap_or(default)
1356    }
1357
1358    fn validated_classifier_mode(
1359        &self,
1360        route_name: &str,
1361    ) -> AlgorithmResult<LlmClassifierModeConfig> {
1362        let Self {
1363            classifier_target,
1364            mode,
1365            strong_target,
1366            weak_target,
1367            base_threshold,
1368            threshold_step,
1369            unmatched_steps,
1370            fail_open,
1371            classify_trigger,
1372            message_hash_fallback,
1373            recent_turn_window,
1374            prompt,
1375            response_format_type,
1376            max_output_tokens,
1377            judge_deadline_ms,
1378            escalation,
1379            decision,
1380            models,
1381            default_target,
1382            response_schema,
1383            policy,
1384        } = self;
1385
1386        let selected_mode = match (mode, escalation.is_some()) {
1387            (Some(mode), _) => *mode,
1388            (None, true) => ClassifierMode::Escalation,
1389            (None, false) => ClassifierMode::Capability,
1390        };
1391
1392        if decision.is_some() && !matches!(selected_mode, ClassifierMode::Capability) {
1393            return Err(AlgorithmConfigError::new(format!(
1394                "llm_classifier route {route_name}: decision requires capability mode"
1395            )));
1396        }
1397
1398        if !matches!(
1399            selected_mode,
1400            ClassifierMode::Capability | ClassifierMode::Direct
1401        ) && *fail_open == Some(true)
1402        {
1403            return Err(AlgorithmConfigError::new(format!(
1404                "llm_classifier route {route_name}: fail_open = true requires capability or direct mode"
1405            )));
1406        }
1407
1408        match selected_mode {
1409            ClassifierMode::Capability => {
1410                if escalation.is_some() {
1411                    return Err(classifier_field_error(
1412                        route_name,
1413                        "escalation",
1414                        "capability",
1415                    ));
1416                }
1417                reject_custom_fields(
1418                    route_name,
1419                    "capability",
1420                    models,
1421                    default_target,
1422                    response_schema,
1423                    policy,
1424                )?;
1425                let judge = if let Some(decision) = decision {
1426                    if base_threshold.is_some()
1427                        || threshold_step.is_some()
1428                        || unmatched_steps.is_some()
1429                        || prompt.is_some()
1430                        || *response_format_type != ClassifierResponseFormat::JsonSchema
1431                        || *max_output_tokens != default_classifier_max_output_tokens()
1432                        || judge_deadline_ms.is_some()
1433                    {
1434                        return Err(AlgorithmConfigError::new(format!(
1435                            "llm_classifier route {route_name}: decision cannot use LLM judge settings; use decision.cutoff and decision.instructions"
1436                        )));
1437                    }
1438                    CapabilityJudgeRouteConfig::Decision(decision.clone())
1439                } else {
1440                    CapabilityJudgeRouteConfig::Llm(LlmCapabilityConfig {
1441                        base_threshold: required_classifier_field(
1442                            route_name,
1443                            "base_threshold",
1444                            base_threshold,
1445                        )?,
1446                        threshold_step: threshold_step.unwrap_or_default(),
1447                        unmatched_steps: unmatched_steps.unwrap_or(DEFAULT_UNMATCHED_STEPS),
1448                        contract: classifier_contract(prompt.as_deref())
1449                            .with_response_format_type(*response_format_type),
1450                        max_output_tokens: *max_output_tokens,
1451                        judge_deadline_ms: *judge_deadline_ms,
1452                    })
1453                };
1454                Ok(LlmClassifierModeConfig::Capability(
1455                    CapabilityClassifierRouteConfig {
1456                        classifier_target: classifier_target.clone(),
1457                        fail_open: fail_open.unwrap_or(true),
1458                        strong_target: required_classifier_field(
1459                            route_name,
1460                            "strong_target",
1461                            strong_target,
1462                        )?,
1463                        weak_target: required_classifier_field(
1464                            route_name,
1465                            "weak_target",
1466                            weak_target,
1467                        )?,
1468                        judge,
1469                        classify_trigger: *classify_trigger,
1470                        message_hash_fallback: *message_hash_fallback,
1471                        recent_turn_window: *recent_turn_window,
1472                    },
1473                ))
1474            }
1475            ClassifierMode::Escalation => {
1476                reject_custom_fields(
1477                    route_name,
1478                    "escalation",
1479                    models,
1480                    default_target,
1481                    response_schema,
1482                    policy,
1483                )?;
1484                if *classify_trigger != ClassifyTrigger::EveryRequest {
1485                    return Err(AlgorithmConfigError::new(format!(
1486                        "llm_classifier route {route_name} mode escalation cannot use classify_trigger"
1487                    )));
1488                }
1489                let has_capability_settings = base_threshold.is_some()
1490                    || threshold_step.is_some()
1491                    || fail_open.is_some()
1492                    || *message_hash_fallback
1493                    || recent_turn_window.is_some();
1494                // `unmatched_steps` is new, so no existing escalation configuration carries it:
1495                // reject it even when the mode is implied by `escalation`. The older capability
1496                // keys stay tolerated in that implicit form for compatibility.
1497                if unmatched_steps.is_some() || (mode.is_some() && has_capability_settings) {
1498                    return Err(AlgorithmConfigError::new(format!(
1499                        "llm_classifier route {route_name} mode escalation cannot use capability routing settings"
1500                    )));
1501                }
1502                if has_capability_settings {
1503                    // Existing files that select escalation through the `escalation` table alone
1504                    // keep loading, but their capability keys do nothing in this mode.
1505                    tracing::warn!(
1506                        "llm_classifier route {route_name} runs in escalation mode because it has an escalation table; base_threshold, threshold_step, fail_open, recent_turn_window and message_hash_fallback are ignored in this mode. Set mode = \"escalation\" and remove them."
1507                    );
1508                }
1509                Ok(LlmClassifierModeConfig::Escalation(
1510                    EscalationClassifierRouteConfig {
1511                        classifier_target: classifier_target.clone(),
1512                        strong_target: required_classifier_field(
1513                            route_name,
1514                            "strong_target",
1515                            strong_target,
1516                        )?,
1517                        weak_target: required_classifier_field(
1518                            route_name,
1519                            "weak_target",
1520                            weak_target,
1521                        )?,
1522                        prompt: prompt.clone(),
1523                        response_format_type: *response_format_type,
1524                        max_output_tokens: *max_output_tokens,
1525                        judge_deadline_ms: *judge_deadline_ms,
1526                        judge: required_classifier_field(route_name, "escalation", escalation)?,
1527                    },
1528                ))
1529            }
1530            ClassifierMode::Custom => {
1531                if !classifier_target.is_empty()
1532                    || strong_target.is_some()
1533                    || weak_target.is_some()
1534                    || base_threshold.is_some()
1535                    || threshold_step.is_some()
1536                    || unmatched_steps.is_some()
1537                    || escalation.is_some()
1538                    || *response_format_type != ClassifierResponseFormat::JsonSchema
1539                {
1540                    return Err(AlgorithmConfigError::new(format!(
1541                        "llm_classifier route {route_name} mode custom cannot use capability or escalation fields and response_format_type must be 'json_schema'"
1542                    )));
1543                }
1544                let (models, default_target) =
1545                    validated_models(route_name, models, default_target)?;
1546                Ok(LlmClassifierModeConfig::Custom(
1547                    CustomClassifierRouteConfig {
1548                        models,
1549                        default_target,
1550                        prompt: required_classifier_field(route_name, "prompt", prompt)?,
1551                        response_schema: required_classifier_field(
1552                            route_name,
1553                            "response_schema",
1554                            response_schema,
1555                        )?,
1556                        policy: required_classifier_field(route_name, "policy", policy)?,
1557                        classify_trigger: *classify_trigger,
1558                        message_hash_fallback: *message_hash_fallback,
1559                        recent_turn_window: *recent_turn_window,
1560                        max_output_tokens: *max_output_tokens,
1561                        judge_deadline_ms: *judge_deadline_ms,
1562                    },
1563                ))
1564            }
1565            ClassifierMode::Direct => {
1566                if !classifier_target.is_empty()
1567                    || strong_target.is_some()
1568                    || weak_target.is_some()
1569                    || base_threshold.is_some()
1570                    || threshold_step.is_some()
1571                    || unmatched_steps.is_some()
1572                    || escalation.is_some()
1573                    || prompt.is_some()
1574                    || response_schema.is_some()
1575                    || policy.is_some()
1576                    || *response_format_type != ClassifierResponseFormat::JsonSchema
1577                    || *max_output_tokens != default_classifier_max_output_tokens()
1578                    || judge_deadline_ms.is_some()
1579                {
1580                    return Err(AlgorithmConfigError::new(format!(
1581                        "llm_classifier route {route_name} mode direct cannot use capability, escalation, or LLM judge fields"
1582                    )));
1583                }
1584                let (models, default_target) =
1585                    validated_models(route_name, models, default_target)?;
1586                let options = models
1587                    .groups()
1588                    .filter(|(category, targets)| {
1589                        !targets.is_empty() && !matches!(category, Category::Judge | Category::Any)
1590                    })
1591                    .map(|(category, _)| category)
1592                    .collect();
1593                Ok(LlmClassifierModeConfig::Direct {
1594                    models,
1595                    default_target,
1596                    config: DirectClassifierConfig {
1597                        options,
1598                        fail_open: fail_open.unwrap_or(true),
1599                        classify_trigger: *classify_trigger,
1600                        message_hash_fallback: *message_hash_fallback,
1601                        recent_turn_window: *recent_turn_window,
1602                    },
1603                })
1604            }
1605        }
1606    }
1607}
1608
1609fn validated_models(
1610    route_name: &str,
1611    models: &Option<CategoryModelConfig>,
1612    default_target: &Option<String>,
1613) -> AlgorithmResult<(CategoryModelConfig, Category)> {
1614    let models = required_classifier_field(route_name, "models", models)?;
1615    let default_target: Category =
1616        required_classifier_field(route_name, "default_target", default_target)?
1617            .parse()
1618            .map_err(|error| {
1619                AlgorithmConfigError::new(format!(
1620                    "llm_classifier route {route_name} has invalid default_target: {error}"
1621                ))
1622            })?;
1623    if default_target == Category::Judge {
1624        return Err(AlgorithmConfigError::new(format!(
1625            "llm_classifier route {route_name} default_target cannot be judge"
1626        )));
1627    }
1628    models.validate(route_name, &default_target)?;
1629    Ok((models, default_target))
1630}
1631
1632fn reject_custom_fields(
1633    route_name: &str,
1634    mode: &str,
1635    models: &Option<CategoryModelConfig>,
1636    default_target: &Option<String>,
1637    response_schema: &Option<String>,
1638    policy: &Option<ClassifierPolicyConfig>,
1639) -> AlgorithmResult<()> {
1640    if models.is_some() || default_target.is_some() || response_schema.is_some() || policy.is_some()
1641    {
1642        return Err(AlgorithmConfigError::new(format!(
1643            "llm_classifier route {route_name} mode {mode} cannot use custom classifier fields"
1644        )));
1645    }
1646    Ok(())
1647}
1648
1649fn classifier_field_error(route_name: &str, field: &str, mode: &str) -> AlgorithmConfigError {
1650    AlgorithmConfigError::new(format!(
1651        "llm_classifier route {route_name} mode {mode} cannot use {field}"
1652    ))
1653}
1654
1655fn required_classifier_field<T: Clone>(
1656    route_name: &str,
1657    field: &str,
1658    value: &Option<T>,
1659) -> AlgorithmResult<T> {
1660    value.clone().ok_or_else(|| {
1661        AlgorithmConfigError::new(format!(
1662            "llm_classifier route {route_name} requires {field}"
1663        ))
1664    })
1665}
1666
1667fn build_subagent_router_config(
1668    route_name: &str,
1669    config: &SubagentRouteConfig,
1670    targets: &BTreeMap<String, ModelId>,
1671) -> AlgorithmResult<SubagentRouterConfig> {
1672    match config {
1673        SubagentRouteConfig::Passthrough { target } => {
1674            resolve_target_model_id(route_name, target, targets)?;
1675            Ok(SubagentRouterConfig::fixed_target())
1676        }
1677        SubagentRouteConfig::LlmClassifier(config) => {
1678            let LlmClassifierModeConfig::Custom(config) =
1679                config.validated_classifier_mode(route_name)?
1680            else {
1681                return Err(AlgorithmConfigError::new(format!(
1682                    "route {route_name}: subagents llm_classifier only supports mode custom"
1683                )));
1684            };
1685            for name in config.models.all_names() {
1686                resolve_target_model_id(route_name, name, targets)?;
1687            }
1688            if config.models.get(&config.default_target).is_empty() {
1689                return Err(AlgorithmConfigError::new(format!(
1690                    "route {route_name}: subagents llm_classifier has no model for default category {}",
1691                    config.default_target.as_str()
1692                )));
1693            }
1694            let response_schema =
1695                serde_json::from_str(&config.response_schema).map_err(|error| {
1696                    AlgorithmConfigError::with_source(
1697                        format!(
1698                            "route {route_name}: subagents llm_classifier response_schema is invalid JSON: {error}"
1699                        ),
1700                        error,
1701                    )
1702                })?;
1703            warn_single_target_classifier(route_name, &config.models);
1704            let mut classifier_config = CustomClassifierConfig::new(
1705                config.prompt,
1706                response_schema,
1707                config.policy.into_libsy(),
1708            );
1709            classifier_config.recent_turn_window = config.recent_turn_window;
1710            classifier_config.max_output_tokens = config.max_output_tokens;
1711            classifier_config.judge_deadline_ms = config.judge_deadline_ms;
1712            let classifier = Arc::new(
1713                LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1714                    default_target: config.default_target.clone(),
1715                    config: classifier_config,
1716                })
1717                .map_err(|error| {
1718                    AlgorithmConfigError::with_source(
1719                        format!("route {route_name}: subagents llm_classifier: {error}"),
1720                        error,
1721                    )
1722                })?,
1723            );
1724            Ok(SubagentRouterConfig {
1725                classifier,
1726                default_target: config.default_target,
1727                classify_trigger: config.classify_trigger,
1728                message_hash_fallback: config.message_hash_fallback,
1729            })
1730        }
1731    }
1732}
1733
1734fn attach_subagent_router(
1735    route_name: &str,
1736    parent: Arc<dyn Algorithm>,
1737    config: Option<&SubagentRouteConfig>,
1738    targets: &BTreeMap<String, ModelId>,
1739) -> AlgorithmResult<Arc<dyn Algorithm>> {
1740    let Some(config) = config else {
1741        return Ok(parent);
1742    };
1743    let config = build_subagent_router_config(route_name, config, targets)?;
1744    let algorithm = SubagentRouter::new(parent, config).map_err(|error| {
1745        AlgorithmConfigError::with_source(
1746            format!("route {route_name}: subagent routing: {error}"),
1747            error,
1748        )
1749    })?;
1750    Ok(Arc::new(algorithm))
1751}
1752
1753/// `build_algorithm` builds the configured algorithm and attaches supported subagent routing.
1754/// It returns errors for invalid settings or failed target lookups during construction.
1755/// `AlgorithmSpec::build_with_runtime_models` also resolves the runtime model groups.
1756fn build_algorithm(
1757    route_name: &str,
1758    config: &AlgorithmSpec,
1759    targets: &BTreeMap<String, ModelId>,
1760) -> AlgorithmResult<Arc<dyn Algorithm>> {
1761    match config {
1762        AlgorithmSpec::Noop { .. } => Ok(Arc::new(Noop {})),
1763        AlgorithmSpec::Random {
1764            targets: names,
1765            weights,
1766            seed,
1767        } => {
1768            // The algorithm only sees its targets at request time, so a bad pairing
1769            // would otherwise fail every request instead of failing to start.
1770            if let Some(weights) = weights
1771                && weights.len() != names.len()
1772            {
1773                return Err(AlgorithmConfigError::new(format!(
1774                    "random route {route_name}: expected {} weights, got {}",
1775                    names.len(),
1776                    weights.len()
1777                )));
1778            }
1779            let mut seen = BTreeSet::new();
1780            if let Some(duplicate) = names.iter().find(|name| !seen.insert(*name)) {
1781                return Err(AlgorithmConfigError::new(format!(
1782                    "random route {route_name}: targets must be unique, {duplicate} is repeated"
1783                )));
1784            }
1785            let algorithm = Random::new(weights.clone(), *seed).map_err(|error| {
1786                AlgorithmConfigError::with_source(
1787                    format!("random route {route_name}: {error}"),
1788                    error,
1789                )
1790            })?;
1791            Ok(Arc::new(algorithm))
1792        }
1793        AlgorithmSpec::Passthrough { subagents, .. } => {
1794            let algorithm = Passthrough;
1795            let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1796            attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1797        }
1798        AlgorithmSpec::LlmClassifier {
1799            config: classifier_config,
1800            ..
1801        } => {
1802            let mode = classifier_config.validated_classifier_mode(route_name)?;
1803            let algorithm = match mode {
1804                LlmClassifierModeConfig::Capability(config) => {
1805                    let classifier_config = config.into_task_config();
1806                    LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1807                        config: classifier_config,
1808                    })
1809                }
1810                LlmClassifierModeConfig::Escalation(config) => {
1811                    LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1812                        contract: classifier_contract(config.prompt.as_deref())
1813                            .with_response_format_type(config.response_format_type),
1814                        config: config.judge,
1815                        max_output_tokens: config.max_output_tokens,
1816                        judge_deadline_ms: config.judge_deadline_ms,
1817                    })
1818                }
1819                LlmClassifierModeConfig::Custom(config) => {
1820                    let response_schema = serde_json::from_str(&config.response_schema).map_err(
1821                        |error| {
1822                            AlgorithmConfigError::with_source(
1823                                format!(
1824                                    "llm_classifier route {route_name}: response_schema is invalid JSON: {error}"
1825                                ),
1826                                error,
1827                            )
1828                        },
1829                    )?;
1830                    warn_single_target_classifier(route_name, &config.models);
1831                    let mut classifier_config = CustomClassifierConfig::new(
1832                        config.prompt,
1833                        response_schema,
1834                        config.policy.into_libsy(),
1835                    );
1836                    classifier_config.classify_trigger = config.classify_trigger;
1837                    classifier_config.message_hash_fallback = config.message_hash_fallback;
1838                    classifier_config.recent_turn_window = config.recent_turn_window;
1839                    classifier_config.max_output_tokens = config.max_output_tokens;
1840                    classifier_config.judge_deadline_ms = config.judge_deadline_ms;
1841                    LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1842                        default_target: config.default_target,
1843                        config: classifier_config,
1844                    })
1845                }
1846                LlmClassifierModeConfig::Direct {
1847                    default_target,
1848                    config,
1849                    ..
1850                } => LlmTaskClassifier::new(LlmClassifierConfig::Direct {
1851                    default_target,
1852                    config,
1853                }),
1854            }
1855            .map_err(|error| {
1856                AlgorithmConfigError::with_source(
1857                    format!("llm_classifier route {route_name}: {error}"),
1858                    error,
1859                )
1860            })?;
1861            Ok(Arc::new(algorithm))
1862        }
1863        AlgorithmSpec::StageRouter {
1864            capable_target,
1865            efficient_target,
1866            capable_first_threshold,
1867            settings,
1868            picker,
1869            classifier,
1870            subagents,
1871            ..
1872        } => {
1873            if matches!(picker, PickerMode::CapableFirst) {
1874                tracing::warn!(
1875                    "stage_router route {route_name} uses picker \"capable_first\", which is experimental: published thresholds and routing results all come from \"efficient_first\", so there is no calibrated confidence_threshold for it and no measured accuracy or cost. Use \"efficient_first\" unless you are running your own calibration."
1876                );
1877            }
1878            warn_identical_tiers(route_name, capable_target, efficient_target);
1879            let threshold = settings.confidence_threshold.ok_or_else(|| {
1880                AlgorithmConfigError::new(format!(
1881                    "stage_router route {route_name}: confidence_threshold is required"
1882                ))
1883            })?;
1884            let mut config = settings.stage_config(settings, *picker, threshold);
1885            config.capable_first_threshold = *capable_first_threshold;
1886            // The judge is called through its own target, so it is not a routing
1887            // destination and stays out of the tier pair.
1888            config.llm_fallback = classifier
1889                .as_ref()
1890                .map(|classifier| {
1891                    classifier.stage_fallback(route_name, capable_target, efficient_target, targets)
1892                })
1893                .transpose()?;
1894            let algorithm = StageRouter::new(config).map_err(|error| {
1895                AlgorithmConfigError::with_source(
1896                    format!("stage_router route {route_name}: {error}"),
1897                    error,
1898                )
1899            })?;
1900            let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1901            attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1902        }
1903        AlgorithmSpec::Auto {
1904            capable_target,
1905            efficient_target,
1906        } => {
1907            warn_identical_tiers(route_name, capable_target, efficient_target);
1908            let config = StageRouterConfig::new(PickerMode::EfficientFirst, 0.5);
1909            let algorithm = StageRouter::new(config).map_err(|error| {
1910                AlgorithmConfigError::with_source(
1911                    format!("auto route {route_name}: {error}"),
1912                    error,
1913                )
1914            })?;
1915            Ok(Arc::new(algorithm))
1916        }
1917        AlgorithmSpec::Composite {
1918            classifier,
1919            stage,
1920            efficient,
1921            capable,
1922            subagents,
1923        } => {
1924            stage.warn_identical_tiers(route_name);
1925            let judge = if matches!(classifier.mode, Some(ClassifierMode::Custom)) {
1926                CompositeJudgeConfig::Custom(classifier.custom_classifier_config(route_name)?)
1927            } else {
1928                CompositeJudgeConfig::Capability(classifier.task_classifier_config(
1929                    route_name,
1930                    &stage.capable_target,
1931                    &stage.efficient_target,
1932                )?)
1933            };
1934            let shared = &stage.settings;
1935            let algorithm = if efficient.is_some() || capable.is_some() {
1936                let defaults = StageProfileConfig::default();
1937                let profiles = [
1938                    (
1939                        efficient,
1940                        PickerMode::EfficientFirst,
1941                        shared.confidence_threshold.unwrap_or(0.5),
1942                    ),
1943                    (
1944                        capable,
1945                        PickerMode::CapableFirst,
1946                        stage.capable_first_threshold,
1947                    ),
1948                ]
1949                .map(|(profile, picker, threshold)| -> AlgorithmResult<_> {
1950                    let profile = profile.as_deref().unwrap_or(&defaults);
1951                    let mut config = profile.settings.stage_config(
1952                        shared,
1953                        profile.picker.unwrap_or(picker),
1954                        threshold,
1955                    );
1956                    config.llm_fallback = profile
1957                        .classifier
1958                        .as_ref()
1959                        .map(|classifier| {
1960                            classifier.stage_fallback(
1961                                route_name,
1962                                &stage.capable_target,
1963                                &stage.efficient_target,
1964                                targets,
1965                            )
1966                        })
1967                        .transpose()?;
1968                    Ok(config)
1969                });
1970                let [profile_0, profile_1] = profiles;
1971                CompositeRouter::new_with_profiles(judge, profile_0?, profile_1?)
1972            } else {
1973                let mut config = shared.stage_config(shared, PickerMode::EfficientFirst, 0.5);
1974                config.capable_first_threshold = stage.capable_first_threshold;
1975                CompositeRouter::new(CompositeRouterConfig {
1976                    judge,
1977                    stage: config,
1978                })
1979            }
1980            .map_err(|error| {
1981                AlgorithmConfigError::with_source(
1982                    format!("composite route {route_name}: {error}"),
1983                    error,
1984                )
1985            })?;
1986            let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1987            attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1988        }
1989        AlgorithmSpec::Advisor {
1990            reviewer_system_prompt,
1991            redo_feedback_prefix,
1992            gate_trigger,
1993            gate_trigger_pattern,
1994            max_reviews,
1995            gate_stall_turns,
1996            gate_min_tool_results,
1997            advisor_max_tokens,
1998            advisor_temperature,
1999            transcript_max_chars,
2000            fail_open,
2001            ..
2002        } => {
2003            // A pattern set under the default trigger would be silently
2004            // ignored; reject the misconfiguration instead.
2005            if *gate_trigger == AdvisorTriggerConfig::NoToolCall && gate_trigger_pattern.is_some() {
2006                return Err(AlgorithmConfigError::new(format!(
2007                    "advisor route {route_name}: gate_trigger_pattern requires \
2008                     gate_trigger = \"pattern\""
2009                )));
2010            }
2011            let mut config = AdvisorGateConfig::default();
2012            if let Some(prompt) = reviewer_system_prompt {
2013                config.reviewer_system_prompt = prompt.clone();
2014            }
2015            if let Some(prefix) = redo_feedback_prefix {
2016                config.redo_feedback_prefix = prefix.clone();
2017            }
2018            config.gate_trigger = match gate_trigger {
2019                AdvisorTriggerConfig::NoToolCall => GateTrigger::NoToolCall,
2020                AdvisorTriggerConfig::Pattern => {
2021                    GateTrigger::Pattern(gate_trigger_pattern.clone().unwrap_or_default())
2022                }
2023            };
2024            config.max_reviews = *max_reviews;
2025            config.gate_stall_turns = *gate_stall_turns;
2026            config.gate_min_tool_results = *gate_min_tool_results;
2027            config.advisor_max_tokens = *advisor_max_tokens;
2028            config.advisor_temperature = *advisor_temperature;
2029            config.transcript_max_chars = *transcript_max_chars;
2030            config.fail_open = *fail_open;
2031            let algorithm = AdvisorGate::new(config).map_err(|error| {
2032                AlgorithmConfigError::with_source(
2033                    format!("advisor route {route_name}: {error}"),
2034                    error,
2035                )
2036            })?;
2037            Ok(Arc::new(algorithm))
2038        }
2039        AlgorithmSpec::Vgr { config } => build_vgr(route_name, config, targets),
2040        AlgorithmSpec::PrefillRouter {
2041            targets: names,
2042            checkpoint,
2043            device,
2044            cache_dir,
2045            max_length,
2046            batch_size,
2047        } => {
2048            #[cfg(feature = "prefill-router")]
2049            {
2050                let targets = names
2051                    .iter()
2052                    .map(|name| resolve_target_model_id(route_name, name, targets))
2053                    .collect::<AlgorithmResult<Vec<_>>>()?;
2054                let mut config = prefill_router::PrefillRouterConfig::new(targets, checkpoint);
2055                config.device.clone_from(device);
2056                config.cache_dir.clone_from(cache_dir);
2057                if let Some(max_length) = max_length {
2058                    config.max_length = *max_length;
2059                }
2060                if let Some(batch_size) = batch_size {
2061                    config.batch_size = *batch_size;
2062                }
2063                let algorithm = config.build().map_err(|error| {
2064                    AlgorithmConfigError::with_source(
2065                        format!("prefill_router route {route_name}: {error}"),
2066                        error,
2067                    )
2068                })?;
2069                Ok(Arc::new(algorithm))
2070            }
2071
2072            #[cfg(not(feature = "prefill-router"))]
2073            {
2074                let _ = (
2075                    names, checkpoint, device, cache_dir, max_length, batch_size, targets,
2076                );
2077                Err(AlgorithmConfigError::new(format!(
2078                    "prefill_router route {route_name} requires the `prefill-router` Cargo feature"
2079                )))
2080            }
2081        }
2082    }
2083}
2084
2085fn build_vgr(
2086    route_name: &str,
2087    config: &VgrRouteConfig,
2088    targets: &BTreeMap<String, ModelId>,
2089) -> AlgorithmResult<Arc<dyn Algorithm>> {
2090    let efficient = resolve_target_model_id(route_name, &config.efficient_target, targets)?;
2091    let capable = resolve_target_model_id(route_name, &config.capable_target, targets)?;
2092    let optional = |name: &Option<String>| {
2093        name.as_deref()
2094            .map(|name| resolve_target_model_id(route_name, name, targets))
2095            .transpose()
2096    };
2097    let mut runtime = VgrConfig::new(efficient, capable);
2098    runtime.targets.judge = optional(&config.judge_target)?;
2099    runtime.targets.capable_judge = optional(&config.capable_judge_target)?;
2100    runtime.mode = match config.mode {
2101        VgrModeConfig::Off => libsy::ServingMode::Off,
2102        VgrModeConfig::Evaluate => libsy::ServingMode::Evaluate,
2103        VgrModeConfig::Shadow => libsy::ServingMode::Shadow,
2104        VgrModeConfig::Active => libsy::ServingMode::Active {
2105            approval: config.active_approval.clone().unwrap_or_default(),
2106        },
2107    };
2108    runtime.deadline = vgr_duration(route_name, "deadline_seconds", config.deadline_seconds)?;
2109    runtime.bounded_efficient = config.bounded_efficient;
2110    runtime.task_typing = config.task_typing;
2111    runtime.confirmed_recovery_min_clean_tail = config.confirmed_recovery_min_clean_tail;
2112    runtime.agentic_handoff = config.agentic_handoff;
2113    runtime.compact_handoff = config.compact_handoff;
2114    runtime.latch_escalation = config.latch_escalation;
2115    runtime.efficient_turn_budget = config
2116        .efficient_turn_budget_seconds
2117        .map(|seconds| vgr_duration(route_name, "efficient_turn_budget_seconds", seconds))
2118        .transpose()?;
2119    runtime.breaker = BreakerConfig {
2120        threshold: config.breaker_threshold,
2121        cooldown: vgr_duration(
2122            route_name,
2123            "breaker_cooldown_seconds",
2124            config.breaker_cooldown_seconds,
2125        )?,
2126    };
2127    Vgr::new(runtime)
2128        .map(|algorithm| Arc::new(algorithm) as Arc<dyn Algorithm>)
2129        .map_err(|error| {
2130            AlgorithmConfigError::with_source(format!("vgr route {route_name}: {error}"), error)
2131        })
2132}
2133
2134fn vgr_duration(route: &str, field: &str, seconds: f64) -> AlgorithmResult<std::time::Duration> {
2135    let message = || format!("vgr route {route}: {field} must be a positive number");
2136    let duration = std::time::Duration::try_from_secs_f64(seconds)
2137        .map_err(|error| AlgorithmConfigError::with_source(message(), error))?;
2138    if duration.is_zero() {
2139        return Err(AlgorithmConfigError::new(message()));
2140    }
2141    Ok(duration)
2142}
2143
2144const fn default_max_reviews() -> u32 {
2145    1
2146}
2147
2148const fn default_advisor_max_tokens() -> u64 {
2149    2048
2150}
2151
2152const fn default_transcript_max_chars() -> usize {
2153    200_000
2154}
2155
2156const fn default_fail_open() -> bool {
2157    true
2158}
2159
2160fn classifier_contract(prompt: Option<&str>) -> ClassifierContractConfig {
2161    prompt.map_or_else(ClassifierContractConfig::default, |prompt| {
2162        ClassifierContractConfig::default().with_prompt(prompt)
2163    })
2164}
2165
2166fn default_classifier_max_output_tokens() -> u64 {
2167    LlmCapabilityConfig::default().max_output_tokens
2168}
2169
2170/// Both tiers on one target leave the route nothing to choose between; every decision
2171/// lands on the same model. The file still loads, since a single-model deployment can
2172/// be a deliberate overhead measurement.
2173fn warn_identical_tiers(route_name: &str, capable_target: &str, efficient_target: &str) {
2174    if capable_target == efficient_target {
2175        tracing::warn!(
2176            route = route_name,
2177            target = capable_target,
2178            "capable_target and efficient_target name the same target, so every request goes to one model; use passthrough for a single model, or point the tiers at two targets"
2179        );
2180    }
2181}
2182
2183fn warn_single_target_classifier(route_name: &str, models: &CategoryModelConfig) {
2184    if models.routing_names().len() < 2 {
2185        tracing::warn!(
2186            route = route_name,
2187            "custom classifier has only one routing target; judge calls add cost without a routing choice. Use passthrough or add another completion target in routes.<name>, or in routes.<name>.subagents for a subagent classifier."
2188        );
2189    }
2190}
2191
2192const fn default_vgr_deadline() -> f64 {
2193    30.0
2194}
2195
2196const fn default_true() -> bool {
2197    true
2198}
2199
2200const fn default_vgr_breaker_threshold() -> u32 {
2201    5
2202}
2203
2204const fn default_vgr_breaker_cooldown() -> f64 {
2205    30.0
2206}
2207
2208fn resolve_target_model_id(
2209    route_name: &str,
2210    name: &str,
2211    targets: &BTreeMap<String, ModelId>,
2212) -> AlgorithmResult<ModelId> {
2213    targets.get(name).cloned().ok_or_else(|| {
2214        AlgorithmConfigError::new(format!(
2215            "route {route_name} references unknown target {name}"
2216        ))
2217    })
2218}
2219
2220fn resolve_runtime_models(
2221    route_name: &str,
2222    names: HashMap<Category, Vec<String>>,
2223    targets: &BTreeMap<String, ModelId>,
2224) -> AlgorithmResult<HashMap<Category, Vec<ModelId>>> {
2225    names
2226        .into_iter()
2227        .map(|(category, names)| {
2228            let models = names
2229                .into_iter()
2230                .map(|name| resolve_target_model_id(route_name, &name, targets))
2231                .collect::<AlgorithmResult<Vec<_>>>()?;
2232            Ok((category, models))
2233        })
2234        .collect()
2235}
2236
2237#[cfg(test)]
2238mod tests {
2239    use super::*;
2240
2241    #[test]
2242    fn restricted_random_preserves_weight_positions() -> AlgorithmResult<()> {
2243        let allowed = BTreeSet::from(["private_a", "private_b"]);
2244        let mut algorithm = AlgorithmSpec::Random {
2245            targets: ["public_a", "private_a", "public_b", "private_b"]
2246                .map(String::from)
2247                .to_vec(),
2248            weights: Some(vec![1.0, 2.0, 3.0, 4.0]),
2249            seed: Some(42),
2250        };
2251        let AlgorithmSpec::Random {
2252            targets,
2253            weights,
2254            seed,
2255        } = algorithm.restricted_to(&allowed)?
2256        else {
2257            unreachable!();
2258        };
2259        assert_eq!(targets, ["private_a", "private_b"]);
2260        assert_eq!(weights, Some(vec![2.0, 4.0]));
2261        assert_eq!(seed, Some(42));
2262        if let AlgorithmSpec::Random { weights, .. } = &mut algorithm {
2263            *weights = Some(vec![1.0]);
2264        }
2265        assert!(algorithm.restricted_to(&allowed).is_err());
2266        Ok(())
2267    }
2268
2269    fn custom_classifier() -> LlmClassifierRouteConfig {
2270        LlmClassifierRouteConfig {
2271            mode: Some(ClassifierMode::Custom),
2272            models: Some(CategoryModelConfig(BTreeMap::from([
2273                ("any".into(), vec!["public".into(), "private".into()]),
2274                ("efficient".into(), vec!["public".into(), "private".into()]),
2275                ("judge".into(), vec!["judge".into()]),
2276                ("research".into(), vec!["public".into()]),
2277            ]))),
2278            default_target: Some("efficient".into()),
2279            prompt: Some("Select an available target.".into()),
2280            response_schema: Some(r#"{"type":"object"}"#.into()),
2281            max_output_tokens: default_classifier_max_output_tokens(),
2282            policy: Some(ClassifierPolicyConfig::TargetSelector {
2283                selector: "/target".into(),
2284            }),
2285            ..LlmClassifierRouteConfig::default()
2286        }
2287    }
2288
2289    #[test]
2290    fn restricted_custom_pools_cover_parent_and_subagents() -> AlgorithmResult<()> {
2291        let allowed = BTreeSet::from(["private", "judge"]);
2292        let targets = BTreeMap::from([
2293            ("private".into(), ModelId::from("private")),
2294            ("judge".into(), ModelId::from("judge")),
2295        ]);
2296        let parent = AlgorithmSpec::LlmClassifier {
2297            config: custom_classifier(),
2298        };
2299        let child = AlgorithmSpec::Passthrough {
2300            target: "private".into(),
2301            subagents: Some(SubagentRouteConfig::LlmClassifier(Box::new(
2302                custom_classifier(),
2303            ))),
2304        };
2305        for algorithm in [parent, child] {
2306            let restricted = algorithm.restricted_to(&allowed)?;
2307            let groups = restricted.runtime_model_names("test")?;
2308            let groups = groups.subagent.unwrap_or(groups.parent);
2309            assert_eq!(groups[&Category::Any], ["private"]);
2310            assert_eq!(groups[&Category::Efficient], ["private"]);
2311            assert_eq!(groups[&Category::Judge], ["judge"]);
2312            restricted.build("test", &targets)?;
2313            let missing_judge = algorithm.restricted_to(&BTreeSet::from(["private"]))?;
2314            assert!(missing_judge.build("test", &targets).is_err());
2315        }
2316        Ok(())
2317    }
2318
2319    #[test]
2320    fn restricted_roles_are_not_replaced() {
2321        let stage = AlgorithmSpec::Auto {
2322            capable_target: "private".into(),
2323            efficient_target: "public".into(),
2324        };
2325        assert!(stage.restricted_to(&BTreeSet::from(["private"])).is_err());
2326    }
2327}