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