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 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 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 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 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 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 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 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 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 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 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 pub fn judge_target_names(&self) -> Vec<&str> {
1116 let mut names = Vec::new();
1117 match self {
1118 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 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 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 let subagent = subagents
1268 .map(|subagents| subagent_runtime_model_names(subagents, route_name))
1269 .transpose()?;
1270 Ok(RuntimeModelNames { parent, subagent })
1271 }
1272
1273 #[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 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 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
1334struct RuntimeModelNames {
1336 parent: HashMap<Category, Vec<String>>,
1338 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 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 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
1804fn 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 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 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 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
2195fn 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}