use std::fmt;
use std::str::FromStr;
use crate::providers::ProviderName;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AssetSensitivity {
#[default]
Public,
Internal,
Confidential,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum FailureStrategy {
#[default]
Abort,
Retry,
Skip,
Ask,
}
impl fmt::Display for FailureStrategy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Abort => write!(f, "abort"),
Self::Retry => write!(f, "retry"),
Self::Skip => write!(f, "skip"),
Self::Ask => write!(f, "ask"),
}
}
}
impl FromStr for FailureStrategy {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"abort" => Ok(Self::Abort),
"retry" => Ok(Self::Retry),
"skip" => Ok(Self::Skip),
"ask" => Ok(Self::Ask),
other => Err(format!(
"unknown failure strategy '{other}': expected one of abort, retry, skip, ask"
)),
}
}
}
fn default_planner_max_tokens() -> u32 {
4096
}
fn default_aggregator_max_tokens() -> u32 {
4096
}
fn default_deferral_backoff_ms() -> u64 {
100
}
fn default_experiment_max_experiments() -> u32 {
20
}
fn default_experiment_max_wall_time_secs() -> u64 {
3600
}
fn default_experiment_min_improvement() -> f64 {
0.5
}
fn default_experiment_eval_budget_tokens() -> u64 {
100_000
}
fn default_experiment_schedule_cron() -> String {
"0 3 * * *".to_string()
}
fn default_experiment_max_experiments_per_run() -> u32 {
20
}
fn default_experiment_schedule_max_wall_time_secs() -> u64 {
1800
}
fn default_verify_max_tokens() -> u32 {
1024
}
fn default_max_replans() -> u32 {
2
}
fn default_completeness_threshold() -> f32 {
0.7
}
fn default_cascade_failure_threshold() -> f32 {
0.5
}
fn default_cascade_chain_threshold() -> usize {
3
}
fn default_lineage_ttl_secs() -> u64 {
300
}
fn default_max_predicate_replans() -> u32 {
2
}
fn default_predicate_timeout_secs() -> u64 {
30
}
fn default_persistence_enabled() -> bool {
true
}
fn default_aggregator_timeout_secs() -> u64 {
60
}
fn default_planner_timeout_secs() -> u64 {
120
}
fn default_verifier_timeout_secs() -> u64 {
120
}
fn default_ensemble_ema_alpha() -> f64 {
0.3
}
fn default_ensemble_ema_decay() -> f64 {
0.95
}
fn default_ensemble_min_observations() -> u32 {
5
}
fn default_plan_cache_similarity_threshold() -> f32 {
0.90
}
fn default_plan_cache_ttl_days() -> u32 {
30
}
fn default_plan_cache_max_templates() -> u32 {
100
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct PlanCacheConfig {
pub enabled: bool,
#[serde(default = "default_plan_cache_similarity_threshold")]
pub similarity_threshold: f32,
#[serde(default = "default_plan_cache_ttl_days")]
pub ttl_days: u32,
#[serde(default = "default_plan_cache_max_templates")]
pub max_templates: u32,
}
impl Default for PlanCacheConfig {
fn default() -> Self {
Self {
enabled: false,
similarity_threshold: default_plan_cache_similarity_threshold(),
ttl_days: default_plan_cache_ttl_days(),
max_templates: default_plan_cache_max_templates(),
}
}
}
impl PlanCacheConfig {
#[must_use = "validation result must be checked"]
pub fn validate(&self) -> Result<(), String> {
if !(0.5..=1.0).contains(&self.similarity_threshold) {
return Err(format!(
"plan_cache.similarity_threshold must be in [0.5, 1.0], got {}",
self.similarity_threshold
));
}
if self.max_templates == 0 || self.max_templates > 10_000 {
return Err(format!(
"plan_cache.max_templates must be in [1, 10000], got {}",
self.max_templates
));
}
if self.ttl_days == 0 || self.ttl_days > 365 {
return Err(format!(
"plan_cache.ttl_days must be in [1, 365], got {}",
self.ttl_days
));
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct EnsembleConfig {
pub enabled: bool,
pub verify: bool,
pub members: Vec<String>,
#[serde(default = "default_ensemble_ema_alpha")]
pub ema_alpha: f64,
#[serde(default = "default_ensemble_ema_decay")]
pub ema_decay: f64,
#[serde(default = "default_ensemble_min_observations")]
pub min_observations: u32,
#[serde(default)]
pub member_timeout_secs: u64,
}
impl Default for EnsembleConfig {
fn default() -> Self {
Self {
enabled: false,
verify: false,
members: Vec::new(),
ema_alpha: default_ensemble_ema_alpha(),
ema_decay: default_ensemble_ema_decay(),
min_observations: default_ensemble_min_observations(),
member_timeout_secs: 0,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct CommandConfig {
pub enabled: bool,
#[serde(default = "default_command_max_handoffs")]
pub max_handoffs: u32,
}
impl Default for CommandConfig {
fn default() -> Self {
Self {
enabled: false,
max_handoffs: default_command_max_handoffs(),
}
}
}
fn default_command_max_handoffs() -> u32 {
16
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
#[allow(clippy::struct_excessive_bools)] pub struct OrchestrationConfig {
pub enabled: bool,
pub max_tasks: u32,
pub max_parallel: u32,
#[serde(default)]
pub default_failure_strategy: FailureStrategy,
pub default_max_retries: u32,
pub task_timeout_secs: u64,
#[serde(default)]
pub planner_provider: ProviderName,
#[serde(default = "default_planner_max_tokens")]
pub planner_max_tokens: u32,
pub dependency_context_budget: usize,
pub confirm_before_execute: bool,
#[serde(default = "default_aggregator_max_tokens")]
pub aggregator_max_tokens: u32,
#[serde(default = "default_deferral_backoff_ms")]
pub deferral_backoff_ms: u64,
#[serde(default)]
pub plan_cache: PlanCacheConfig,
#[serde(default)]
pub topology_selection: bool,
#[serde(default)]
pub verify_provider: ProviderName,
#[serde(default = "default_verify_max_tokens")]
pub verify_max_tokens: u32,
#[serde(default = "default_max_replans")]
pub max_replans: u32,
#[serde(default)]
pub verify_completeness: bool,
#[serde(default)]
pub tool_provider: ProviderName,
#[serde(default = "default_completeness_threshold")]
pub completeness_threshold: f32,
#[serde(default)]
pub cascade_routing: bool,
#[serde(default = "default_cascade_failure_threshold")]
pub cascade_failure_threshold: f32,
#[serde(default)]
pub tree_optimized_dispatch: bool,
#[serde(default)]
pub adaptorch: AdaptOrchConfig,
#[serde(default = "default_cascade_chain_threshold")]
pub cascade_chain_threshold: usize,
#[serde(default)]
pub cascade_failure_rate_abort_threshold: f32,
#[serde(default = "default_lineage_ttl_secs")]
pub lineage_ttl_secs: u64,
#[serde(default)]
pub verify_predicate_enabled: bool,
#[serde(default)]
pub predicate_provider: ProviderName,
#[serde(default = "default_max_predicate_replans")]
pub max_predicate_replans: u32,
#[serde(default = "default_predicate_timeout_secs")]
pub predicate_timeout_secs: u64,
#[serde(default = "default_persistence_enabled")]
pub persistence_enabled: bool,
#[serde(default)]
pub orchestrator_provider: ProviderName,
#[serde(default)]
pub default_task_budget_cents: f64,
#[serde(default)]
pub default_asset_sensitivity: AssetSensitivity,
#[serde(default = "default_aggregator_timeout_secs")]
pub aggregator_timeout_secs: u64,
#[serde(default = "default_planner_timeout_secs")]
pub planner_timeout_secs: u64,
#[serde(default = "default_verifier_timeout_secs")]
pub verifier_timeout_secs: u64,
#[serde(default)]
pub whole_plan_verifier_timeout_secs: u64,
#[serde(default)]
pub ensemble: EnsembleConfig,
#[serde(default)]
pub default_idle_timeout_secs: Option<u64>,
#[serde(default)]
pub command: CommandConfig,
}
impl Default for OrchestrationConfig {
fn default() -> Self {
Self {
enabled: false,
max_tasks: 20,
max_parallel: 4,
default_failure_strategy: FailureStrategy::default(),
default_max_retries: 3,
task_timeout_secs: 300,
planner_provider: ProviderName::default(),
planner_max_tokens: default_planner_max_tokens(),
dependency_context_budget: 16384,
confirm_before_execute: true,
aggregator_max_tokens: default_aggregator_max_tokens(),
deferral_backoff_ms: default_deferral_backoff_ms(),
plan_cache: PlanCacheConfig::default(),
topology_selection: false,
verify_provider: ProviderName::default(),
verify_max_tokens: default_verify_max_tokens(),
max_replans: default_max_replans(),
verify_completeness: false,
completeness_threshold: default_completeness_threshold(),
tool_provider: ProviderName::default(),
cascade_routing: false,
cascade_failure_threshold: default_cascade_failure_threshold(),
tree_optimized_dispatch: false,
adaptorch: AdaptOrchConfig::default(),
cascade_chain_threshold: default_cascade_chain_threshold(),
cascade_failure_rate_abort_threshold: 0.0,
lineage_ttl_secs: default_lineage_ttl_secs(),
verify_predicate_enabled: false,
predicate_provider: ProviderName::default(),
max_predicate_replans: default_max_predicate_replans(),
predicate_timeout_secs: default_predicate_timeout_secs(),
persistence_enabled: default_persistence_enabled(),
orchestrator_provider: ProviderName::default(),
default_task_budget_cents: 0.0,
default_asset_sensitivity: AssetSensitivity::default(),
aggregator_timeout_secs: default_aggregator_timeout_secs(),
planner_timeout_secs: default_planner_timeout_secs(),
verifier_timeout_secs: default_verifier_timeout_secs(),
whole_plan_verifier_timeout_secs: 0,
ensemble: EnsembleConfig::default(),
default_idle_timeout_secs: None,
command: CommandConfig::default(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ExperimentConfig {
pub enabled: bool,
#[serde(default)]
pub eval_provider: ProviderName,
pub benchmark_file: Option<std::path::PathBuf>,
#[serde(default = "default_experiment_max_experiments")]
pub max_experiments: u32,
#[serde(default = "default_experiment_max_wall_time_secs")]
pub max_wall_time_secs: u64,
#[serde(default = "default_experiment_min_improvement")]
pub min_improvement: f64,
#[serde(default = "default_experiment_eval_budget_tokens")]
pub eval_budget_tokens: u64,
pub auto_apply: bool,
#[serde(default)]
pub schedule: ExperimentSchedule,
#[serde(default)]
pub tolerate_subject_errors: bool,
}
impl Default for ExperimentConfig {
fn default() -> Self {
Self {
enabled: false,
eval_provider: ProviderName::default(),
benchmark_file: None,
max_experiments: default_experiment_max_experiments(),
max_wall_time_secs: default_experiment_max_wall_time_secs(),
min_improvement: default_experiment_min_improvement(),
eval_budget_tokens: default_experiment_eval_budget_tokens(),
auto_apply: false,
schedule: ExperimentSchedule::default(),
tolerate_subject_errors: false,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct AdaptOrchConfig {
pub enabled: bool,
pub topology_provider: ProviderName,
#[serde(default = "default_classify_timeout_secs")]
pub classify_timeout_secs: u64,
#[serde(default)]
pub state_path: String,
#[serde(default = "default_max_classify_tokens")]
pub max_classify_tokens: u32,
}
fn default_classify_timeout_secs() -> u64 {
4
}
fn default_max_classify_tokens() -> u32 {
80
}
impl Default for AdaptOrchConfig {
fn default() -> Self {
Self {
enabled: false,
topology_provider: ProviderName::default(),
classify_timeout_secs: default_classify_timeout_secs(),
state_path: String::new(),
max_classify_tokens: default_max_classify_tokens(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ExperimentSchedule {
pub enabled: bool,
#[serde(default = "default_experiment_schedule_cron")]
pub cron: String,
#[serde(default = "default_experiment_max_experiments_per_run")]
pub max_experiments_per_run: u32,
#[serde(default = "default_experiment_schedule_max_wall_time_secs")]
pub max_wall_time_secs: u64,
}
impl Default for ExperimentSchedule {
fn default() -> Self {
Self {
enabled: false,
cron: default_experiment_schedule_cron(),
max_experiments_per_run: default_experiment_max_experiments_per_run(),
max_wall_time_secs: default_experiment_schedule_max_wall_time_secs(),
}
}
}
impl ExperimentConfig {
#[must_use = "validation result must be checked"]
pub fn validate(&self) -> Result<(), String> {
if !(1..=1_000).contains(&self.max_experiments) {
return Err(format!(
"experiments.max_experiments must be in 1..=1000, got {}",
self.max_experiments
));
}
if !(60..=86_400).contains(&self.max_wall_time_secs) {
return Err(format!(
"experiments.max_wall_time_secs must be in 60..=86400, got {}",
self.max_wall_time_secs
));
}
if !(1_000..=10_000_000).contains(&self.eval_budget_tokens) {
return Err(format!(
"experiments.eval_budget_tokens must be in 1000..=10000000, got {}",
self.eval_budget_tokens
));
}
if !(0.0..=100.0).contains(&self.min_improvement) {
return Err(format!(
"experiments.min_improvement must be in 0.0..=100.0, got {}",
self.min_improvement
));
}
if !(1..=100).contains(&self.schedule.max_experiments_per_run) {
return Err(format!(
"experiments.schedule.max_experiments_per_run must be in 1..=100, got {}",
self.schedule.max_experiments_per_run
));
}
if !(60..=86_400).contains(&self.schedule.max_wall_time_secs) {
return Err(format!(
"experiments.schedule.max_wall_time_secs must be in 60..=86400, got {}",
self.schedule.max_wall_time_secs
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plan_cache_similarity_threshold_above_one_is_rejected() {
let cfg = PlanCacheConfig {
similarity_threshold: 1.1,
..PlanCacheConfig::default()
};
let result = cfg.validate();
assert!(
result.is_err(),
"similarity_threshold = 1.1 must return a validation error"
);
}
#[test]
fn completeness_threshold_default_is_0_7() {
let cfg = OrchestrationConfig::default();
assert!(
(cfg.completeness_threshold - 0.7).abs() < f32::EPSILON,
"completeness_threshold default must be 0.7, got {}",
cfg.completeness_threshold
);
}
#[test]
fn completeness_threshold_serde_round_trip() {
let toml_in = r"
enabled = true
completeness_threshold = 0.85
";
let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
assert!((cfg.completeness_threshold - 0.85).abs() < f32::EPSILON);
let serialized = toml::to_string(&cfg).expect("serialize");
let cfg2: OrchestrationConfig = toml::from_str(&serialized).expect("re-deserialize");
assert!((cfg2.completeness_threshold - 0.85).abs() < f32::EPSILON);
}
#[test]
fn completeness_threshold_missing_uses_default() {
let toml_in = "enabled = true\n";
let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
assert!(
(cfg.completeness_threshold - 0.7).abs() < f32::EPSILON,
"missing field must use default 0.7, got {}",
cfg.completeness_threshold
);
}
#[test]
fn ensemble_config_default_is_disabled() {
let cfg = EnsembleConfig::default();
assert!(!cfg.enabled);
assert!(!cfg.verify);
assert!(cfg.members.is_empty());
assert!((cfg.ema_alpha - 0.3).abs() < f64::EPSILON);
assert!((cfg.ema_decay - 0.95).abs() < f64::EPSILON);
assert_eq!(cfg.min_observations, 5);
assert_eq!(cfg.member_timeout_secs, 0);
}
#[test]
fn orchestration_config_ensemble_is_disabled_by_default() {
assert!(!OrchestrationConfig::default().ensemble.enabled);
}
#[test]
fn ensemble_config_serde_round_trip() {
let toml_in = r#"
enabled = true
verify = true
members = ["fast", "quality", "cheap"]
ema_alpha = 0.4
ema_decay = 0.9
min_observations = 10
member_timeout_secs = 15
"#;
let cfg: EnsembleConfig = toml::from_str(toml_in).expect("deserialize");
assert!(cfg.enabled);
assert!(cfg.verify);
assert_eq!(cfg.members, vec!["fast", "quality", "cheap"]);
assert!((cfg.ema_alpha - 0.4).abs() < f64::EPSILON);
let serialized = toml::to_string(&cfg).expect("serialize");
let cfg2: EnsembleConfig = toml::from_str(&serialized).expect("re-deserialize");
assert_eq!(cfg2.members, cfg.members);
assert_eq!(cfg2.member_timeout_secs, 15);
}
#[test]
fn ensemble_config_missing_section_uses_defaults() {
let toml_in = "enabled = true\n";
let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
assert!(!cfg.ensemble.enabled);
assert!(cfg.ensemble.members.is_empty());
}
#[test]
fn asset_sensitivity_default_is_public() {
assert_eq!(AssetSensitivity::default(), AssetSensitivity::Public);
}
#[test]
fn asset_sensitivity_serde_snake_case() {
assert_eq!(
serde_json::to_string(&AssetSensitivity::Public).unwrap(),
"\"public\""
);
assert_eq!(
serde_json::to_string(&AssetSensitivity::Confidential).unwrap(),
"\"confidential\""
);
let v: AssetSensitivity = serde_json::from_str("\"internal\"").unwrap();
assert_eq!(v, AssetSensitivity::Internal);
}
#[test]
fn orchestration_config_default_asset_sensitivity_is_public() {
let cfg = OrchestrationConfig::default();
assert_eq!(cfg.default_asset_sensitivity, AssetSensitivity::Public);
}
#[test]
fn orchestration_config_asset_sensitivity_toml_roundtrip() {
let toml_in = "enabled = true\ndefault_asset_sensitivity = \"confidential\"\n";
let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
assert_eq!(
cfg.default_asset_sensitivity,
AssetSensitivity::Confidential
);
let serialized = toml::to_string(&cfg).expect("serialize");
let cfg2: OrchestrationConfig = toml::from_str(&serialized).expect("re-deserialize");
assert_eq!(
cfg2.default_asset_sensitivity,
AssetSensitivity::Confidential
);
}
#[test]
fn orchestration_config_missing_asset_sensitivity_uses_default() {
let toml_in = "enabled = true\n";
let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
assert_eq!(cfg.default_asset_sensitivity, AssetSensitivity::Public);
}
#[test]
fn orchestration_config_default_idle_timeout_secs_is_none() {
let cfg = OrchestrationConfig::default();
assert_eq!(cfg.default_idle_timeout_secs, None);
}
#[test]
fn orchestration_config_idle_timeout_secs_toml_roundtrip() {
let toml_in = "enabled = true\ndefault_idle_timeout_secs = 60\n";
let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
assert_eq!(cfg.default_idle_timeout_secs, Some(60));
let serialized = toml::to_string(&cfg).expect("serialize");
let cfg2: OrchestrationConfig = toml::from_str(&serialized).expect("re-deserialize");
assert_eq!(cfg2.default_idle_timeout_secs, Some(60));
}
#[test]
fn orchestration_config_missing_idle_timeout_secs_migrates_to_none() {
let toml_in = "enabled = true\n";
let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
assert_eq!(cfg.default_idle_timeout_secs, None);
}
#[test]
fn orchestration_config_default_verifier_timeout_secs_is_120() {
assert_eq!(OrchestrationConfig::default().verifier_timeout_secs, 120);
}
#[test]
fn orchestration_config_default_whole_plan_verifier_timeout_secs_is_0() {
assert_eq!(
OrchestrationConfig::default().whole_plan_verifier_timeout_secs,
0
);
}
#[test]
fn orchestration_config_whole_plan_verifier_timeout_secs_toml_roundtrip() {
let toml_in = "enabled = true\nwhole_plan_verifier_timeout_secs = 300\n";
let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
assert_eq!(cfg.whole_plan_verifier_timeout_secs, 300);
let serialized = toml::to_string(&cfg).expect("serialize");
let cfg2: OrchestrationConfig = toml::from_str(&serialized).expect("re-deserialize");
assert_eq!(cfg2.whole_plan_verifier_timeout_secs, 300);
}
}