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