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, CustomClassifierConfig, CustomClassifierPolicy, DEFAULT_UNMATCHED_STEPS,
16    DecisionChoice, DecisionJudgeConfig, DirectClassifierConfig, EscalationJudgeConfig,
17    GateTrigger, HandoffNoteConfig, LlmCapabilityConfig, LlmClassifierConfig, LlmFallback,
18    LlmTaskClassifier, Noop, Passthrough, PickerMode, Random, RuntimeModels, StageRouter,
19    StageRouterConfig, SubagentRouter, SubagentRouterConfig, TaskClassifierConfig, ToolSemantics,
20    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
638const COMPOSITE_PLANNING_PROMPT: &str = "You are in the planning phase. Inspect the task and the relevant code, then form a concrete implementation plan before modifying any files. Use read-only tools as needed. Do not make an edit until the plan is complete. After completing the plan, immediately make the first edit in the same task to hand execution off. Do not finish or return a final response with only the plan.";
639
640/// The shared model pair and defaults for composite stage profiles.
641#[derive(Clone, Debug, Deserialize)]
642#[serde(deny_unknown_fields)]
643pub struct StageTierConfig {
644    pub capable_target: String,
645    pub efficient_target: String,
646    #[serde(default)]
647    pub capable_first_threshold: f64,
648    #[serde(flatten)]
649    pub settings: StageSettingsConfig,
650}
651
652/// Settings shared by standalone stage routes and composite profiles.
653#[derive(Clone, Debug, Default, Deserialize)]
654#[serde(default, deny_unknown_fields)]
655pub struct StageSettingsConfig {
656    #[serde(alias = "efficient_first_threshold")]
657    pub confidence_threshold: Option<f64>,
658    pub recent_turn_window: Option<usize>,
659    pub tool_semantics: Option<ToolSemantics>,
660    pub compaction_override: Option<bool>,
661    pub capable_hold_turns: Option<i64>,
662    pub efficient_hold_turns: Option<i64>,
663    pub handoff_notes: Option<HandoffNoteConfig>,
664    pub capable_system_prompt: Option<String>,
665    pub efficient_system_prompt: Option<String>,
666}
667
668#[derive(Clone, Debug, Default, Deserialize)]
669#[serde(deny_unknown_fields)]
670pub struct StageProfileConfig {
671    pub classifier: Option<StageClassifierConfig>,
672    pub picker: Option<PickerMode>,
673    #[serde(flatten)]
674    pub settings: StageSettingsConfig,
675}
676
677impl StageSettingsConfig {
678    fn stage_config(&self, shared: &Self, mode: PickerMode, threshold: f64) -> StageRouterConfig {
679        let mut config =
680            StageRouterConfig::new(mode, self.confidence_threshold.unwrap_or(threshold));
681        config.recent_window = self.recent_turn_window.or(shared.recent_turn_window);
682        config.compaction_override = self
683            .compaction_override
684            .or(shared.compaction_override)
685            .unwrap_or(true);
686        config.tool_semantics = self
687            .tool_semantics
688            .as_ref()
689            .or(shared.tool_semantics.as_ref())
690            .cloned()
691            .unwrap_or_default();
692        config.capable_hold_turns = self
693            .capable_hold_turns
694            .or(shared.capable_hold_turns)
695            .unwrap_or(2);
696        config.efficient_hold_turns = self.efficient_hold_turns.or(shared.efficient_hold_turns);
697        config.handoff_notes = self
698            .handoff_notes
699            .as_ref()
700            .or(shared.handoff_notes.as_ref())
701            .cloned();
702        config.capable_system_prompt = self
703            .capable_system_prompt
704            .as_ref()
705            .or(shared.capable_system_prompt.as_ref())
706            .cloned();
707        config.efficient_system_prompt = self
708            .efficient_system_prompt
709            .as_ref()
710            .or(shared.efficient_system_prompt.as_ref())
711            .cloned();
712        config
713    }
714}
715
716impl StageTierConfig {
717    fn profile_config(
718        &self,
719        profile: &StageProfileConfig,
720        picker: PickerMode,
721    ) -> StageRouterConfig {
722        let threshold = match picker {
723            PickerMode::EfficientFirst => self.settings.confidence_threshold.unwrap_or(0.4),
724            PickerMode::CapableFirst => self.capable_first_threshold,
725        };
726        let mode = profile.picker.unwrap_or(picker);
727        let capable_first = mode == PickerMode::CapableFirst;
728        let mut config = profile
729            .settings
730            .stage_config(&self.settings, mode, threshold);
731        config.recent_window.get_or_insert(3);
732        config.capable_hold_turns = profile
733            .settings
734            .capable_hold_turns
735            .or(self.settings.capable_hold_turns)
736            .unwrap_or(if capable_first { 0 } else { 2 });
737        config
738            .efficient_hold_turns
739            .get_or_insert(if capable_first { -1 } else { 0 });
740        if capable_first {
741            config
742                .capable_system_prompt
743                .get_or_insert_with(|| COMPOSITE_PLANNING_PROMPT.into());
744        }
745        config
746    }
747
748    /// Warns when both tiers name one target; see [`warn_identical_tiers`].
749    fn warn_identical_tiers(&self, route_name: &str) {
750        warn_identical_tiers(route_name, &self.capable_target, &self.efficient_target);
751    }
752}
753
754impl StageClassifierConfig {
755    fn composite_judge_config(
756        &self,
757        route_name: &str,
758        capable: &str,
759        efficient: &str,
760    ) -> AlgorithmResult<CompositeJudgeConfig> {
761        if matches!(self.mode, Some(ClassifierMode::Custom)) {
762            return Ok(CompositeJudgeConfig::Custom(
763                self.custom_classifier_config(route_name)?,
764            ));
765        }
766        let mut config = self.task_classifier_config(route_name, capable, efficient)?;
767        if let CapabilityJudgeConfig::Decision(judge) = &mut config.judge {
768            judge.cutoff.get_or_insert(0.92);
769        }
770        Ok(CompositeJudgeConfig::Capability(config))
771    }
772
773    fn task_classifier_config(
774        &self,
775        route_name: &str,
776        capable_target: &str,
777        efficient_target: &str,
778    ) -> AlgorithmResult<TaskClassifierConfig> {
779        if self
780            .mode
781            .is_some_and(|mode| !matches!(mode, ClassifierMode::Capability))
782            || self.response_schema.is_some()
783            || self.policy.is_some()
784        {
785            return Err(AlgorithmConfigError::new(
786                "custom classifier settings require composite with mode = custom",
787            ));
788        }
789        let config = LlmClassifierRouteConfig {
790            classifier_target: self.target.clone(),
791            strong_target: Some(capable_target.to_string()),
792            weak_target: Some(efficient_target.to_string()),
793            base_threshold: self.base_threshold,
794            threshold_step: self.threshold_step,
795            fail_open: self.fail_open,
796            decision: self.decision.clone(),
797            classify_trigger: self.classify_trigger,
798            message_hash_fallback: self.message_hash_fallback,
799            recent_turn_window: self.recent_turn_window,
800            prompt: self.prompt.clone(),
801            response_format_type: self.response_format_type,
802            max_output_tokens: self.max_output_tokens,
803            judge_deadline_ms: self.judge_deadline_ms,
804            ..Default::default()
805        };
806        match config.validated_classifier_mode(route_name)? {
807            LlmClassifierModeConfig::Capability(config) => Ok(config.into_task_config()),
808            _ => Err(AlgorithmConfigError::new("expected a capability judge")),
809        }
810    }
811
812    fn infer_decision(
813        &mut self,
814        infer: &mut impl FnMut(&str, &mut Option<DecisionJudgeRouteConfig>),
815    ) {
816        if matches!(self.mode, None | Some(ClassifierMode::Capability)) {
817            infer(&self.target, &mut self.decision);
818        }
819    }
820
821    fn stage_fallback(
822        &self,
823        route_name: &str,
824        capable: &str,
825        efficient: &str,
826        targets: &BTreeMap<String, ModelId>,
827    ) -> AlgorithmResult<LlmFallback> {
828        Ok(LlmFallback {
829            target: Some(resolve_target_model_id(route_name, &self.target, targets)?),
830            config: self.task_classifier_config(route_name, capable, efficient)?,
831        })
832    }
833
834    fn custom_classifier_config(
835        &self,
836        route_name: &str,
837    ) -> AlgorithmResult<CustomClassifierConfig> {
838        if self.fail_open == Some(false) {
839            return Err(AlgorithmConfigError::new(
840                "composite custom classifiers always fail open; remove fail_open = false",
841            ));
842        }
843        if self.decision.is_some()
844            || self.base_threshold.is_some()
845            || self.threshold_step.is_some()
846            || self.response_format_type != ClassifierResponseFormat::JsonSchema
847        {
848            return Err(AlgorithmConfigError::new(
849                "composite custom classifier cannot use capability thresholds, decision settings, or response_format_type",
850            ));
851        }
852        let schema =
853            required_classifier_field(route_name, "response_schema", &self.response_schema)?;
854        let schema = serde_json::from_str(&schema).map_err(|error| {
855            AlgorithmConfigError::with_source(
856                format!("composite route {route_name}: response_schema is invalid JSON"),
857                error,
858            )
859        })?;
860        let mut config = CustomClassifierConfig::new(
861            required_classifier_field(route_name, "prompt", &self.prompt)?,
862            schema,
863            required_classifier_field(route_name, "policy", &self.policy)?.into_libsy(),
864        );
865        config.classify_trigger = self.classify_trigger;
866        config.message_hash_fallback = self.message_hash_fallback;
867        config.recent_turn_window = self.recent_turn_window;
868        config.max_output_tokens = self.max_output_tokens;
869        Ok(config)
870    }
871}
872
873impl AlgorithmSpec {
874    /// Filters configured pools without replacing required targets or changing their roles.
875    pub(crate) fn restricted_to(&self, allowed: &BTreeSet<&str>) -> AlgorithmResult<Self> {
876        let mut restricted = self.clone();
877        match &mut restricted {
878            Self::Random {
879                targets, weights, ..
880            } => {
881                if let Some(weights) = weights {
882                    if weights.len() != targets.len() {
883                        return Err(AlgorithmConfigError::new(format!(
884                            "random expected {} weights, got {}",
885                            targets.len(),
886                            weights.len()
887                        )));
888                    }
889                    // Keep weight positions paired with the original target order.
890                    let mut index = 0;
891                    weights.retain(|_| {
892                        let keep = allowed.contains(targets[index].as_str());
893                        index += 1;
894                        keep
895                    });
896                }
897                targets.retain(|target| allowed.contains(target.as_str()));
898                if targets.is_empty() {
899                    return Err(AlgorithmConfigError::new(
900                        "target requirements leave random without an eligible target".to_string(),
901                    ));
902                }
903            }
904            Self::LlmClassifier { config } => {
905                if let Some(models) = &mut config.models {
906                    models.restrict_to(allowed);
907                }
908            }
909            _ => {}
910        }
911        let subagents = match &mut restricted {
912            Self::Passthrough { subagents, .. }
913            | Self::StageRouter { subagents, .. }
914            | Self::Composite { subagents, .. } => subagents.as_mut(),
915            _ => None,
916        };
917        if let Some(SubagentRouteConfig::LlmClassifier(config)) = subagents
918            && let Some(models) = &mut config.models
919        {
920            models.restrict_to(allowed);
921        }
922        if let Some(target) = restricted
923            .callable_target_names()
924            .into_iter()
925            .find(|target| !allowed.contains(target))
926        {
927            return Err(AlgorithmConfigError::new(format!(
928                "required target {target} does not satisfy target requirements"
929            )));
930        }
931        Ok(restricted)
932    }
933
934    pub(crate) const fn supports_target_restrictions(&self) -> bool {
935        !matches!(self, Self::PrefillRouter { .. })
936    }
937
938    /// This iterator yields tier classifier targets and their optional decision settings.
939    /// It includes classifiers in nested Composite profiles.
940    /// Standalone custom and direct modes use model groups and are excluded.
941    pub(crate) fn classifier_targets(
942        &self,
943    ) -> impl Iterator<Item = (&str, Option<&DecisionJudgeRouteConfig>)> {
944        let (primary, profiles) = match self {
945            Self::LlmClassifier { config, .. }
946                if !matches!(
947                    config.classifier_mode(),
948                    ClassifierMode::Custom | ClassifierMode::Direct
949                ) =>
950            {
951                (
952                    Some((config.classifier_target.as_str(), config.decision.as_ref())),
953                    [None, None],
954                )
955            }
956            Self::Composite {
957                classifier,
958                efficient,
959                capable,
960                ..
961            } => (
962                Some((classifier.target.as_str(), classifier.decision.as_ref())),
963                [efficient.as_deref(), capable.as_deref()],
964            ),
965            Self::StageRouter { classifier, .. } => (
966                classifier
967                    .as_ref()
968                    .map(|c| (c.target.as_str(), c.decision.as_ref())),
969                [None, None],
970            ),
971            _ => (None, [None, None]),
972        };
973        primary.into_iter().chain(
974            profiles
975                .into_iter()
976                .flatten()
977                .filter_map(|profile| profile.classifier.as_ref())
978                .map(|c| (c.target.as_str(), c.decision.as_ref())),
979        )
980    }
981
982    /// Gives capability judges default decision settings when their target is a decision target.
983    pub(crate) fn infer_decision_judges(&mut self, is_decision_target: impl Fn(&str) -> bool) {
984        let mut infer = |target: &str, decision: &mut Option<DecisionJudgeRouteConfig>| {
985            if is_decision_target(target) {
986                decision.get_or_insert_with(Default::default);
987            }
988        };
989        match self {
990            Self::LlmClassifier { config }
991                if matches!(config.classifier_mode(), ClassifierMode::Capability) =>
992            {
993                infer(&config.classifier_target, &mut config.decision);
994            }
995            Self::StageRouter {
996                classifier: Some(classifier),
997                ..
998            } => classifier.infer_decision(&mut infer),
999            Self::Composite {
1000                classifier,
1001                efficient,
1002                capable,
1003                ..
1004            } => {
1005                classifier.infer_decision(&mut infer);
1006                for profile in [efficient, capable].into_iter().flatten() {
1007                    if let Some(classifier) = &mut profile.classifier {
1008                        classifier.infer_decision(&mut infer);
1009                    }
1010                }
1011            }
1012            _ => {}
1013        }
1014    }
1015
1016    /// Judge targets that must be decision targets.
1017    pub(crate) fn decision_target_names(&self) -> Vec<&str> {
1018        let mut names: Vec<&str> = self
1019            .classifier_targets()
1020            .filter(|(_, decision)| decision.is_some())
1021            .map(|(target, _)| target)
1022            .collect();
1023        if let Self::LlmClassifier { config, .. } = self
1024            && matches!(config.classifier_mode(), ClassifierMode::Direct)
1025            && let Some(models) = &config.models
1026        {
1027            names.extend(models.get(&Category::Judge).iter().map(String::as_str));
1028        }
1029        names
1030    }
1031
1032    /// Completion targets in algorithm order; judge-only targets are excluded.
1033    pub fn routing_target_names(&self) -> Vec<&str> {
1034        match self {
1035            Self::Noop { .. } => Vec::new(),
1036            Self::Random { targets, .. } => targets.iter().map(String::as_str).collect(),
1037            Self::Passthrough {
1038                target, subagents, ..
1039            } => {
1040                let mut names = vec![target.as_str()];
1041                if let Some(subagents) = subagents {
1042                    names.extend(subagents.routing_target_names());
1043                }
1044                names
1045            }
1046            Self::LlmClassifier { config, .. } => match config.classifier_mode() {
1047                ClassifierMode::Capability => config
1048                    .weak_target
1049                    .iter()
1050                    .chain(&config.strong_target)
1051                    .map(String::as_str)
1052                    .collect(),
1053                ClassifierMode::Escalation => config
1054                    .strong_target
1055                    .iter()
1056                    .chain(&config.weak_target)
1057                    .map(String::as_str)
1058                    .collect(),
1059                ClassifierMode::Custom | ClassifierMode::Direct => config
1060                    .models
1061                    .as_ref()
1062                    .map(CategoryModelConfig::routing_names)
1063                    .unwrap_or_default(),
1064            },
1065            Self::StageRouter {
1066                capable_target,
1067                efficient_target,
1068                subagents,
1069                ..
1070            } => {
1071                let mut names = vec![capable_target.as_str(), efficient_target.as_str()];
1072                if let Some(subagents) = subagents {
1073                    names.extend(subagents.routing_target_names());
1074                }
1075                names
1076            }
1077            Self::Auto {
1078                capable_target,
1079                efficient_target,
1080            } => vec![capable_target.as_str(), efficient_target.as_str()],
1081            Self::Composite {
1082                stage, subagents, ..
1083            } => {
1084                let mut names = vec![
1085                    stage.capable_target.as_str(),
1086                    stage.efficient_target.as_str(),
1087                ];
1088                if let Some(subagents) = subagents {
1089                    names.extend(subagents.routing_target_names());
1090                }
1091                names
1092            }
1093            // The advisor is judge-only: reviews go through its own client,
1094            // so it is not a completion (or count_tokens) destination.
1095            Self::Advisor {
1096                executor_target, ..
1097            } => vec![executor_target],
1098            Self::Vgr { config } => {
1099                vec![
1100                    config.efficient_target.as_str(),
1101                    config.capable_target.as_str(),
1102                ]
1103            }
1104            Self::PrefillRouter { targets, .. } => targets.iter().map(String::as_str).collect(),
1105        }
1106    }
1107
1108    /// Every target the algorithm may call, including judge-only targets.
1109    ///
1110    /// [`routing_target_names`](Self::routing_target_names) covers completion destinations;
1111    /// a classifier also calls its judge, and that call needs a client too.
1112    pub fn callable_target_names(&self) -> Vec<&str> {
1113        let mut names = self.routing_target_names();
1114        names.extend(self.judge_target_names());
1115        names
1116    }
1117
1118    /// Targets the algorithm calls only to decide, such as a judge or an advisor. A target
1119    /// that also answers requests appears in [`routing_target_names`](Self::routing_target_names) too.
1120    pub fn judge_target_names(&self) -> Vec<&str> {
1121        let mut names = Vec::new();
1122        match self {
1123            // Custom and direct modes name their judge in `models.judge`; the other
1124            // two modes use the top-level `classifier_target`.
1125            Self::LlmClassifier { config, .. } => {
1126                if matches!(
1127                    config.classifier_mode(),
1128                    ClassifierMode::Custom | ClassifierMode::Direct
1129                ) {
1130                    names.extend(
1131                        config
1132                            .models
1133                            .as_ref()
1134                            .map(|models| models.get(&Category::Judge))
1135                            .unwrap_or_default()
1136                            .iter()
1137                            .map(String::as_str),
1138                    );
1139                } else {
1140                    names.push(config.classifier_target.as_str());
1141                }
1142            }
1143            Self::StageRouter { .. } | Self::Composite { .. } => {
1144                names.extend(self.classifier_targets().map(|(target, _)| target));
1145            }
1146            Self::Advisor { advisor_target, .. } => names.push(advisor_target),
1147            Self::Vgr { config } => {
1148                names.extend(config.judge_target.as_deref());
1149                names.extend(config.capable_judge_target.as_deref());
1150            }
1151            _ => {}
1152        }
1153        // A sub-agent classifier calls its own judge, which is never a completion target.
1154        if let Self::Passthrough {
1155            subagents: Some(subagents),
1156            ..
1157        }
1158        | Self::StageRouter {
1159            subagents: Some(subagents),
1160            ..
1161        }
1162        | Self::Composite {
1163            subagents: Some(subagents),
1164            ..
1165        } = self
1166        {
1167            names.extend(subagents.judge_target_names());
1168        }
1169        names
1170    }
1171
1172    /// Target names grouped as the runtime [`Driver`](libsy::Driver) expects them.
1173    fn runtime_model_names(&self, route_name: &str) -> AlgorithmResult<RuntimeModelNames> {
1174        let parent = match self {
1175            Self::Noop { .. } => HashMap::new(),
1176            Self::Random { targets, .. } | Self::PrefillRouter { targets, .. } => {
1177                category_models([(Category::Any, targets.clone())])
1178            }
1179            Self::Passthrough { target, .. } => {
1180                category_models([(Category::Any, vec![target.clone()])])
1181            }
1182            Self::LlmClassifier { config } => {
1183                classifier_runtime_model_names(config.validated_classifier_mode(route_name)?)
1184            }
1185            Self::StageRouter {
1186                capable_target,
1187                efficient_target,
1188                classifier,
1189                ..
1190            } => {
1191                let mut models = category_models([
1192                    (Category::Capable, vec![capable_target.clone()]),
1193                    (Category::Efficient, vec![efficient_target.clone()]),
1194                    (
1195                        Category::Any,
1196                        vec![capable_target.clone(), efficient_target.clone()],
1197                    ),
1198                ]);
1199                if let Some(classifier) = classifier {
1200                    models.insert(Category::Judge, vec![classifier.target.clone()]);
1201                }
1202                models
1203            }
1204            Self::Auto {
1205                capable_target,
1206                efficient_target,
1207            } => category_models([
1208                (Category::Capable, vec![capable_target.clone()]),
1209                (Category::Efficient, vec![efficient_target.clone()]),
1210                (
1211                    Category::Any,
1212                    vec![capable_target.clone(), efficient_target.clone()],
1213                ),
1214            ]),
1215            Self::Composite { stage, .. } => category_models([
1216                (
1217                    Category::Judge,
1218                    self.judge_target_names()
1219                        .into_iter()
1220                        .map(str::to_string)
1221                        .collect(),
1222                ),
1223                (Category::Capable, vec![stage.capable_target.clone()]),
1224                (Category::Efficient, vec![stage.efficient_target.clone()]),
1225                (
1226                    Category::Any,
1227                    vec![stage.capable_target.clone(), stage.efficient_target.clone()],
1228                ),
1229            ]),
1230            Self::Advisor {
1231                executor_target,
1232                advisor_target,
1233                ..
1234            } => category_models([
1235                (Category::Efficient, vec![executor_target.clone()]),
1236                (Category::Any, vec![executor_target.clone()]),
1237                (Category::Judge, vec![advisor_target.clone()]),
1238            ]),
1239            Self::Vgr { config } => {
1240                let judge = config
1241                    .judge_target
1242                    .as_ref()
1243                    .unwrap_or(&config.efficient_target);
1244                let judges = std::iter::once(judge)
1245                    .chain(&config.capable_judge_target)
1246                    .cloned()
1247                    .collect();
1248                category_models([
1249                    (Category::Efficient, vec![config.efficient_target.clone()]),
1250                    (Category::Capable, vec![config.capable_target.clone()]),
1251                    (
1252                        Category::Any,
1253                        vec![
1254                            config.efficient_target.clone(),
1255                            config.capable_target.clone(),
1256                        ],
1257                    ),
1258                    (Category::Judge, judges),
1259                ])
1260            }
1261        };
1262
1263        let subagents = match self {
1264            Self::Passthrough { subagents, .. }
1265            | Self::StageRouter { subagents, .. }
1266            | Self::Composite { subagents, .. } => subagents.as_ref(),
1267            _ => None,
1268        };
1269        // Sub-agent groups stay separate from the parent's. Merging them would let a
1270        // sub-agent's `capable` resolve to the parent's, and would put models only the
1271        // sub-agents were given into the parent's `Any` fallback list.
1272        let subagent = subagents
1273            .map(|subagents| subagent_runtime_model_names(subagents, route_name))
1274            .transpose()?;
1275        Ok(RuntimeModelNames { parent, subagent })
1276    }
1277
1278    /// Response target and routing-only dependency for routers that answer while routing.
1279    #[cfg(not(target_os = "wasi"))]
1280    pub(crate) fn routing_response_and_dependency(&self) -> Option<(&str, &str)> {
1281        match self {
1282            Self::LlmClassifier { config, .. }
1283                if matches!(config.classifier_mode(), ClassifierMode::Escalation) =>
1284            {
1285                Some((
1286                    config.weak_target.as_deref()?,
1287                    config.classifier_target.as_str(),
1288                ))
1289            }
1290            Self::Advisor {
1291                executor_target,
1292                advisor_target,
1293                ..
1294            } => Some((executor_target, advisor_target)),
1295            Self::Vgr { config } => Some((
1296                &config.efficient_target,
1297                config
1298                    .judge_target
1299                    .as_deref()
1300                    .unwrap_or(&config.efficient_target),
1301            )),
1302            Self::Noop { .. }
1303            | Self::Random { .. }
1304            | Self::Passthrough { .. }
1305            | Self::LlmClassifier { .. }
1306            | Self::StageRouter { .. }
1307            | Self::Auto { .. }
1308            | Self::Composite { .. }
1309            | Self::PrefillRouter { .. } => None,
1310        }
1311    }
1312
1313    /// Builds this algorithm after resolving configured target names.
1314    pub fn build(
1315        &self,
1316        route_name: &str,
1317        targets: &BTreeMap<String, ModelId>,
1318    ) -> AlgorithmResult<Arc<dyn Algorithm>> {
1319        build_algorithm(route_name, self, targets)
1320    }
1321
1322    /// Resolves target names to host model IDs and builds the algorithm and runtime groups.
1323    pub fn build_with_runtime_models(
1324        &self,
1325        route_name: &str,
1326        targets: &BTreeMap<String, ModelId>,
1327    ) -> AlgorithmResult<(Arc<dyn Algorithm>, RuntimeModels)> {
1328        let algorithm = self.build(route_name, targets)?;
1329        let names = self.runtime_model_names(route_name)?;
1330        let mut models =
1331            RuntimeModels::new(resolve_runtime_models(route_name, names.parent, targets)?);
1332        if let Some(subagent) = names.subagent {
1333            models = models.with_subagent(resolve_runtime_models(route_name, subagent, targets)?);
1334        }
1335        Ok((algorithm, models))
1336    }
1337}
1338
1339/// One route's target names, grouped by category and by routing scope.
1340struct RuntimeModelNames {
1341    /// Groups the algorithm itself routes over.
1342    parent: HashMap<Category, Vec<String>>,
1343    /// Groups delegated sub-agent work routes over, when the route has a `subagents` table.
1344    subagent: Option<HashMap<Category, Vec<String>>>,
1345}
1346
1347fn category_models(
1348    entries: impl IntoIterator<Item = (Category, Vec<String>)>,
1349) -> HashMap<Category, Vec<String>> {
1350    entries.into_iter().collect()
1351}
1352
1353fn custom_runtime_model_names(config: &CategoryModelConfig) -> HashMap<Category, Vec<String>> {
1354    config.groups().collect()
1355}
1356
1357fn classifier_runtime_model_names(
1358    config: LlmClassifierModeConfig,
1359) -> HashMap<Category, Vec<String>> {
1360    match config {
1361        LlmClassifierModeConfig::Capability(config) => category_models([
1362            (Category::Judge, vec![config.classifier_target]),
1363            (Category::Efficient, vec![config.weak_target.clone()]),
1364            (Category::Capable, vec![config.strong_target.clone()]),
1365            (
1366                Category::Any,
1367                vec![config.weak_target, config.strong_target],
1368            ),
1369        ]),
1370        LlmClassifierModeConfig::Escalation(config) => category_models([
1371            (Category::Judge, vec![config.classifier_target]),
1372            (Category::Efficient, vec![config.weak_target.clone()]),
1373            (Category::Capable, vec![config.strong_target.clone()]),
1374            (
1375                Category::Any,
1376                vec![config.strong_target, config.weak_target],
1377            ),
1378        ]),
1379        LlmClassifierModeConfig::Custom(config) => custom_runtime_model_names(&config.models),
1380        LlmClassifierModeConfig::Direct { models, .. } => custom_runtime_model_names(&models),
1381    }
1382}
1383
1384fn subagent_runtime_model_names(
1385    config: &SubagentRouteConfig,
1386    route_name: &str,
1387) -> AlgorithmResult<HashMap<Category, Vec<String>>> {
1388    match config {
1389        SubagentRouteConfig::Passthrough { target } => {
1390            Ok(category_models([(Category::Any, vec![target.clone()])]))
1391        }
1392        SubagentRouteConfig::LlmClassifier(config) => {
1393            let LlmClassifierModeConfig::Custom(config) =
1394                config.validated_classifier_mode(route_name)?
1395            else {
1396                return Err(AlgorithmConfigError::new(format!(
1397                    "route {route_name}: subagents llm_classifier only supports mode custom"
1398                )));
1399            };
1400            Ok(custom_runtime_model_names(&config.models))
1401        }
1402    }
1403}
1404impl LlmClassifierRouteConfig {
1405    fn classifier_mode(&self) -> ClassifierMode {
1406        let default = if self.escalation.is_some() {
1407            ClassifierMode::Escalation
1408        } else {
1409            ClassifierMode::Capability
1410        };
1411        self.mode.unwrap_or(default)
1412    }
1413
1414    fn validated_classifier_mode(
1415        &self,
1416        route_name: &str,
1417    ) -> AlgorithmResult<LlmClassifierModeConfig> {
1418        let Self {
1419            classifier_target,
1420            mode,
1421            strong_target,
1422            weak_target,
1423            base_threshold,
1424            threshold_step,
1425            unmatched_steps,
1426            fail_open,
1427            classify_trigger,
1428            message_hash_fallback,
1429            recent_turn_window,
1430            prompt,
1431            response_format_type,
1432            max_output_tokens,
1433            judge_deadline_ms,
1434            escalation,
1435            decision,
1436            models,
1437            default_target,
1438            response_schema,
1439            policy,
1440        } = self;
1441
1442        let selected_mode = match (mode, escalation.is_some()) {
1443            (Some(mode), _) => *mode,
1444            (None, true) => ClassifierMode::Escalation,
1445            (None, false) => ClassifierMode::Capability,
1446        };
1447
1448        if decision.is_some() && !matches!(selected_mode, ClassifierMode::Capability) {
1449            return Err(AlgorithmConfigError::new(format!(
1450                "llm_classifier route {route_name}: decision requires capability mode"
1451            )));
1452        }
1453
1454        if !matches!(
1455            selected_mode,
1456            ClassifierMode::Capability | ClassifierMode::Direct
1457        ) && *fail_open == Some(true)
1458        {
1459            return Err(AlgorithmConfigError::new(format!(
1460                "llm_classifier route {route_name}: fail_open = true requires capability or direct mode"
1461            )));
1462        }
1463
1464        match selected_mode {
1465            ClassifierMode::Capability => {
1466                if escalation.is_some() {
1467                    return Err(classifier_field_error(
1468                        route_name,
1469                        "escalation",
1470                        "capability",
1471                    ));
1472                }
1473                reject_custom_fields(
1474                    route_name,
1475                    "capability",
1476                    models,
1477                    default_target,
1478                    response_schema,
1479                    policy,
1480                )?;
1481                let judge = if let Some(decision) = decision {
1482                    if base_threshold.is_some()
1483                        || threshold_step.is_some()
1484                        || unmatched_steps.is_some()
1485                        || prompt.is_some()
1486                        || *response_format_type != ClassifierResponseFormat::JsonSchema
1487                        || *max_output_tokens != default_classifier_max_output_tokens()
1488                        || judge_deadline_ms.is_some()
1489                    {
1490                        return Err(AlgorithmConfigError::new(format!(
1491                            "llm_classifier route {route_name}: decision cannot use LLM judge settings; use decision.cutoff and decision.instructions"
1492                        )));
1493                    }
1494                    CapabilityJudgeRouteConfig::Decision(decision.clone())
1495                } else {
1496                    CapabilityJudgeRouteConfig::Llm(LlmCapabilityConfig {
1497                        base_threshold: required_classifier_field(
1498                            route_name,
1499                            "base_threshold",
1500                            base_threshold,
1501                        )?,
1502                        threshold_step: threshold_step.unwrap_or_default(),
1503                        unmatched_steps: unmatched_steps.unwrap_or(DEFAULT_UNMATCHED_STEPS),
1504                        contract: classifier_contract(prompt.as_deref())
1505                            .with_response_format_type(*response_format_type),
1506                        max_output_tokens: *max_output_tokens,
1507                        judge_deadline_ms: *judge_deadline_ms,
1508                    })
1509                };
1510                Ok(LlmClassifierModeConfig::Capability(
1511                    CapabilityClassifierRouteConfig {
1512                        classifier_target: classifier_target.clone(),
1513                        fail_open: fail_open.unwrap_or(true),
1514                        strong_target: required_classifier_field(
1515                            route_name,
1516                            "strong_target",
1517                            strong_target,
1518                        )?,
1519                        weak_target: required_classifier_field(
1520                            route_name,
1521                            "weak_target",
1522                            weak_target,
1523                        )?,
1524                        judge,
1525                        classify_trigger: *classify_trigger,
1526                        message_hash_fallback: *message_hash_fallback,
1527                        recent_turn_window: *recent_turn_window,
1528                    },
1529                ))
1530            }
1531            ClassifierMode::Escalation => {
1532                reject_custom_fields(
1533                    route_name,
1534                    "escalation",
1535                    models,
1536                    default_target,
1537                    response_schema,
1538                    policy,
1539                )?;
1540                if *classify_trigger != ClassifyTrigger::EveryRequest {
1541                    return Err(AlgorithmConfigError::new(format!(
1542                        "llm_classifier route {route_name} mode escalation cannot use classify_trigger"
1543                    )));
1544                }
1545                let has_capability_settings = base_threshold.is_some()
1546                    || threshold_step.is_some()
1547                    || fail_open.is_some()
1548                    || *message_hash_fallback
1549                    || recent_turn_window.is_some();
1550                // `unmatched_steps` is new, so no existing escalation configuration carries it:
1551                // reject it even when the mode is implied by `escalation`. The older capability
1552                // keys stay tolerated in that implicit form for compatibility.
1553                if unmatched_steps.is_some() || (mode.is_some() && has_capability_settings) {
1554                    return Err(AlgorithmConfigError::new(format!(
1555                        "llm_classifier route {route_name} mode escalation cannot use capability routing settings"
1556                    )));
1557                }
1558                if has_capability_settings {
1559                    // Existing files that select escalation through the `escalation` table alone
1560                    // keep loading, but their capability keys do nothing in this mode.
1561                    tracing::warn!(
1562                        "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."
1563                    );
1564                }
1565                Ok(LlmClassifierModeConfig::Escalation(
1566                    EscalationClassifierRouteConfig {
1567                        classifier_target: classifier_target.clone(),
1568                        strong_target: required_classifier_field(
1569                            route_name,
1570                            "strong_target",
1571                            strong_target,
1572                        )?,
1573                        weak_target: required_classifier_field(
1574                            route_name,
1575                            "weak_target",
1576                            weak_target,
1577                        )?,
1578                        prompt: prompt.clone(),
1579                        response_format_type: *response_format_type,
1580                        max_output_tokens: *max_output_tokens,
1581                        judge_deadline_ms: *judge_deadline_ms,
1582                        judge: required_classifier_field(route_name, "escalation", escalation)?,
1583                    },
1584                ))
1585            }
1586            ClassifierMode::Custom => {
1587                if !classifier_target.is_empty()
1588                    || strong_target.is_some()
1589                    || weak_target.is_some()
1590                    || base_threshold.is_some()
1591                    || threshold_step.is_some()
1592                    || unmatched_steps.is_some()
1593                    || escalation.is_some()
1594                    || *response_format_type != ClassifierResponseFormat::JsonSchema
1595                {
1596                    return Err(AlgorithmConfigError::new(format!(
1597                        "llm_classifier route {route_name} mode custom cannot use capability or escalation fields and response_format_type must be 'json_schema'"
1598                    )));
1599                }
1600                let (models, default_target) =
1601                    validated_models(route_name, models, default_target)?;
1602                Ok(LlmClassifierModeConfig::Custom(
1603                    CustomClassifierRouteConfig {
1604                        models,
1605                        default_target,
1606                        prompt: required_classifier_field(route_name, "prompt", prompt)?,
1607                        response_schema: required_classifier_field(
1608                            route_name,
1609                            "response_schema",
1610                            response_schema,
1611                        )?,
1612                        policy: required_classifier_field(route_name, "policy", policy)?,
1613                        classify_trigger: *classify_trigger,
1614                        message_hash_fallback: *message_hash_fallback,
1615                        recent_turn_window: *recent_turn_window,
1616                        max_output_tokens: *max_output_tokens,
1617                        judge_deadline_ms: *judge_deadline_ms,
1618                    },
1619                ))
1620            }
1621            ClassifierMode::Direct => {
1622                if !classifier_target.is_empty()
1623                    || strong_target.is_some()
1624                    || weak_target.is_some()
1625                    || base_threshold.is_some()
1626                    || threshold_step.is_some()
1627                    || unmatched_steps.is_some()
1628                    || escalation.is_some()
1629                    || prompt.is_some()
1630                    || response_schema.is_some()
1631                    || policy.is_some()
1632                    || *response_format_type != ClassifierResponseFormat::JsonSchema
1633                    || *max_output_tokens != default_classifier_max_output_tokens()
1634                    || judge_deadline_ms.is_some()
1635                {
1636                    return Err(AlgorithmConfigError::new(format!(
1637                        "llm_classifier route {route_name} mode direct cannot use capability, escalation, or LLM judge fields"
1638                    )));
1639                }
1640                let (models, default_target) =
1641                    validated_models(route_name, models, default_target)?;
1642                let options = models
1643                    .groups()
1644                    .filter(|(category, targets)| {
1645                        !targets.is_empty() && !matches!(category, Category::Judge | Category::Any)
1646                    })
1647                    .map(|(category, _)| category)
1648                    .collect();
1649                Ok(LlmClassifierModeConfig::Direct {
1650                    models,
1651                    default_target,
1652                    config: DirectClassifierConfig {
1653                        options,
1654                        fail_open: fail_open.unwrap_or(true),
1655                        classify_trigger: *classify_trigger,
1656                        message_hash_fallback: *message_hash_fallback,
1657                        recent_turn_window: *recent_turn_window,
1658                    },
1659                })
1660            }
1661        }
1662    }
1663}
1664
1665fn validated_models(
1666    route_name: &str,
1667    models: &Option<CategoryModelConfig>,
1668    default_target: &Option<String>,
1669) -> AlgorithmResult<(CategoryModelConfig, Category)> {
1670    let models = required_classifier_field(route_name, "models", models)?;
1671    let default_target: Category =
1672        required_classifier_field(route_name, "default_target", default_target)?
1673            .parse()
1674            .map_err(|error| {
1675                AlgorithmConfigError::new(format!(
1676                    "llm_classifier route {route_name} has invalid default_target: {error}"
1677                ))
1678            })?;
1679    if default_target == Category::Judge {
1680        return Err(AlgorithmConfigError::new(format!(
1681            "llm_classifier route {route_name} default_target cannot be judge"
1682        )));
1683    }
1684    models.validate(route_name, &default_target)?;
1685    Ok((models, default_target))
1686}
1687
1688fn reject_custom_fields(
1689    route_name: &str,
1690    mode: &str,
1691    models: &Option<CategoryModelConfig>,
1692    default_target: &Option<String>,
1693    response_schema: &Option<String>,
1694    policy: &Option<ClassifierPolicyConfig>,
1695) -> AlgorithmResult<()> {
1696    if models.is_some() || default_target.is_some() || response_schema.is_some() || policy.is_some()
1697    {
1698        return Err(AlgorithmConfigError::new(format!(
1699            "llm_classifier route {route_name} mode {mode} cannot use custom classifier fields"
1700        )));
1701    }
1702    Ok(())
1703}
1704
1705fn classifier_field_error(route_name: &str, field: &str, mode: &str) -> AlgorithmConfigError {
1706    AlgorithmConfigError::new(format!(
1707        "llm_classifier route {route_name} mode {mode} cannot use {field}"
1708    ))
1709}
1710
1711fn required_classifier_field<T: Clone>(
1712    route_name: &str,
1713    field: &str,
1714    value: &Option<T>,
1715) -> AlgorithmResult<T> {
1716    value.clone().ok_or_else(|| {
1717        AlgorithmConfigError::new(format!(
1718            "llm_classifier route {route_name} requires {field}"
1719        ))
1720    })
1721}
1722
1723fn build_subagent_router_config(
1724    route_name: &str,
1725    config: &SubagentRouteConfig,
1726    targets: &BTreeMap<String, ModelId>,
1727) -> AlgorithmResult<SubagentRouterConfig> {
1728    match config {
1729        SubagentRouteConfig::Passthrough { target } => {
1730            resolve_target_model_id(route_name, target, targets)?;
1731            Ok(SubagentRouterConfig::fixed_target())
1732        }
1733        SubagentRouteConfig::LlmClassifier(config) => {
1734            let LlmClassifierModeConfig::Custom(config) =
1735                config.validated_classifier_mode(route_name)?
1736            else {
1737                return Err(AlgorithmConfigError::new(format!(
1738                    "route {route_name}: subagents llm_classifier only supports mode custom"
1739                )));
1740            };
1741            for name in config.models.all_names() {
1742                resolve_target_model_id(route_name, name, targets)?;
1743            }
1744            if config.models.get(&config.default_target).is_empty() {
1745                return Err(AlgorithmConfigError::new(format!(
1746                    "route {route_name}: subagents llm_classifier has no model for default category {}",
1747                    config.default_target.as_str()
1748                )));
1749            }
1750            let response_schema =
1751                serde_json::from_str(&config.response_schema).map_err(|error| {
1752                    AlgorithmConfigError::with_source(
1753                        format!(
1754                            "route {route_name}: subagents llm_classifier response_schema is invalid JSON: {error}"
1755                        ),
1756                        error,
1757                    )
1758                })?;
1759            warn_single_target_classifier(route_name, &config.models);
1760            let mut classifier_config = CustomClassifierConfig::new(
1761                config.prompt,
1762                response_schema,
1763                config.policy.into_libsy(),
1764            );
1765            classifier_config.recent_turn_window = config.recent_turn_window;
1766            classifier_config.max_output_tokens = config.max_output_tokens;
1767            classifier_config.judge_deadline_ms = config.judge_deadline_ms;
1768            let classifier = Arc::new(
1769                LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1770                    default_target: config.default_target.clone(),
1771                    config: classifier_config,
1772                })
1773                .map_err(|error| {
1774                    AlgorithmConfigError::with_source(
1775                        format!("route {route_name}: subagents llm_classifier: {error}"),
1776                        error,
1777                    )
1778                })?,
1779            );
1780            Ok(SubagentRouterConfig {
1781                classifier,
1782                default_target: config.default_target,
1783                classify_trigger: config.classify_trigger,
1784                message_hash_fallback: config.message_hash_fallback,
1785            })
1786        }
1787    }
1788}
1789
1790fn attach_subagent_router(
1791    route_name: &str,
1792    parent: Arc<dyn Algorithm>,
1793    config: Option<&SubagentRouteConfig>,
1794    targets: &BTreeMap<String, ModelId>,
1795) -> AlgorithmResult<Arc<dyn Algorithm>> {
1796    let Some(config) = config else {
1797        return Ok(parent);
1798    };
1799    let config = build_subagent_router_config(route_name, config, targets)?;
1800    let algorithm = SubagentRouter::new(parent, config).map_err(|error| {
1801        AlgorithmConfigError::with_source(
1802            format!("route {route_name}: subagent routing: {error}"),
1803            error,
1804        )
1805    })?;
1806    Ok(Arc::new(algorithm))
1807}
1808
1809/// `build_algorithm` builds the configured algorithm and attaches supported subagent routing.
1810/// It returns errors for invalid settings or failed target lookups during construction.
1811/// `AlgorithmSpec::build_with_runtime_models` also resolves the runtime model groups.
1812fn build_algorithm(
1813    route_name: &str,
1814    config: &AlgorithmSpec,
1815    targets: &BTreeMap<String, ModelId>,
1816) -> AlgorithmResult<Arc<dyn Algorithm>> {
1817    match config {
1818        AlgorithmSpec::Noop { .. } => Ok(Arc::new(Noop {})),
1819        AlgorithmSpec::Random {
1820            targets: names,
1821            weights,
1822            seed,
1823        } => {
1824            // The algorithm only sees its targets at request time, so a bad pairing
1825            // would otherwise fail every request instead of failing to start.
1826            if let Some(weights) = weights
1827                && weights.len() != names.len()
1828            {
1829                return Err(AlgorithmConfigError::new(format!(
1830                    "random route {route_name}: expected {} weights, got {}",
1831                    names.len(),
1832                    weights.len()
1833                )));
1834            }
1835            let mut seen = BTreeSet::new();
1836            if let Some(duplicate) = names.iter().find(|name| !seen.insert(*name)) {
1837                return Err(AlgorithmConfigError::new(format!(
1838                    "random route {route_name}: targets must be unique, {duplicate} is repeated"
1839                )));
1840            }
1841            let algorithm = Random::new(weights.clone(), *seed).map_err(|error| {
1842                AlgorithmConfigError::with_source(
1843                    format!("random route {route_name}: {error}"),
1844                    error,
1845                )
1846            })?;
1847            Ok(Arc::new(algorithm))
1848        }
1849        AlgorithmSpec::Passthrough { subagents, .. } => {
1850            let algorithm = Passthrough;
1851            let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1852            attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1853        }
1854        AlgorithmSpec::LlmClassifier {
1855            config: classifier_config,
1856            ..
1857        } => {
1858            let mode = classifier_config.validated_classifier_mode(route_name)?;
1859            let algorithm = match mode {
1860                LlmClassifierModeConfig::Capability(config) => {
1861                    let classifier_config = config.into_task_config();
1862                    LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1863                        config: classifier_config,
1864                    })
1865                }
1866                LlmClassifierModeConfig::Escalation(config) => {
1867                    LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1868                        contract: classifier_contract(config.prompt.as_deref())
1869                            .with_response_format_type(config.response_format_type),
1870                        config: config.judge,
1871                        max_output_tokens: config.max_output_tokens,
1872                        judge_deadline_ms: config.judge_deadline_ms,
1873                    })
1874                }
1875                LlmClassifierModeConfig::Custom(config) => {
1876                    let response_schema = serde_json::from_str(&config.response_schema).map_err(
1877                        |error| {
1878                            AlgorithmConfigError::with_source(
1879                                format!(
1880                                    "llm_classifier route {route_name}: response_schema is invalid JSON: {error}"
1881                                ),
1882                                error,
1883                            )
1884                        },
1885                    )?;
1886                    warn_single_target_classifier(route_name, &config.models);
1887                    let mut classifier_config = CustomClassifierConfig::new(
1888                        config.prompt,
1889                        response_schema,
1890                        config.policy.into_libsy(),
1891                    );
1892                    classifier_config.classify_trigger = config.classify_trigger;
1893                    classifier_config.message_hash_fallback = config.message_hash_fallback;
1894                    classifier_config.recent_turn_window = config.recent_turn_window;
1895                    classifier_config.max_output_tokens = config.max_output_tokens;
1896                    classifier_config.judge_deadline_ms = config.judge_deadline_ms;
1897                    LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1898                        default_target: config.default_target,
1899                        config: classifier_config,
1900                    })
1901                }
1902                LlmClassifierModeConfig::Direct {
1903                    default_target,
1904                    config,
1905                    ..
1906                } => LlmTaskClassifier::new(LlmClassifierConfig::Direct {
1907                    default_target,
1908                    config,
1909                }),
1910            }
1911            .map_err(|error| {
1912                AlgorithmConfigError::with_source(
1913                    format!("llm_classifier route {route_name}: {error}"),
1914                    error,
1915                )
1916            })?;
1917            Ok(Arc::new(algorithm))
1918        }
1919        AlgorithmSpec::StageRouter {
1920            capable_target,
1921            efficient_target,
1922            capable_first_threshold,
1923            settings,
1924            picker,
1925            classifier,
1926            subagents,
1927            ..
1928        } => {
1929            if matches!(picker, PickerMode::CapableFirst) {
1930                tracing::warn!(
1931                    "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."
1932                );
1933            }
1934            warn_identical_tiers(route_name, capable_target, efficient_target);
1935            let threshold = settings.confidence_threshold.ok_or_else(|| {
1936                AlgorithmConfigError::new(format!(
1937                    "stage_router route {route_name}: confidence_threshold is required"
1938                ))
1939            })?;
1940            let mut config = settings.stage_config(settings, *picker, threshold);
1941            config.capable_first_threshold = *capable_first_threshold;
1942            // The judge is called through its own target, so it is not a routing
1943            // destination and stays out of the tier pair.
1944            config.llm_fallback = classifier
1945                .as_ref()
1946                .map(|classifier| {
1947                    classifier.stage_fallback(route_name, capable_target, efficient_target, targets)
1948                })
1949                .transpose()?;
1950            let algorithm = StageRouter::new(config).map_err(|error| {
1951                AlgorithmConfigError::with_source(
1952                    format!("stage_router route {route_name}: {error}"),
1953                    error,
1954                )
1955            })?;
1956            let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1957            attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1958        }
1959        AlgorithmSpec::Auto {
1960            capable_target,
1961            efficient_target,
1962        } => {
1963            warn_identical_tiers(route_name, capable_target, efficient_target);
1964            let config = StageRouterConfig::new(PickerMode::EfficientFirst, 0.5);
1965            let algorithm = StageRouter::new(config).map_err(|error| {
1966                AlgorithmConfigError::with_source(
1967                    format!("auto route {route_name}: {error}"),
1968                    error,
1969                )
1970            })?;
1971            Ok(Arc::new(algorithm))
1972        }
1973        AlgorithmSpec::Composite {
1974            classifier,
1975            stage,
1976            efficient,
1977            capable,
1978            subagents,
1979        } => {
1980            stage.warn_identical_tiers(route_name);
1981            let judge = classifier.composite_judge_config(
1982                route_name,
1983                &stage.capable_target,
1984                &stage.efficient_target,
1985            )?;
1986            let defaults = StageProfileConfig::default();
1987            let profiles = [
1988                (efficient, PickerMode::EfficientFirst),
1989                (capable, PickerMode::CapableFirst),
1990            ]
1991            .map(|(profile, picker)| -> AlgorithmResult<_> {
1992                let profile = profile.as_deref().unwrap_or(&defaults);
1993                let mut config = stage.profile_config(profile, picker);
1994                config.llm_fallback = profile
1995                    .classifier
1996                    .as_ref()
1997                    .map(|classifier| {
1998                        classifier.stage_fallback(
1999                            route_name,
2000                            &stage.capable_target,
2001                            &stage.efficient_target,
2002                            targets,
2003                        )
2004                    })
2005                    .transpose()?;
2006                Ok(config)
2007            });
2008            let [profile_0, profile_1] = profiles;
2009            let algorithm = CompositeRouter::new_with_profiles(judge, profile_0?, profile_1?)
2010                .map_err(|error| {
2011                    AlgorithmConfigError::with_source(
2012                        format!("composite route {route_name}: {error}"),
2013                        error,
2014                    )
2015                })?;
2016            let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
2017            attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
2018        }
2019        AlgorithmSpec::Advisor {
2020            reviewer_system_prompt,
2021            redo_feedback_prefix,
2022            gate_trigger,
2023            gate_trigger_pattern,
2024            max_reviews,
2025            gate_stall_turns,
2026            gate_min_tool_results,
2027            advisor_max_tokens,
2028            advisor_temperature,
2029            transcript_max_chars,
2030            fail_open,
2031            ..
2032        } => {
2033            // A pattern set under the default trigger would be silently
2034            // ignored; reject the misconfiguration instead.
2035            if *gate_trigger == AdvisorTriggerConfig::NoToolCall && gate_trigger_pattern.is_some() {
2036                return Err(AlgorithmConfigError::new(format!(
2037                    "advisor route {route_name}: gate_trigger_pattern requires \
2038                     gate_trigger = \"pattern\""
2039                )));
2040            }
2041            let mut config = AdvisorGateConfig::default();
2042            if let Some(prompt) = reviewer_system_prompt {
2043                config.reviewer_system_prompt = prompt.clone();
2044            }
2045            if let Some(prefix) = redo_feedback_prefix {
2046                config.redo_feedback_prefix = prefix.clone();
2047            }
2048            config.gate_trigger = match gate_trigger {
2049                AdvisorTriggerConfig::NoToolCall => GateTrigger::NoToolCall,
2050                AdvisorTriggerConfig::Pattern => {
2051                    GateTrigger::Pattern(gate_trigger_pattern.clone().unwrap_or_default())
2052                }
2053            };
2054            config.max_reviews = *max_reviews;
2055            config.gate_stall_turns = *gate_stall_turns;
2056            config.gate_min_tool_results = *gate_min_tool_results;
2057            config.advisor_max_tokens = *advisor_max_tokens;
2058            config.advisor_temperature = *advisor_temperature;
2059            config.transcript_max_chars = *transcript_max_chars;
2060            config.fail_open = *fail_open;
2061            let algorithm = AdvisorGate::new(config).map_err(|error| {
2062                AlgorithmConfigError::with_source(
2063                    format!("advisor route {route_name}: {error}"),
2064                    error,
2065                )
2066            })?;
2067            Ok(Arc::new(algorithm))
2068        }
2069        AlgorithmSpec::Vgr { config } => build_vgr(route_name, config, targets),
2070        AlgorithmSpec::PrefillRouter {
2071            targets: names,
2072            checkpoint,
2073            device,
2074            cache_dir,
2075            max_length,
2076            batch_size,
2077        } => {
2078            #[cfg(feature = "prefill-router")]
2079            {
2080                let targets = names
2081                    .iter()
2082                    .map(|name| resolve_target_model_id(route_name, name, targets))
2083                    .collect::<AlgorithmResult<Vec<_>>>()?;
2084                let mut config = prefill_router::PrefillRouterConfig::new(targets, checkpoint);
2085                config.device.clone_from(device);
2086                config.cache_dir.clone_from(cache_dir);
2087                if let Some(max_length) = max_length {
2088                    config.max_length = *max_length;
2089                }
2090                if let Some(batch_size) = batch_size {
2091                    config.batch_size = *batch_size;
2092                }
2093                let algorithm = config.build().map_err(|error| {
2094                    AlgorithmConfigError::with_source(
2095                        format!("prefill_router route {route_name}: {error}"),
2096                        error,
2097                    )
2098                })?;
2099                Ok(Arc::new(algorithm))
2100            }
2101
2102            #[cfg(not(feature = "prefill-router"))]
2103            {
2104                let _ = (
2105                    names, checkpoint, device, cache_dir, max_length, batch_size, targets,
2106                );
2107                Err(AlgorithmConfigError::new(format!(
2108                    "prefill_router route {route_name} requires the `prefill-router` Cargo feature"
2109                )))
2110            }
2111        }
2112    }
2113}
2114
2115fn build_vgr(
2116    route_name: &str,
2117    config: &VgrRouteConfig,
2118    targets: &BTreeMap<String, ModelId>,
2119) -> AlgorithmResult<Arc<dyn Algorithm>> {
2120    let efficient = resolve_target_model_id(route_name, &config.efficient_target, targets)?;
2121    let capable = resolve_target_model_id(route_name, &config.capable_target, targets)?;
2122    let optional = |name: &Option<String>| {
2123        name.as_deref()
2124            .map(|name| resolve_target_model_id(route_name, name, targets))
2125            .transpose()
2126    };
2127    let mut runtime = VgrConfig::new(efficient, capable);
2128    runtime.targets.judge = optional(&config.judge_target)?;
2129    runtime.targets.capable_judge = optional(&config.capable_judge_target)?;
2130    runtime.mode = match config.mode {
2131        VgrModeConfig::Off => libsy::ServingMode::Off,
2132        VgrModeConfig::Evaluate => libsy::ServingMode::Evaluate,
2133        VgrModeConfig::Shadow => libsy::ServingMode::Shadow,
2134        VgrModeConfig::Active => libsy::ServingMode::Active {
2135            approval: config.active_approval.clone().unwrap_or_default(),
2136        },
2137    };
2138    runtime.deadline = vgr_duration(route_name, "deadline_seconds", config.deadline_seconds)?;
2139    runtime.bounded_efficient = config.bounded_efficient;
2140    runtime.task_typing = config.task_typing;
2141    runtime.confirmed_recovery_min_clean_tail = config.confirmed_recovery_min_clean_tail;
2142    runtime.agentic_handoff = config.agentic_handoff;
2143    runtime.compact_handoff = config.compact_handoff;
2144    runtime.latch_escalation = config.latch_escalation;
2145    runtime.efficient_turn_budget = config
2146        .efficient_turn_budget_seconds
2147        .map(|seconds| vgr_duration(route_name, "efficient_turn_budget_seconds", seconds))
2148        .transpose()?;
2149    runtime.breaker = BreakerConfig {
2150        threshold: config.breaker_threshold,
2151        cooldown: vgr_duration(
2152            route_name,
2153            "breaker_cooldown_seconds",
2154            config.breaker_cooldown_seconds,
2155        )?,
2156    };
2157    Vgr::new(runtime)
2158        .map(|algorithm| Arc::new(algorithm) as Arc<dyn Algorithm>)
2159        .map_err(|error| {
2160            AlgorithmConfigError::with_source(format!("vgr route {route_name}: {error}"), error)
2161        })
2162}
2163
2164fn vgr_duration(route: &str, field: &str, seconds: f64) -> AlgorithmResult<std::time::Duration> {
2165    let message = || format!("vgr route {route}: {field} must be a positive number");
2166    let duration = std::time::Duration::try_from_secs_f64(seconds)
2167        .map_err(|error| AlgorithmConfigError::with_source(message(), error))?;
2168    if duration.is_zero() {
2169        return Err(AlgorithmConfigError::new(message()));
2170    }
2171    Ok(duration)
2172}
2173
2174const fn default_max_reviews() -> u32 {
2175    1
2176}
2177
2178const fn default_advisor_max_tokens() -> u64 {
2179    2048
2180}
2181
2182const fn default_transcript_max_chars() -> usize {
2183    200_000
2184}
2185
2186const fn default_fail_open() -> bool {
2187    true
2188}
2189
2190fn classifier_contract(prompt: Option<&str>) -> ClassifierContractConfig {
2191    prompt.map_or_else(ClassifierContractConfig::default, |prompt| {
2192        ClassifierContractConfig::default().with_prompt(prompt)
2193    })
2194}
2195
2196fn default_classifier_max_output_tokens() -> u64 {
2197    LlmCapabilityConfig::default().max_output_tokens
2198}
2199
2200/// Both tiers on one target leave the route nothing to choose between; every decision
2201/// lands on the same model. The file still loads, since a single-model deployment can
2202/// be a deliberate overhead measurement.
2203fn warn_identical_tiers(route_name: &str, capable_target: &str, efficient_target: &str) {
2204    if capable_target == efficient_target {
2205        tracing::warn!(
2206            route = route_name,
2207            target = capable_target,
2208            "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"
2209        );
2210    }
2211}
2212
2213fn warn_single_target_classifier(route_name: &str, models: &CategoryModelConfig) {
2214    if models.routing_names().len() < 2 {
2215        tracing::warn!(
2216            route = route_name,
2217            "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."
2218        );
2219    }
2220}
2221
2222const fn default_vgr_deadline() -> f64 {
2223    30.0
2224}
2225
2226const fn default_true() -> bool {
2227    true
2228}
2229
2230const fn default_vgr_breaker_threshold() -> u32 {
2231    5
2232}
2233
2234const fn default_vgr_breaker_cooldown() -> f64 {
2235    30.0
2236}
2237
2238fn resolve_target_model_id(
2239    route_name: &str,
2240    name: &str,
2241    targets: &BTreeMap<String, ModelId>,
2242) -> AlgorithmResult<ModelId> {
2243    targets.get(name).cloned().ok_or_else(|| {
2244        AlgorithmConfigError::new(format!(
2245            "route {route_name} references unknown target {name}"
2246        ))
2247    })
2248}
2249
2250fn resolve_runtime_models(
2251    route_name: &str,
2252    names: HashMap<Category, Vec<String>>,
2253    targets: &BTreeMap<String, ModelId>,
2254) -> AlgorithmResult<HashMap<Category, Vec<ModelId>>> {
2255    names
2256        .into_iter()
2257        .map(|(category, names)| {
2258            let models = names
2259                .into_iter()
2260                .map(|name| resolve_target_model_id(route_name, &name, targets))
2261                .collect::<AlgorithmResult<Vec<_>>>()?;
2262            Ok((category, models))
2263        })
2264        .collect()
2265}
2266
2267#[cfg(test)]
2268mod tests {
2269    use super::*;
2270
2271    #[test]
2272    fn composite_profile_defaults() -> AlgorithmResult<()> {
2273        let stage: StageTierConfig =
2274            toml::from_str("capable_target = 'strong'\nefficient_target = 'weak'").unwrap();
2275        for (picker, threshold, capable_hold, efficient_hold, prompt) in [
2276            (PickerMode::EfficientFirst, 0.4, 2, 0, None),
2277            (
2278                PickerMode::CapableFirst,
2279                0.0,
2280                0,
2281                -1,
2282                Some(COMPOSITE_PLANNING_PROMPT),
2283            ),
2284        ] {
2285            let config = stage.profile_config(&StageProfileConfig::default(), picker);
2286            assert_eq!(config.mode, picker);
2287            assert_eq!(config.confidence_threshold, threshold);
2288            assert_eq!(config.recent_window, Some(3));
2289            assert!(config.compaction_override);
2290            assert_eq!(config.capable_hold_turns, capable_hold);
2291            assert_eq!(config.efficient_hold_turns, Some(efficient_hold));
2292            assert_eq!(config.capable_system_prompt.as_deref(), prompt);
2293            assert!(config.llm_fallback.is_none());
2294            assert!(config.handoff_notes.is_none());
2295        }
2296        let standalone = StageSettingsConfig::default().stage_config(
2297            &StageSettingsConfig::default(),
2298            PickerMode::CapableFirst,
2299            0.5,
2300        );
2301        assert_eq!(standalone.capable_hold_turns, 2);
2302        assert!(standalone.compaction_override);
2303        assert!(standalone.capable_system_prompt.is_none());
2304        Ok(())
2305    }
2306
2307    #[test]
2308    fn composite_profile_overrides() {
2309        let stage: StageTierConfig = toml::from_str(
2310            r#"
2311capable_target = "strong"
2312efficient_target = "weak"
2313efficient_first_threshold = 0.7
2314capable_first_threshold = 0.2
2315recent_turn_window = 5
2316compaction_override = false
2317capable_hold_turns = 4
2318efficient_hold_turns = 1
2319capable_system_prompt = "Shared plan"
2320"#,
2321        )
2322        .unwrap();
2323        let shared = stage.profile_config(&StageProfileConfig::default(), PickerMode::CapableFirst);
2324        assert_eq!(shared.confidence_threshold, 0.2);
2325        assert_eq!(shared.recent_window, Some(5));
2326        assert!(!shared.compaction_override);
2327        assert_eq!(shared.capable_hold_turns, 4);
2328        assert_eq!(shared.efficient_hold_turns, Some(1));
2329        assert_eq!(shared.capable_system_prompt.as_deref(), Some("Shared plan"));
2330        assert_eq!(
2331            stage
2332                .profile_config(&StageProfileConfig::default(), PickerMode::EfficientFirst)
2333                .confidence_threshold,
2334            0.7
2335        );
2336        let profile: StageProfileConfig = toml::from_str(
2337            r#"
2338confidence_threshold = 0.0
2339recent_turn_window = 2
2340compaction_override = true
2341capable_hold_turns = 0
2342efficient_hold_turns = 0
2343capable_system_prompt = ""
2344"#,
2345        )
2346        .unwrap();
2347        let config = stage.profile_config(&profile, PickerMode::CapableFirst);
2348        assert_eq!(config.confidence_threshold, 0.0);
2349        assert_eq!(config.recent_window, Some(2));
2350        assert!(config.compaction_override);
2351        assert_eq!(config.capable_hold_turns, 0);
2352        assert_eq!(config.efficient_hold_turns, Some(0));
2353        assert_eq!(config.capable_system_prompt.as_deref(), Some(""));
2354    }
2355
2356    #[test]
2357    fn composite_decision_defaults_and_overrides() -> AlgorithmResult<()> {
2358        for (settings, cutoff, fail_open) in [
2359            ("[decision]", 0.92, true),
2360            ("fail_open = false\n[decision]\ncutoff = 0.7", 0.7, false),
2361        ] {
2362            let classifier: StageClassifierConfig = toml::from_str(&format!(
2363                "target = 'judge'\nclassify_trigger = 'new_session'\n{settings}"
2364            ))
2365            .unwrap();
2366            let CompositeJudgeConfig::Capability(config) =
2367                classifier.composite_judge_config("route", "strong", "weak")?
2368            else {
2369                panic!()
2370            };
2371            assert_eq!(config.classify_trigger, ClassifyTrigger::NewSession);
2372            assert_eq!(config.fail_open, fail_open);
2373            let CapabilityJudgeConfig::Decision(judge) = config.judge else {
2374                panic!()
2375            };
2376            assert_eq!(judge.cutoff, Some(cutoff));
2377        }
2378        Ok(())
2379    }
2380
2381    #[test]
2382    fn restricted_random_preserves_weight_positions() -> AlgorithmResult<()> {
2383        let allowed = BTreeSet::from(["private_a", "private_b"]);
2384        let mut algorithm = AlgorithmSpec::Random {
2385            targets: ["public_a", "private_a", "public_b", "private_b"]
2386                .map(String::from)
2387                .to_vec(),
2388            weights: Some(vec![1.0, 2.0, 3.0, 4.0]),
2389            seed: Some(42),
2390        };
2391        let AlgorithmSpec::Random {
2392            targets,
2393            weights,
2394            seed,
2395        } = algorithm.restricted_to(&allowed)?
2396        else {
2397            unreachable!();
2398        };
2399        assert_eq!(targets, ["private_a", "private_b"]);
2400        assert_eq!(weights, Some(vec![2.0, 4.0]));
2401        assert_eq!(seed, Some(42));
2402        if let AlgorithmSpec::Random { weights, .. } = &mut algorithm {
2403            *weights = Some(vec![1.0]);
2404        }
2405        assert!(algorithm.restricted_to(&allowed).is_err());
2406        Ok(())
2407    }
2408
2409    fn custom_classifier() -> LlmClassifierRouteConfig {
2410        LlmClassifierRouteConfig {
2411            mode: Some(ClassifierMode::Custom),
2412            models: Some(CategoryModelConfig(BTreeMap::from([
2413                ("any".into(), vec!["public".into(), "private".into()]),
2414                ("efficient".into(), vec!["public".into(), "private".into()]),
2415                ("judge".into(), vec!["judge".into()]),
2416                ("research".into(), vec!["public".into()]),
2417            ]))),
2418            default_target: Some("efficient".into()),
2419            prompt: Some("Select an available target.".into()),
2420            response_schema: Some(r#"{"type":"object"}"#.into()),
2421            max_output_tokens: default_classifier_max_output_tokens(),
2422            policy: Some(ClassifierPolicyConfig::TargetSelector {
2423                selector: "/target".into(),
2424            }),
2425            ..LlmClassifierRouteConfig::default()
2426        }
2427    }
2428
2429    #[test]
2430    fn restricted_custom_pools_cover_parent_and_subagents() -> AlgorithmResult<()> {
2431        let allowed = BTreeSet::from(["private", "judge"]);
2432        let targets = BTreeMap::from([
2433            ("private".into(), ModelId::from("private")),
2434            ("judge".into(), ModelId::from("judge")),
2435        ]);
2436        let parent = AlgorithmSpec::LlmClassifier {
2437            config: custom_classifier(),
2438        };
2439        let child = AlgorithmSpec::Passthrough {
2440            target: "private".into(),
2441            subagents: Some(SubagentRouteConfig::LlmClassifier(Box::new(
2442                custom_classifier(),
2443            ))),
2444        };
2445        for algorithm in [parent, child] {
2446            let restricted = algorithm.restricted_to(&allowed)?;
2447            let groups = restricted.runtime_model_names("test")?;
2448            let groups = groups.subagent.unwrap_or(groups.parent);
2449            assert_eq!(groups[&Category::Any], ["private"]);
2450            assert_eq!(groups[&Category::Efficient], ["private"]);
2451            assert_eq!(groups[&Category::Judge], ["judge"]);
2452            restricted.build("test", &targets)?;
2453            let missing_judge = algorithm.restricted_to(&BTreeSet::from(["private"]))?;
2454            assert!(missing_judge.build("test", &targets).is_err());
2455        }
2456        Ok(())
2457    }
2458
2459    #[test]
2460    fn restricted_roles_are_not_replaced() {
2461        let stage = AlgorithmSpec::Auto {
2462            capable_target: "private".into(),
2463            efficient_target: "public".into(),
2464        };
2465        assert!(stage.restricted_to(&BTreeSet::from(["private"])).is_err());
2466    }
2467}