Skip to main content

switchyard_runner/
algorithm.rs

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