Skip to main content

zeph_config/
experiment.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::fmt;
5use std::str::FromStr;
6
7use crate::providers::ProviderName;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11/// Sensitivity level of an asset accessed by an orchestrated task.
12///
13/// Set per-task on `TaskNode::asset_sensitivity` and graph-wide via
14/// [`OrchestrationConfig::default_asset_sensitivity`].  In the current
15/// implementation this is **advisory only** — the dispatcher does not yet
16/// auto-restrict the tool allow-list based on this field.
17/// See `specs/069-threat-model/spec.md §5` for enforcement caveats.
18///
19/// # Examples
20///
21/// ```rust
22/// use zeph_config::AssetSensitivity;
23///
24/// assert_eq!(AssetSensitivity::default(), AssetSensitivity::Public);
25/// let s: AssetSensitivity = serde_json::from_str("\"confidential\"").unwrap();
26/// assert_eq!(s, AssetSensitivity::Confidential);
27/// ```
28#[non_exhaustive]
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
30#[serde(rename_all = "snake_case")]
31pub enum AssetSensitivity {
32    /// No sensitive assets accessed (default).
33    #[default]
34    Public,
35    /// Sensitive but not secret: user data, conversation history, semantic memory.
36    Internal,
37    /// Highly sensitive: vault keys, API credentials, private tokens.
38    Confidential,
39}
40
41/// Strategy applied when a task in the orchestration graph fails.
42///
43/// Set at the graph level via [`OrchestrationConfig::default_failure_strategy`] and overridden
44/// per-task in the task node. Variants map directly to the `serde` lowercase string form used in
45/// TOML config and LLM-produced JSON plans.
46///
47/// # Examples
48///
49/// ```rust
50/// use zeph_config::FailureStrategy;
51///
52/// assert_eq!(FailureStrategy::default(), FailureStrategy::Abort);
53///
54/// let s: FailureStrategy = serde_json::from_str("\"skip\"").unwrap();
55/// assert_eq!(s, FailureStrategy::Skip);
56/// ```
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
58#[serde(rename_all = "snake_case")]
59#[non_exhaustive]
60pub enum FailureStrategy {
61    /// Abort the entire graph and cancel all running tasks.
62    #[default]
63    Abort,
64    /// Retry the task up to the configured `max_retries` limit, then abort.
65    Retry,
66    /// Skip the failed task and transitively skip all its dependents.
67    Skip,
68    /// Pause the graph and wait for user intervention.
69    Ask,
70}
71
72impl fmt::Display for FailureStrategy {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        match self {
75            Self::Abort => write!(f, "abort"),
76            Self::Retry => write!(f, "retry"),
77            Self::Skip => write!(f, "skip"),
78            Self::Ask => write!(f, "ask"),
79        }
80    }
81}
82
83impl FromStr for FailureStrategy {
84    type Err = String;
85
86    fn from_str(s: &str) -> Result<Self, Self::Err> {
87        match s {
88            "abort" => Ok(Self::Abort),
89            "retry" => Ok(Self::Retry),
90            "skip" => Ok(Self::Skip),
91            "ask" => Ok(Self::Ask),
92            other => Err(format!(
93                "unknown failure strategy '{other}': expected one of abort, retry, skip, ask"
94            )),
95        }
96    }
97}
98
99fn default_planner_max_tokens() -> u32 {
100    4096
101}
102
103fn default_aggregator_max_tokens() -> u32 {
104    4096
105}
106
107fn default_deferral_backoff_ms() -> u64 {
108    100
109}
110
111fn default_experiment_max_experiments() -> u32 {
112    20
113}
114
115fn default_experiment_max_wall_time_secs() -> u64 {
116    3600
117}
118
119fn default_experiment_min_improvement() -> f64 {
120    0.5
121}
122
123fn default_experiment_eval_budget_tokens() -> u64 {
124    100_000
125}
126
127fn default_experiment_schedule_cron() -> String {
128    "0 3 * * *".to_string()
129}
130
131fn default_experiment_max_experiments_per_run() -> u32 {
132    20
133}
134
135fn default_experiment_schedule_max_wall_time_secs() -> u64 {
136    1800
137}
138
139fn default_verify_max_tokens() -> u32 {
140    1024
141}
142
143fn default_max_replans() -> u32 {
144    2
145}
146
147fn default_completeness_threshold() -> f32 {
148    0.7
149}
150
151fn default_cascade_failure_threshold() -> f32 {
152    0.5
153}
154
155fn default_cascade_chain_threshold() -> usize {
156    3
157}
158
159fn default_lineage_ttl_secs() -> u64 {
160    300
161}
162
163fn default_max_predicate_replans() -> u32 {
164    2
165}
166
167fn default_predicate_timeout_secs() -> u64 {
168    30
169}
170
171fn default_persistence_enabled() -> bool {
172    true
173}
174
175fn default_aggregator_timeout_secs() -> u64 {
176    60
177}
178
179fn default_planner_timeout_secs() -> u64 {
180    120
181}
182
183fn default_verifier_timeout_secs() -> u64 {
184    120
185}
186
187fn default_ensemble_ema_alpha() -> f64 {
188    0.3
189}
190
191fn default_ensemble_ema_decay() -> f64 {
192    0.95
193}
194
195fn default_ensemble_min_observations() -> u32 {
196    5
197}
198
199fn default_plan_cache_similarity_threshold() -> f32 {
200    0.90
201}
202
203fn default_plan_cache_ttl_days() -> u32 {
204    30
205}
206
207fn default_plan_cache_max_templates() -> u32 {
208    100
209}
210
211/// Configuration for plan template caching (`[orchestration.plan_cache]` TOML section).
212#[derive(Debug, Clone, Deserialize, Serialize)]
213#[serde(default)]
214pub struct PlanCacheConfig {
215    /// Enable plan template caching. Default: false.
216    pub enabled: bool,
217    /// Minimum cosine similarity to consider a cached template a match. Default: 0.90.
218    #[serde(default = "default_plan_cache_similarity_threshold")]
219    pub similarity_threshold: f32,
220    /// Days since last access before a template is evicted. Default: 30.
221    #[serde(default = "default_plan_cache_ttl_days")]
222    pub ttl_days: u32,
223    /// Maximum number of cached templates. Default: 100.
224    #[serde(default = "default_plan_cache_max_templates")]
225    pub max_templates: u32,
226}
227
228impl Default for PlanCacheConfig {
229    fn default() -> Self {
230        Self {
231            enabled: false,
232            similarity_threshold: default_plan_cache_similarity_threshold(),
233            ttl_days: default_plan_cache_ttl_days(),
234            max_templates: default_plan_cache_max_templates(),
235        }
236    }
237}
238
239impl PlanCacheConfig {
240    /// Validate that all fields are within sane operating limits.
241    ///
242    /// # Errors
243    ///
244    /// Returns a description string if any field is outside the allowed range.
245    #[must_use = "validation result must be checked"]
246    pub fn validate(&self) -> Result<(), String> {
247        if !(0.5..=1.0).contains(&self.similarity_threshold) {
248            return Err(format!(
249                "plan_cache.similarity_threshold must be in [0.5, 1.0], got {}",
250                self.similarity_threshold
251            ));
252        }
253        if self.max_templates == 0 || self.max_templates > 10_000 {
254            return Err(format!(
255                "plan_cache.max_templates must be in [1, 10000], got {}",
256                self.max_templates
257            ));
258        }
259        if self.ttl_days == 0 || self.ttl_days > 365 {
260            return Err(format!(
261                "plan_cache.ttl_days must be in [1, 365], got {}",
262                self.ttl_days
263            ));
264        }
265        Ok(())
266    }
267}
268
269/// Configuration for ORCH-style deterministic verifier ensemble-merge
270/// (`[orchestration.ensemble]` TOML section, spec `073-orch-ensemble-merge`).
271///
272/// Opt-in and default-`OFF`: when `enabled = false` (the default), `PlanVerifier::verify()`
273/// behaves byte-for-byte identically to the single-provider path — no ensemble code runs.
274///
275/// `size` and `min_quorum` are intentionally NOT configurable fields — both are always
276/// derived from `members.len()` (`quorum = members.len() / 2 + 1`) so they can never drift
277/// out of sync with the member list.
278#[derive(Debug, Clone, Deserialize, Serialize)]
279#[serde(default)]
280pub struct EnsembleConfig {
281    /// Enable ensemble machinery (member resolution at bootstrap). Default: `false`.
282    ///
283    /// Separate from `verify` so the ensemble can be resolved/warmed without yet being used
284    /// for verification decisions.
285    pub enabled: bool,
286    /// Use the ensemble for `PlanVerifier` per-task verification. Default: `false`.
287    ///
288    /// Requires `enabled = true`. When `false` (even with `enabled = true`), the
289    /// `SchedulerAction::Verify` handler still uses the single-provider `verify_provider`
290    /// path — `verify` is the per-target activation flag.
291    pub verify: bool,
292    /// Provider names from `[[llm.providers]]`, one per ensemble member.
293    ///
294    /// Validated at config load time (when `enabled && verify`) to be odd-length, `>= 3`,
295    /// and free of duplicates — this guarantees a strict majority with no ties by
296    /// construction. Default: empty.
297    pub members: Vec<String>,
298    /// EMA smoothing factor for `EnsembleTracker`'s per-member agreement score, in `[0.0, 1.0]`.
299    /// Higher values weight the most recent observation more heavily. Default: `0.3`.
300    #[serde(default = "default_ensemble_ema_alpha")]
301    pub ema_alpha: f64,
302    /// Decay-toward-neutral-prior factor for `EnsembleTracker`, in `[0.0, 1.0]`. Default: `0.95`.
303    #[serde(default = "default_ensemble_ema_decay")]
304    pub ema_decay: f64,
305    /// Minimum recorded observations before `EnsembleTracker::ema()` returns a score instead
306    /// of `None` (cold-start gate). Default: `5`.
307    #[serde(default = "default_ensemble_min_observations")]
308    pub min_observations: u32,
309    /// Per-member `chat_typed` call timeout in seconds. `0` = fall back to
310    /// `verifier_timeout_secs`. Default: `0`.
311    #[serde(default)]
312    pub member_timeout_secs: u64,
313}
314
315impl Default for EnsembleConfig {
316    fn default() -> Self {
317        Self {
318            enabled: false,
319            verify: false,
320            members: Vec::new(),
321            ema_alpha: default_ensemble_ema_alpha(),
322            ema_decay: default_ensemble_ema_decay(),
323            min_observations: default_ensemble_min_observations(),
324            member_timeout_secs: 0,
325        }
326    }
327}
328
329/// Configuration for Command-style dynamic task handoff
330/// (`[orchestration.command]` TOML section, spec `080-cross-thread-store-dynamic-handoff`,
331/// GitHub #6363).
332///
333/// Opt-in and default-`OFF`: when `enabled = false` (the default), a node's trailing
334/// ` ```zeph-command ` output block is left as ordinary output text and
335/// `TaskOutcome::Handoff` is never produced — zero behavior change (FR-B-001).
336#[derive(Debug, Clone, Deserialize, Serialize)]
337#[serde(default)]
338pub struct CommandConfig {
339    /// Enable node-agent-driven dynamic task handoff via a trailing ` ```zeph-command `
340    /// output block. Default: `false`.
341    pub enabled: bool,
342    /// Per-graph livelock budget: maximum number of `Command.goto` handoffs allowed across
343    /// a single graph run (`dag::try_handoff`'s budget check). Validated `> 0` at config
344    /// load time (FR-B-013) — `0` would make every handoff attempt fail immediately, which
345    /// is indistinguishable from `enabled = false` but without the honest signal. Default:
346    /// `16`.
347    #[serde(default = "default_command_max_handoffs")]
348    pub max_handoffs: u32,
349}
350
351impl Default for CommandConfig {
352    fn default() -> Self {
353        Self {
354            enabled: false,
355            max_handoffs: default_command_max_handoffs(),
356        }
357    }
358}
359
360fn default_command_max_handoffs() -> u32 {
361    16
362}
363
364/// Configuration for the task orchestration subsystem (`[orchestration]` TOML section).
365#[derive(Debug, Clone, Deserialize, Serialize)]
366#[serde(default)]
367#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
368pub struct OrchestrationConfig {
369    /// Enable the orchestration subsystem.
370    pub enabled: bool,
371    /// Maximum number of tasks in a single graph.
372    pub max_tasks: u32,
373    /// Maximum number of tasks that can run in parallel.
374    pub max_parallel: u32,
375    /// Default failure strategy applied to every task graph unless overridden per-task.
376    #[serde(default)]
377    pub default_failure_strategy: FailureStrategy,
378    /// Default number of retries for the `retry` failure strategy.
379    pub default_max_retries: u32,
380    /// Timeout in seconds for a single task. `0` means no timeout.
381    pub task_timeout_secs: u64,
382    /// Provider name from `[[llm.providers]]` for planning LLM calls.
383    /// Empty string = use the agent's primary provider.
384    #[serde(default)]
385    pub planner_provider: ProviderName,
386    /// Maximum tokens budget hint for planner responses. Reserved for future use when
387    /// per-call token limits are added to the `LlmProvider::chat` API.
388    #[serde(default = "default_planner_max_tokens")]
389    pub planner_max_tokens: u32,
390    /// Total character budget for cross-task dependency context injection.
391    pub dependency_context_budget: usize,
392    /// Whether to show a confirmation prompt before executing a plan.
393    pub confirm_before_execute: bool,
394    /// Maximum tokens budget for aggregation LLM calls. Default: 4096.
395    #[serde(default = "default_aggregator_max_tokens")]
396    pub aggregator_max_tokens: u32,
397    /// Base backoff for `ConcurrencyLimit` retries; grows exponentially (×2 each attempt) up to 5 s.
398    #[serde(default = "default_deferral_backoff_ms")]
399    pub deferral_backoff_ms: u64,
400    /// Plan template caching configuration.
401    #[serde(default)]
402    pub plan_cache: PlanCacheConfig,
403    /// Enable topology-aware concurrency selection. When true, `TopologyClassifier`
404    /// adjusts `max_parallel` based on the DAG structure. Default: false (opt-in).
405    #[serde(default)]
406    pub topology_selection: bool,
407    /// Provider name from `[[llm.providers]]` for verification LLM calls.
408    /// Empty string = use the agent's primary provider. Should be a cheap/fast provider.
409    #[serde(default)]
410    pub verify_provider: ProviderName,
411    /// Maximum tokens budget for verification LLM calls. Default: 1024.
412    #[serde(default = "default_verify_max_tokens")]
413    pub verify_max_tokens: u32,
414    /// Maximum number of replan cycles per graph execution. Default: 2.
415    ///
416    /// Prevents infinite verify-replan loops. 0 = disable replan (verification still
417    /// runs, gaps are logged only).
418    #[serde(default = "default_max_replans")]
419    pub max_replans: u32,
420    /// Enable post-task completeness verification. Default: false (opt-in).
421    ///
422    /// When true, completed tasks are evaluated by `PlanVerifier`. Task stays
423    /// `Completed` during verification; downstream tasks are unblocked immediately.
424    /// Verification is best-effort and does not gate dispatch.
425    #[serde(default)]
426    pub verify_completeness: bool,
427    /// Provider name from `[[llm.providers]]` for tool-dispatch routing.
428    /// When set, tool-heavy tasks prefer this provider over the primary.
429    /// Prefer mid-tier models (e.g., qwen2.5:14b) for reliability per arXiv:2601.16280.
430    /// Empty string = use the primary provider.
431    #[serde(default)]
432    pub tool_provider: ProviderName,
433    /// Minimum completeness score (0.0–1.0) for the plan to be accepted without
434    /// replanning. Default: 0.7. When the verifier reports `confidence <
435    /// completeness_threshold` AND gaps exist, a replan cycle is triggered.
436    /// Used by both per-task and whole-plan verification.
437    /// Values outside [0.0, 1.0] are rejected at startup by `Config::validate()`.
438    #[serde(default = "default_completeness_threshold")]
439    pub completeness_threshold: f32,
440    /// Enable cascade-aware routing for Mixed-topology DAGs. Requires `topology_selection = true`.
441    /// When enabled, tasks in failing subtrees are deprioritized in favour of healthy branches.
442    /// Default: false (opt-in).
443    #[serde(default)]
444    pub cascade_routing: bool,
445    /// Failure rate threshold (0.0–1.0) above which a DAG region is considered "cascading".
446    /// Must be in (0.0, 1.0]. Default: 0.5.
447    #[serde(default = "default_cascade_failure_threshold")]
448    pub cascade_failure_threshold: f32,
449    /// Enable tree-optimized dispatch for FanOut/FanIn topologies.
450    /// Sorts the ready queue by critical-path distance (deepest tasks first) to minimize
451    /// end-to-end latency. Default: false (opt-in).
452    #[serde(default)]
453    pub tree_optimized_dispatch: bool,
454
455    /// `AdaptOrch` bandit-driven topology advisor. Default: disabled.
456    #[serde(default)]
457    pub adaptorch: AdaptOrchConfig,
458    /// Consecutive-chain cascade abort threshold: number of consecutive `Failed` entries
459    /// in a `depends_on` chain that triggers a DAG abort.
460    ///
461    /// `0` disables linear-chain cascade abort. Default: 3.
462    /// Must not be `1` — a threshold of 1 would abort on every single failure.
463    #[serde(default = "default_cascade_chain_threshold")]
464    pub cascade_chain_threshold: usize,
465    /// Fan-out cascade abort failure-rate threshold (0.0–1.0).
466    ///
467    /// When a DAG region's failure rate reaches this value AND the region has ≥ 3 tasks,
468    /// the DAG is aborted immediately. `0.0` disables this signal (opt-in).
469    /// Recommended production value: `0.7`.
470    #[serde(default)]
471    pub cascade_failure_rate_abort_threshold: f32,
472    /// TTL for lineage entries in seconds. Entries older than this are pruned during
473    /// chain merge. Setting this too low can prevent detection of slow-build cascades.
474    ///
475    /// Default: 300 seconds (5 minutes).
476    #[serde(default = "default_lineage_ttl_secs")]
477    pub lineage_ttl_secs: u64,
478    /// Enable per-subtask predicate verification gate.
479    ///
480    /// Requires `predicate_provider` or a primary LLM provider to be configured.
481    /// Default: false (opt-in).
482    #[serde(default)]
483    pub verify_predicate_enabled: bool,
484    /// Provider name from `[[llm.providers]]` for predicate evaluation.
485    ///
486    /// Empty string = fall back to `verify_provider`, then primary.
487    #[serde(default)]
488    pub predicate_provider: ProviderName,
489    /// Maximum number of predicate-driven task re-runs across the entire DAG.
490    ///
491    /// Independent of `max_replans` (verifier completeness budget). Default: 2.
492    #[serde(default = "default_max_predicate_replans")]
493    pub max_predicate_replans: u32,
494    /// Timeout in seconds for each predicate LLM evaluation call.
495    ///
496    /// On timeout the evaluator returns a fail-open outcome (`passed = true`,
497    /// `confidence = 0.0`) and logs a warning. Default: 30.
498    #[serde(default = "default_predicate_timeout_secs")]
499    pub predicate_timeout_secs: u64,
500    /// Persist task graph state to `SQLite` across scheduler ticks.
501    ///
502    /// When `true` and a `SemanticMemory` store is available, the scheduler
503    /// snapshots the graph once per tick and on plan completion. Graphs can
504    /// then be rehydrated via `/plan resume <id>` after a restart.
505    /// Default: `true`.
506    #[serde(default = "default_persistence_enabled")]
507    pub persistence_enabled: bool,
508    /// Provider name from `[[llm.providers]]` for scheduling-tier LLM calls
509    /// (aggregation, predicate evaluation, verification when no specific provider is set).
510    ///
511    /// Acts as fallback for `verify_provider` and `predicate_provider` when those are empty.
512    /// Does NOT affect `planner_provider` — planning is a complex task and stays on the quality
513    /// provider. Empty string = use the agent's primary provider.
514    ///
515    /// # Trade-off
516    ///
517    /// Setting this to a fast/cheap model reduces aggregation quality because `LlmAggregator`
518    /// produces user-visible output. See CHANGELOG for details.
519    #[serde(default)]
520    pub orchestrator_provider: ProviderName,
521
522    /// Default per-task cost budget in US cents. `0.0` = unlimited (no budget check).
523    ///
524    /// When a sub-agent task completes, the scheduler emits a `tracing::warn!` if the
525    /// task exceeded this budget. In MVP this is **warn-only** — hard enforcement requires
526    /// per-task `CostTracker` scoping, which is deferred post-v1.0.0.
527    ///
528    /// Individual tasks can override this via `TaskNode::token_budget_cents`.
529    /// Default: `0.0` (unlimited).
530    #[serde(default)]
531    pub default_task_budget_cents: f64,
532
533    /// Default asset sensitivity level for task nodes that do not set their own.
534    ///
535    /// Advisory only in the current implementation — the dispatcher does not yet
536    /// auto-restrict tool access based on this field. See `specs/069-threat-model/spec.md §5`.
537    ///
538    /// TOML: `[orchestration] default_asset_sensitivity = "public"`
539    /// Default: `public` (no restriction).
540    #[serde(default)]
541    pub default_asset_sensitivity: AssetSensitivity,
542
543    /// Timeout in seconds for aggregation LLM calls. Default: 60.
544    ///
545    /// On timeout the aggregator falls back to raw concatenation so that a graph
546    /// result is always returned. Set to `0` is rejected by `Config::validate()`.
547    #[serde(default = "default_aggregator_timeout_secs")]
548    pub aggregator_timeout_secs: u64,
549
550    /// Timeout in seconds for planner LLM calls. Default: 120.
551    ///
552    /// On timeout the planner returns `OrchestrationError::PlanningFailed`.
553    /// Planning has no fallback — without a graph no tasks can be dispatched.
554    /// Set to `0` is rejected by `Config::validate()`.
555    #[serde(default = "default_planner_timeout_secs")]
556    pub planner_timeout_secs: u64,
557
558    /// Timeout in seconds for verifier LLM calls (per-task and whole-plan). Default: 120.
559    ///
560    /// On timeout the verifier returns a fail-open result (`complete = true`, no gaps) and
561    /// `ground()` is never called, so the entire tool-call grounding safety net (spec 009,
562    /// #6278/#6287) silently never runs for that verification. Local Ollama models in the
563    /// 20B+ parameter range (e.g. `gemma4:26b`) commonly take 60-120s to respond, so a low
564    /// timeout here causes fail-open to trigger on essentially every verification when
565    /// `verify_provider` targets such a model — see #6366.
566    /// Set to `0` is rejected by `Config::validate()`.
567    #[serde(default = "default_verifier_timeout_secs")]
568    pub verifier_timeout_secs: u64,
569
570    /// Timeout in seconds for the whole-plan `verify_plan()` LLM call. `0` = fall back to
571    /// `verifier_timeout_secs`. Default: `0`.
572    ///
573    /// `verify_plan()` runs once per plan (after all tasks complete) whereas per-task
574    /// `verify()` runs many times per plan — a shared timeout budget forces both to the same
575    /// value even though they are invoked at structurally different points. See #6379.
576    #[serde(default)]
577    pub whole_plan_verifier_timeout_secs: u64,
578
579    /// ORCH-style deterministic verifier ensemble-merge configuration. Default: disabled.
580    /// See `specs/073-orch-ensemble-merge/spec.md`.
581    #[serde(default)]
582    pub ensemble: EnsembleConfig,
583
584    /// Global default idle/no-progress timeout in seconds, used when a `TaskNode`'s own
585    /// `TimeoutPolicy.idle_timeout_secs` is unset. `None` (the default) disables idle
586    /// enforcement — it is opt-in, unlike `task_timeout_secs`.
587    ///
588    /// A task is killed if no progress heartbeat is observed for this many seconds — a
589    /// heartbeat is written once per agent-loop turn boundary, so **this value must be set
590    /// above the longest expected single-turn (single LLM call + its tool calls) duration**,
591    /// or a healthy task performing one long-running tool call can be killed spuriously.
592    /// Only enforced on the normal spawn dispatch path; `RunInline` tasks (no sub-agent
593    /// definitions configured) are exempt. See
594    /// `specs/075-orchestration-node-control-parity/spec.md` §4/FR-005.
595    #[serde(default)]
596    pub default_idle_timeout_secs: Option<u64>,
597
598    /// Command-style dynamic task handoff configuration (spec-080, GitHub #6363). Default:
599    /// disabled. See `specs/080-cross-thread-store-dynamic-handoff/spec.md`.
600    #[serde(default)]
601    pub command: CommandConfig,
602}
603
604impl Default for OrchestrationConfig {
605    fn default() -> Self {
606        Self {
607            enabled: false,
608            max_tasks: 20,
609            max_parallel: 4,
610            default_failure_strategy: FailureStrategy::default(),
611            default_max_retries: 3,
612            task_timeout_secs: 300,
613            planner_provider: ProviderName::default(),
614            planner_max_tokens: default_planner_max_tokens(),
615            dependency_context_budget: 16384,
616            confirm_before_execute: true,
617            aggregator_max_tokens: default_aggregator_max_tokens(),
618            deferral_backoff_ms: default_deferral_backoff_ms(),
619            plan_cache: PlanCacheConfig::default(),
620            topology_selection: false,
621            verify_provider: ProviderName::default(),
622            verify_max_tokens: default_verify_max_tokens(),
623            max_replans: default_max_replans(),
624            verify_completeness: false,
625            completeness_threshold: default_completeness_threshold(),
626            tool_provider: ProviderName::default(),
627            cascade_routing: false,
628            cascade_failure_threshold: default_cascade_failure_threshold(),
629            tree_optimized_dispatch: false,
630            adaptorch: AdaptOrchConfig::default(),
631            cascade_chain_threshold: default_cascade_chain_threshold(),
632            cascade_failure_rate_abort_threshold: 0.0,
633            lineage_ttl_secs: default_lineage_ttl_secs(),
634            verify_predicate_enabled: false,
635            predicate_provider: ProviderName::default(),
636            max_predicate_replans: default_max_predicate_replans(),
637            predicate_timeout_secs: default_predicate_timeout_secs(),
638            persistence_enabled: default_persistence_enabled(),
639            orchestrator_provider: ProviderName::default(),
640            default_task_budget_cents: 0.0,
641            default_asset_sensitivity: AssetSensitivity::default(),
642            aggregator_timeout_secs: default_aggregator_timeout_secs(),
643            planner_timeout_secs: default_planner_timeout_secs(),
644            verifier_timeout_secs: default_verifier_timeout_secs(),
645            whole_plan_verifier_timeout_secs: 0,
646            ensemble: EnsembleConfig::default(),
647            default_idle_timeout_secs: None,
648            command: CommandConfig::default(),
649        }
650    }
651}
652
653/// Configuration for the autonomous self-experimentation engine (`[experiments]` TOML section).
654///
655/// When `enabled = true`, Zeph periodically runs A/B experiments on its own skill and
656/// prompt configurations to find improvements automatically.
657///
658/// # Example (TOML)
659///
660/// ```toml
661/// [experiments]
662/// enabled = false
663/// max_experiments = 20
664/// auto_apply = false
665/// ```
666#[derive(Debug, Clone, Deserialize, Serialize)]
667#[serde(default)]
668pub struct ExperimentConfig {
669    /// Enable autonomous self-experimentation. Default: `false`.
670    pub enabled: bool,
671    /// Provider name (from `[[llm.providers]]`) used as the LLM-as-judge for experiment
672    /// evaluation. An empty value falls back to the primary provider. Prefer a capable,
673    /// low-self-judge-bias model (e.g. a different provider than the one being evaluated).
674    #[serde(default)]
675    pub eval_provider: ProviderName,
676    /// Path to a benchmark JSONL file for evaluating experiments.
677    pub benchmark_file: Option<std::path::PathBuf>,
678    #[serde(default = "default_experiment_max_experiments")]
679    pub max_experiments: u32,
680    #[serde(default = "default_experiment_max_wall_time_secs")]
681    pub max_wall_time_secs: u64,
682    #[serde(default = "default_experiment_min_improvement")]
683    pub min_improvement: f64,
684    #[serde(default = "default_experiment_eval_budget_tokens")]
685    pub eval_budget_tokens: u64,
686    pub auto_apply: bool,
687    #[serde(default)]
688    pub schedule: ExperimentSchedule,
689    /// When `true`, a subject call failure (LLM error or timeout) excludes the case from
690    /// scoring instead of aborting the entire evaluation run.
691    ///
692    /// Default: `false` (preserves existing abort-on-error semantics). Set to `true` when
693    /// running parallel evaluations where a single subject timeout should not discard all
694    /// already-billed responses from other in-flight futures — at the cost of producing a
695    /// partial result rather than a guaranteed complete evaluation.
696    #[serde(default)]
697    pub tolerate_subject_errors: bool,
698}
699
700impl Default for ExperimentConfig {
701    fn default() -> Self {
702        Self {
703            enabled: false,
704            eval_provider: ProviderName::default(),
705            benchmark_file: None,
706            max_experiments: default_experiment_max_experiments(),
707            max_wall_time_secs: default_experiment_max_wall_time_secs(),
708            min_improvement: default_experiment_min_improvement(),
709            eval_budget_tokens: default_experiment_eval_budget_tokens(),
710            auto_apply: false,
711            schedule: ExperimentSchedule::default(),
712            tolerate_subject_errors: false,
713        }
714    }
715}
716
717/// Configuration for `AdaptOrch` — bandit-driven topology advisor (`[orchestration.adaptorch]`).
718///
719/// # Example
720///
721/// ```toml
722/// [orchestration.adaptorch]
723/// enabled = true
724/// topology_provider = "fast"
725/// classify_timeout_secs = 4
726/// state_path = ""
727/// ```
728#[derive(Debug, Clone, Deserialize, Serialize)]
729#[serde(default)]
730pub struct AdaptOrchConfig {
731    /// Enable `AdaptOrch`. When `false`, planning uses the default `plan()` path.
732    pub enabled: bool,
733    /// Provider name from `[[llm.providers]]` for goal classification. Empty → primary provider.
734    pub topology_provider: ProviderName,
735    /// Hard timeout (seconds) for the classification LLM call.
736    #[serde(default = "default_classify_timeout_secs")]
737    pub classify_timeout_secs: u64,
738    /// Path to the persisted Beta-arm JSON state file.
739    /// Empty string → `~/.zeph/adaptorch_state.json` (resolved at runtime).
740    #[serde(default)]
741    pub state_path: String,
742    /// Maximum tokens for the classification LLM call.
743    #[serde(default = "default_max_classify_tokens")]
744    pub max_classify_tokens: u32,
745}
746
747fn default_classify_timeout_secs() -> u64 {
748    4
749}
750
751fn default_max_classify_tokens() -> u32 {
752    80
753}
754
755impl Default for AdaptOrchConfig {
756    fn default() -> Self {
757        Self {
758            enabled: false,
759            topology_provider: ProviderName::default(),
760            classify_timeout_secs: default_classify_timeout_secs(),
761            state_path: String::new(),
762            max_classify_tokens: default_max_classify_tokens(),
763        }
764    }
765}
766
767/// Cron scheduling configuration for automatic experiment runs.
768#[derive(Debug, Clone, Deserialize, Serialize)]
769#[serde(default)]
770pub struct ExperimentSchedule {
771    pub enabled: bool,
772    #[serde(default = "default_experiment_schedule_cron")]
773    pub cron: String,
774    #[serde(default = "default_experiment_max_experiments_per_run")]
775    pub max_experiments_per_run: u32,
776    /// Wall-time cap for a single scheduled experiment session (seconds).
777    ///
778    /// Overrides `experiments.max_wall_time_secs` for scheduled runs. Defaults to 1800s so
779    /// a background session cannot overlap the next cron trigger on typical schedules.
780    #[serde(default = "default_experiment_schedule_max_wall_time_secs")]
781    pub max_wall_time_secs: u64,
782}
783
784impl Default for ExperimentSchedule {
785    fn default() -> Self {
786        Self {
787            enabled: false,
788            cron: default_experiment_schedule_cron(),
789            max_experiments_per_run: default_experiment_max_experiments_per_run(),
790            max_wall_time_secs: default_experiment_schedule_max_wall_time_secs(),
791        }
792    }
793}
794
795impl ExperimentConfig {
796    /// Validate that numeric bounds are within sane operating limits.
797    ///
798    /// # Errors
799    ///
800    /// Returns a description string if any field is outside allowed range.
801    #[must_use = "validation result must be checked"]
802    pub fn validate(&self) -> Result<(), String> {
803        if !(1..=1_000).contains(&self.max_experiments) {
804            return Err(format!(
805                "experiments.max_experiments must be in 1..=1000, got {}",
806                self.max_experiments
807            ));
808        }
809        if !(60..=86_400).contains(&self.max_wall_time_secs) {
810            return Err(format!(
811                "experiments.max_wall_time_secs must be in 60..=86400, got {}",
812                self.max_wall_time_secs
813            ));
814        }
815        if !(1_000..=10_000_000).contains(&self.eval_budget_tokens) {
816            return Err(format!(
817                "experiments.eval_budget_tokens must be in 1000..=10000000, got {}",
818                self.eval_budget_tokens
819            ));
820        }
821        if !(0.0..=100.0).contains(&self.min_improvement) {
822            return Err(format!(
823                "experiments.min_improvement must be in 0.0..=100.0, got {}",
824                self.min_improvement
825            ));
826        }
827        if !(1..=100).contains(&self.schedule.max_experiments_per_run) {
828            return Err(format!(
829                "experiments.schedule.max_experiments_per_run must be in 1..=100, got {}",
830                self.schedule.max_experiments_per_run
831            ));
832        }
833        if !(60..=86_400).contains(&self.schedule.max_wall_time_secs) {
834            return Err(format!(
835                "experiments.schedule.max_wall_time_secs must be in 60..=86400, got {}",
836                self.schedule.max_wall_time_secs
837            ));
838        }
839        Ok(())
840    }
841}
842
843#[cfg(test)]
844mod tests {
845    use super::*;
846
847    #[test]
848    fn plan_cache_similarity_threshold_above_one_is_rejected() {
849        let cfg = PlanCacheConfig {
850            similarity_threshold: 1.1,
851            ..PlanCacheConfig::default()
852        };
853        let result = cfg.validate();
854        assert!(
855            result.is_err(),
856            "similarity_threshold = 1.1 must return a validation error"
857        );
858    }
859
860    #[test]
861    fn completeness_threshold_default_is_0_7() {
862        let cfg = OrchestrationConfig::default();
863        assert!(
864            (cfg.completeness_threshold - 0.7).abs() < f32::EPSILON,
865            "completeness_threshold default must be 0.7, got {}",
866            cfg.completeness_threshold
867        );
868    }
869
870    #[test]
871    fn completeness_threshold_serde_round_trip() {
872        let toml_in = r"
873            enabled = true
874            completeness_threshold = 0.85
875        ";
876        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
877        assert!((cfg.completeness_threshold - 0.85).abs() < f32::EPSILON);
878
879        let serialized = toml::to_string(&cfg).expect("serialize");
880        let cfg2: OrchestrationConfig = toml::from_str(&serialized).expect("re-deserialize");
881        assert!((cfg2.completeness_threshold - 0.85).abs() < f32::EPSILON);
882    }
883
884    #[test]
885    fn completeness_threshold_missing_uses_default() {
886        let toml_in = "enabled = true\n";
887        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
888        assert!(
889            (cfg.completeness_threshold - 0.7).abs() < f32::EPSILON,
890            "missing field must use default 0.7, got {}",
891            cfg.completeness_threshold
892        );
893    }
894
895    #[test]
896    fn ensemble_config_default_is_disabled() {
897        let cfg = EnsembleConfig::default();
898        assert!(!cfg.enabled);
899        assert!(!cfg.verify);
900        assert!(cfg.members.is_empty());
901        assert!((cfg.ema_alpha - 0.3).abs() < f64::EPSILON);
902        assert!((cfg.ema_decay - 0.95).abs() < f64::EPSILON);
903        assert_eq!(cfg.min_observations, 5);
904        assert_eq!(cfg.member_timeout_secs, 0);
905    }
906
907    #[test]
908    fn orchestration_config_ensemble_is_disabled_by_default() {
909        assert!(!OrchestrationConfig::default().ensemble.enabled);
910    }
911
912    #[test]
913    fn ensemble_config_serde_round_trip() {
914        let toml_in = r#"
915            enabled = true
916            verify = true
917            members = ["fast", "quality", "cheap"]
918            ema_alpha = 0.4
919            ema_decay = 0.9
920            min_observations = 10
921            member_timeout_secs = 15
922        "#;
923        let cfg: EnsembleConfig = toml::from_str(toml_in).expect("deserialize");
924        assert!(cfg.enabled);
925        assert!(cfg.verify);
926        assert_eq!(cfg.members, vec!["fast", "quality", "cheap"]);
927        assert!((cfg.ema_alpha - 0.4).abs() < f64::EPSILON);
928
929        let serialized = toml::to_string(&cfg).expect("serialize");
930        let cfg2: EnsembleConfig = toml::from_str(&serialized).expect("re-deserialize");
931        assert_eq!(cfg2.members, cfg.members);
932        assert_eq!(cfg2.member_timeout_secs, 15);
933    }
934
935    #[test]
936    fn ensemble_config_missing_section_uses_defaults() {
937        // OrchestrationConfig has #[serde(default)] on the whole struct, and `ensemble` has
938        // #[serde(default)] too — an existing config with no [orchestration.ensemble] table
939        // at all must parse to the disabled default (no migration step required).
940        let toml_in = "enabled = true\n";
941        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
942        assert!(!cfg.ensemble.enabled);
943        assert!(cfg.ensemble.members.is_empty());
944    }
945
946    #[test]
947    fn asset_sensitivity_default_is_public() {
948        assert_eq!(AssetSensitivity::default(), AssetSensitivity::Public);
949    }
950
951    #[test]
952    fn asset_sensitivity_serde_snake_case() {
953        assert_eq!(
954            serde_json::to_string(&AssetSensitivity::Public).unwrap(),
955            "\"public\""
956        );
957        assert_eq!(
958            serde_json::to_string(&AssetSensitivity::Confidential).unwrap(),
959            "\"confidential\""
960        );
961        let v: AssetSensitivity = serde_json::from_str("\"internal\"").unwrap();
962        assert_eq!(v, AssetSensitivity::Internal);
963    }
964
965    #[test]
966    fn orchestration_config_default_asset_sensitivity_is_public() {
967        let cfg = OrchestrationConfig::default();
968        assert_eq!(cfg.default_asset_sensitivity, AssetSensitivity::Public);
969    }
970
971    #[test]
972    fn orchestration_config_asset_sensitivity_toml_roundtrip() {
973        let toml_in = "enabled = true\ndefault_asset_sensitivity = \"confidential\"\n";
974        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
975        assert_eq!(
976            cfg.default_asset_sensitivity,
977            AssetSensitivity::Confidential
978        );
979        let serialized = toml::to_string(&cfg).expect("serialize");
980        let cfg2: OrchestrationConfig = toml::from_str(&serialized).expect("re-deserialize");
981        assert_eq!(
982            cfg2.default_asset_sensitivity,
983            AssetSensitivity::Confidential
984        );
985    }
986
987    #[test]
988    fn orchestration_config_missing_asset_sensitivity_uses_default() {
989        let toml_in = "enabled = true\n";
990        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
991        assert_eq!(cfg.default_asset_sensitivity, AssetSensitivity::Public);
992    }
993
994    // ── default_idle_timeout_secs (spec-075-orchestration-node-control-parity, #6021) ──
995
996    #[test]
997    fn orchestration_config_default_idle_timeout_secs_is_none() {
998        let cfg = OrchestrationConfig::default();
999        assert_eq!(cfg.default_idle_timeout_secs, None);
1000    }
1001
1002    #[test]
1003    fn orchestration_config_idle_timeout_secs_toml_roundtrip() {
1004        let toml_in = "enabled = true\ndefault_idle_timeout_secs = 60\n";
1005        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
1006        assert_eq!(cfg.default_idle_timeout_secs, Some(60));
1007        let serialized = toml::to_string(&cfg).expect("serialize");
1008        let cfg2: OrchestrationConfig = toml::from_str(&serialized).expect("re-deserialize");
1009        assert_eq!(cfg2.default_idle_timeout_secs, Some(60));
1010    }
1011
1012    #[test]
1013    fn orchestration_config_missing_idle_timeout_secs_migrates_to_none() {
1014        // Pre-feature config (no key present) — #[serde(default)] must yield None,
1015        // matching what a config persisted before this field existed deserializes to.
1016        let toml_in = "enabled = true\n";
1017        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
1018        assert_eq!(cfg.default_idle_timeout_secs, None);
1019    }
1020
1021    #[test]
1022    fn orchestration_config_default_verifier_timeout_secs_is_120() {
1023        // #6366: the previous 30s default reliably timed out PlanVerifier::verify() against
1024        // 20B+ local Ollama models, causing fail-open to silently skip ground().
1025        assert_eq!(OrchestrationConfig::default().verifier_timeout_secs, 120);
1026    }
1027
1028    #[test]
1029    fn orchestration_config_default_whole_plan_verifier_timeout_secs_is_0() {
1030        // #6379: 0 = fall back to verifier_timeout_secs, mirrors EnsembleConfig::member_timeout_secs.
1031        assert_eq!(
1032            OrchestrationConfig::default().whole_plan_verifier_timeout_secs,
1033            0
1034        );
1035    }
1036
1037    #[test]
1038    fn orchestration_config_whole_plan_verifier_timeout_secs_toml_roundtrip() {
1039        let toml_in = "enabled = true\nwhole_plan_verifier_timeout_secs = 300\n";
1040        let cfg: OrchestrationConfig = toml::from_str(toml_in).expect("deserialize");
1041        assert_eq!(cfg.whole_plan_verifier_timeout_secs, 300);
1042        let serialized = toml::to_string(&cfg).expect("serialize");
1043        let cfg2: OrchestrationConfig = toml::from_str(&serialized).expect("re-deserialize");
1044        assert_eq!(cfg2.whole_plan_verifier_timeout_secs, 300);
1045    }
1046}