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}