Skip to main content

zeph_config/
agent.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::PathBuf;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8use crate::providers::ProviderName;
9use crate::subagent::{HookDef, MemoryScope, PermissionMode};
10
11/// Specifies which LLM provider a sub-agent should use.
12///
13/// Used in `SubAgentDef.model` frontmatter field.
14#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum ModelSpec {
17    /// Use the parent agent's active provider at spawn time.
18    Inherit,
19    /// Use a specific named provider from `[[llm.providers]]`.
20    Named(String),
21}
22
23impl ModelSpec {
24    /// Return the string representation: `"inherit"` or the provider name.
25    #[must_use]
26    pub fn as_str(&self) -> &str {
27        match self {
28            ModelSpec::Inherit => "inherit",
29            ModelSpec::Named(s) => s.as_str(),
30        }
31    }
32}
33
34impl Serialize for ModelSpec {
35    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
36        match self {
37            ModelSpec::Inherit => serializer.serialize_str("inherit"),
38            ModelSpec::Named(s) => serializer.serialize_str(s),
39        }
40    }
41}
42
43impl<'de> Deserialize<'de> for ModelSpec {
44    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
45        let s = String::deserialize(deserializer)?;
46        if s == "inherit" {
47            Ok(ModelSpec::Inherit)
48        } else {
49            Ok(ModelSpec::Named(s))
50        }
51    }
52}
53
54/// Controls how the parent agent's conversation history is sanitized before passing to a
55/// spawned sub-agent.
56///
57/// Prompt injection is a documented attack vector when the parent history contains untrusted
58/// content from web scrapes, tool results, or A2A messages.  `InheritSanitized` is the safe
59/// default: messages pass through `ContentSanitizer` (in `zeph-sanitizer`) before injection.
60///
61/// # Examples
62///
63/// ```toml
64/// [subagent]
65/// parent_context_policy = "inherit_sanitized"   # default
66/// ```
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
68#[serde(rename_all = "snake_case")]
69#[non_exhaustive]
70pub enum ParentContextPolicy {
71    /// Pass the parent history verbatim — legacy behaviour, no sanitization.
72    Inherit,
73    /// Sanitize text parts of each message through the IPI pipeline before injection.
74    #[default]
75    InheritSanitized,
76    /// Do not inject any parent history into the sub-agent context.
77    None,
78}
79
80/// Controls how parent agent context is injected into a spawned sub-agent's task prompt.
81#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
82#[serde(rename_all = "snake_case")]
83#[non_exhaustive]
84pub enum ContextInjectionMode {
85    /// No parent context injected.
86    None,
87    /// Prepend the last assistant turn from parent history as a preamble.
88    #[default]
89    LastAssistantTurn,
90    /// LLM-generated summary of parent context (not yet implemented in Phase 1).
91    Summary,
92}
93
94/// Tri-state control over whether the main agent may spawn sub-agents, and who may trigger it
95/// (spec `042-subagent-delegation-mode-parity`, issue #5857).
96///
97/// Orthogonal to [`SubAgentConfig::enabled`], which remains the outer kill switch: when
98/// `enabled = false`, the effective mode is always [`DelegationMode::Disabled`] regardless of
99/// this field's value (FR-002). Also orthogonal to [`PermissionMode`] — that governs what a
100/// spawned sub-agent may *do*; this governs whether a spawn may happen *at all* and who may
101/// trigger it.
102#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
103#[serde(rename_all = "snake_case")]
104#[non_exhaustive]
105pub enum DelegationMode {
106    /// No spawn may proceed from any code path (slash command, orchestration planner/scheduler,
107    /// scheduled task). Read-only operations (`/agent list`, definition inspection, status
108    /// queries) remain available.
109    Disabled,
110    /// Only spawns attributable to a direct, explicit user action (e.g. `/agent spawn`) are
111    /// permitted; spawns originating from autonomous planner/scheduler decision-making are
112    /// rejected.
113    ExplicitRequestOnly,
114    /// Both explicit and autonomous spawn paths are permitted, subject to the pre-existing
115    /// constraints (`max_concurrent`, `max_spawn_depth`, permission grants, worktree isolation).
116    /// Matches the subsystem's behavior prior to this field's introduction.
117    #[default]
118    Proactive,
119}
120
121impl DelegationMode {
122    /// Whether a spawn/resume attempt attributable to a direct, explicit user action (e.g.
123    /// `/agent spawn`, `/agent resume`, `/subagent spawn`) is permitted under this mode.
124    ///
125    /// Expressed as an allow-list (`Proactive` and `ExplicitRequestOnly` match; every other
126    /// value, including any future `#[non_exhaustive]` variant, does not) rather than a
127    /// deny-list (`self != Disabled`), so it fails closed automatically on a variant this
128    /// crate doesn't yet recognize instead of silently permitting it. Shared by every
129    /// origin-agnostic "is this explicit action even allowed at all" check — the ACP
130    /// `/subagent spawn` gate and `SubAgentManager::resume` — so the two enforcement points
131    /// cannot drift out of sync with each other. Does **not** cover `Autonomous`-origin spawns;
132    /// `SubAgentManager::spawn`'s own origin-aware gate handles that distinction directly.
133    ///
134    /// # Examples
135    ///
136    /// ```rust
137    /// use zeph_config::DelegationMode;
138    ///
139    /// assert!(DelegationMode::Proactive.permits_explicit());
140    /// assert!(DelegationMode::ExplicitRequestOnly.permits_explicit());
141    /// assert!(!DelegationMode::Disabled.permits_explicit());
142    /// ```
143    #[must_use]
144    pub fn permits_explicit(self) -> bool {
145        matches!(self, Self::Proactive | Self::ExplicitRequestOnly)
146    }
147}
148
149fn default_max_parent_messages() -> usize {
150    20
151}
152
153fn default_summary_max_chars() -> usize {
154    600
155}
156
157fn default_llm_timeout_secs() -> u64 {
158    120
159}
160
161fn default_max_tool_iterations() -> usize {
162    10
163}
164
165fn default_auto_update_check() -> bool {
166    true
167}
168
169fn default_focus_compression_interval() -> usize {
170    12
171}
172
173fn default_focus_reminder_interval() -> usize {
174    15
175}
176
177fn default_focus_min_messages_per_focus() -> usize {
178    8
179}
180
181fn default_focus_max_knowledge_tokens() -> usize {
182    4096
183}
184
185fn default_focus_auto_consolidate_min_window() -> usize {
186    6
187}
188
189fn default_max_tool_retries() -> usize {
190    2
191}
192
193fn default_max_retry_duration_secs() -> u64 {
194    30
195}
196
197fn default_tool_repeat_threshold() -> usize {
198    2
199}
200
201fn default_tool_filter_top_k() -> usize {
202    6
203}
204
205fn default_tool_filter_min_description_words() -> usize {
206    5
207}
208
209fn default_tool_filter_always_on() -> Vec<String> {
210    vec![
211        "memory_search".into(),
212        "memory_save".into(),
213        "load_skill".into(),
214        "invoke_skill".into(),
215        "bash".into(),
216        "read".into(),
217        "edit".into(),
218    ]
219}
220
221fn default_instruction_auto_detect() -> bool {
222    true
223}
224
225fn default_max_concurrent() -> usize {
226    5
227}
228
229fn default_context_window_turns() -> usize {
230    10
231}
232
233fn default_max_spawn_depth() -> u32 {
234    3
235}
236
237fn default_transcript_enabled() -> bool {
238    true
239}
240
241fn default_transcript_max_files() -> usize {
242    50
243}
244
245/// Configuration for focus-based active context compression (#1850).
246#[derive(Debug, Clone, Deserialize, Serialize)]
247#[serde(default)]
248pub struct FocusConfig {
249    /// Enable focus tools (`start_focus` / `complete_focus`). Default: `false`.
250    pub enabled: bool,
251    /// Suggest focus after this many turns without one. Default: `12`.
252    #[serde(default = "default_focus_compression_interval")]
253    pub compression_interval: usize,
254    /// Remind the agent every N turns when focus is overdue. Default: `15`.
255    #[serde(default = "default_focus_reminder_interval")]
256    pub reminder_interval: usize,
257    /// Minimum messages required before suggesting a focus. Default: `8`.
258    #[serde(default = "default_focus_min_messages_per_focus")]
259    pub min_messages_per_focus: usize,
260    /// Maximum tokens the Knowledge block may grow to before old entries are trimmed.
261    /// Default: `4096`.
262    #[serde(default = "default_focus_max_knowledge_tokens")]
263    pub max_knowledge_tokens: usize,
264    /// Minimum turns since the last auto-consolidation before the next one fires.
265    ///
266    /// Must be >= 1. `Config::validate()` rejects `0` at startup. Default: `6`.
267    #[serde(default = "default_focus_auto_consolidate_min_window")]
268    pub auto_consolidate_min_window: usize,
269}
270
271impl Default for FocusConfig {
272    fn default() -> Self {
273        Self {
274            enabled: false,
275            compression_interval: default_focus_compression_interval(),
276            reminder_interval: default_focus_reminder_interval(),
277            min_messages_per_focus: default_focus_min_messages_per_focus(),
278            max_knowledge_tokens: default_focus_max_knowledge_tokens(),
279            auto_consolidate_min_window: default_focus_auto_consolidate_min_window(),
280        }
281    }
282}
283
284/// Dynamic tool schema filtering configuration (#2020).
285///
286/// When enabled, only a subset of tool definitions is sent to the LLM on each turn,
287/// selected by embedding similarity between the user query and tool descriptions.
288#[derive(Debug, Clone, Deserialize, Serialize)]
289#[serde(default)]
290pub struct ToolFilterConfig {
291    /// Enable dynamic tool schema filtering. Default: `false` (opt-in).
292    pub enabled: bool,
293    /// Number of top-scoring filterable tools to include per turn.
294    /// Set to `0` to include all filterable tools.
295    #[serde(default = "default_tool_filter_top_k")]
296    pub top_k: usize,
297    /// Tool IDs that are never filtered out.
298    #[serde(default = "default_tool_filter_always_on")]
299    pub always_on: Vec<String>,
300    /// MCP tools with fewer description words than this are auto-included.
301    #[serde(default = "default_tool_filter_min_description_words")]
302    pub min_description_words: usize,
303}
304
305impl Default for ToolFilterConfig {
306    fn default() -> Self {
307        Self {
308            enabled: false,
309            top_k: default_tool_filter_top_k(),
310            always_on: default_tool_filter_always_on(),
311            min_description_words: default_tool_filter_min_description_words(),
312        }
313    }
314}
315
316/// Core agent behavior configuration, nested under `[agent]` in TOML.
317///
318/// Controls the agent's name, tool-loop limits, instruction loading, and retry
319/// behavior. All fields have sensible defaults; only `name` is typically changed
320/// by end users.
321///
322/// # Example (TOML)
323///
324/// ```toml
325/// [agent]
326/// name = "Zeph"
327/// max_tool_iterations = 15
328/// max_tool_retries = 3
329/// ```
330#[derive(Debug, Deserialize, Serialize)]
331#[allow(clippy::struct_excessive_bools)] // independent boolean flags; bitflags or enum would obscure semantics without reducing complexity
332pub struct AgentConfig {
333    /// Human-readable agent name surfaced in the TUI and Telegram header. Default: `"Zeph"`.
334    pub name: String,
335    /// Maximum number of tool-call iterations per agent turn before the loop is aborted.
336    /// Must be `<= 100`. Default: `10`.
337    #[serde(default = "default_max_tool_iterations")]
338    pub max_tool_iterations: usize,
339    /// Check for new Zeph releases at startup. Default: `true`.
340    #[serde(default = "default_auto_update_check")]
341    pub auto_update_check: bool,
342    /// Additional instruction files to always load, regardless of provider.
343    #[serde(default)]
344    pub instruction_files: Vec<std::path::PathBuf>,
345    /// When true, automatically detect provider-specific instruction files
346    /// (e.g. `CLAUDE.md` for Claude, `AGENTS.md` for `OpenAI`).
347    #[serde(default = "default_instruction_auto_detect")]
348    pub instruction_auto_detect: bool,
349    /// Maximum retry attempts for transient tool errors (0 to disable).
350    #[serde(default = "default_max_tool_retries")]
351    pub max_tool_retries: usize,
352    /// Number of identical tool+args calls within the recent window to trigger repeat-detection
353    /// abort (0 to disable).
354    #[serde(default = "default_tool_repeat_threshold")]
355    pub tool_repeat_threshold: usize,
356    /// Maximum total wall-clock time (seconds) to spend on retries for a single tool call.
357    #[serde(default = "default_max_retry_duration_secs")]
358    pub max_retry_duration_secs: u64,
359    /// Focus-based active context compression configuration (#1850).
360    #[serde(default)]
361    pub focus: FocusConfig,
362    /// Dynamic tool schema filtering configuration (#2020).
363    #[serde(default)]
364    pub tool_filter: ToolFilterConfig,
365    /// Inject a `<budget>` XML block into the volatile system prompt section so the LLM
366    /// can self-regulate tool calls and cost. Self-suppresses when no budget data is
367    /// available (#2267).
368    #[serde(default = "default_budget_hint_enabled")]
369    pub budget_hint_enabled: bool,
370    /// Background task supervisor tuning. Controls concurrency limits and turn-boundary abort.
371    #[serde(default)]
372    pub supervisor: TaskSupervisorConfig,
373    /// Inject a `<current_time>` reminder into the volatile system prompt block every N agent
374    /// turns (#6361, spec 070 FR-003). Opt-in — defaults to `false` so existing prompt content
375    /// and token budget are unaffected unless explicitly enabled (NFR-005). Complementary to
376    /// the always-available `get_current_time` tool, which covers time-awareness within a
377    /// single long-running turn where this per-turn injection cannot re-fire.
378    #[serde(default = "default_time_reminder_enabled")]
379    pub time_reminder_enabled: bool,
380    /// Number of agent turns between `<current_time>` reminder injections when
381    /// `time_reminder_enabled = true` (#6361, spec 070 FR-004). Named after Codex's
382    /// `reminder_interval_model_requests`, but counts agent turn-cycles (`sidequest.turn_counter`)
383    /// rather than individual model requests — the mandated injection hook
384    /// (`rebuild_system_prompt`) runs once per turn, before the tool loop, so a literal
385    /// per-model-request cadence is unreachable there.
386    #[serde(default = "default_time_reminder_interval_requests")]
387    pub time_reminder_interval_requests: u32,
388}
389
390fn default_budget_hint_enabled() -> bool {
391    true
392}
393
394fn default_time_reminder_enabled() -> bool {
395    false
396}
397
398fn default_time_reminder_interval_requests() -> u32 {
399    10
400}
401
402fn default_goal_max_text_chars() -> usize {
403    2000
404}
405
406fn default_goal_max_history() -> usize {
407    50
408}
409
410fn default_autonomous_max_turns() -> u32 {
411    20
412}
413
414fn default_verify_interval() -> u32 {
415    5
416}
417
418fn default_supervisor_timeout_secs() -> u64 {
419    30
420}
421
422fn default_max_stuck_count() -> u32 {
423    3
424}
425
426fn default_autonomous_turn_delay_ms() -> u64 {
427    500
428}
429
430fn default_autonomous_turn_timeout_secs() -> u64 {
431    300
432}
433
434fn default_max_supervisor_fail_count() -> u32 {
435    3
436}
437
438/// Long-horizon goal lifecycle configuration (`[goals]` TOML section).
439///
440/// When enabled, the agent tracks a single active goal across turns, injecting an
441/// `<active_goal>` block into the volatile system-prompt region and accounting for
442/// token consumption per turn.
443///
444/// Set `autonomous_enabled = true` to allow the agent to run multi-turn goal execution
445/// without waiting for user input between turns. A supervisor LLM call periodically checks
446/// whether the goal condition has been satisfied.
447///
448/// # Example (TOML)
449///
450/// ```toml
451/// [goals]
452/// enabled = true
453/// autonomous_enabled = true
454/// autonomous_max_turns = 20
455/// supervisor_provider = "fast"
456/// verify_interval = 5
457/// supervisor_timeout_secs = 30
458/// max_stuck_count = 3
459/// autonomous_turn_delay_ms = 500
460/// default_token_budget = 50000
461/// ```
462#[derive(Debug, Clone, Deserialize, Serialize)]
463#[serde(default)]
464pub struct GoalConfig {
465    /// Enable the goal lifecycle subsystem. Default: `false`.
466    pub enabled: bool,
467    /// Inject `<active_goal>` block into the volatile system-prompt region. Default: `true`.
468    pub inject_into_system_prompt: bool,
469    /// Maximum characters allowed for goal text at creation time. Default: `2000`.
470    #[serde(default = "default_goal_max_text_chars")]
471    pub max_text_chars: usize,
472    /// Default token budget for new goals (`None` = unlimited). Default: `None`.
473    pub default_token_budget: Option<u64>,
474    /// Maximum number of goals to return in `/goal list`. Default: `50`.
475    #[serde(default = "default_goal_max_history")]
476    pub max_history: usize,
477    /// Enable autonomous multi-turn execution mode (`/goal create ... --auto`). Default: `false`.
478    pub autonomous_enabled: bool,
479    /// Maximum number of turns the agent may run without user input per session. Default: `20`.
480    #[serde(default = "default_autonomous_max_turns")]
481    pub autonomous_max_turns: u32,
482    /// Provider name for the supervisor verifier LLM call (references `[[llm.providers]] name`).
483    /// Falls back to the main provider when `None`.
484    pub supervisor_provider: Option<ProviderName>,
485    /// How many turns to execute between supervisor verification checks. Default: `5`.
486    #[serde(default = "default_verify_interval")]
487    pub verify_interval: u32,
488    /// Timeout in seconds for a single supervisor verification LLM call. Default: `30`.
489    #[serde(default = "default_supervisor_timeout_secs")]
490    pub supervisor_timeout_secs: u64,
491    /// Maximum consecutive stuck-turn detections before the session is aborted. Default: `3`.
492    #[serde(default = "default_max_stuck_count")]
493    pub max_stuck_count: u32,
494    /// Delay in milliseconds between autonomous turns to avoid busy-looping. Default: `500`.
495    #[serde(default = "default_autonomous_turn_delay_ms")]
496    pub autonomous_turn_delay_ms: u64,
497    /// Maximum wall-clock time in seconds for a single autonomous LLM turn before it is
498    /// cancelled and the session transitions to `Stuck`. Default: `300` (5 minutes).
499    #[serde(default = "default_autonomous_turn_timeout_secs")]
500    pub autonomous_turn_timeout_secs: u64,
501    /// Maximum consecutive supervisor verification failures before the session is paused.
502    /// Default: `3`.
503    #[serde(default = "default_max_supervisor_fail_count")]
504    pub max_supervisor_fail_count: u32,
505}
506
507impl Default for GoalConfig {
508    fn default() -> Self {
509        Self {
510            enabled: false,
511            inject_into_system_prompt: true,
512            max_text_chars: default_goal_max_text_chars(),
513            default_token_budget: None,
514            max_history: default_goal_max_history(),
515            autonomous_enabled: false,
516            autonomous_max_turns: default_autonomous_max_turns(),
517            supervisor_provider: None,
518            verify_interval: default_verify_interval(),
519            supervisor_timeout_secs: default_supervisor_timeout_secs(),
520            max_stuck_count: default_max_stuck_count(),
521            autonomous_turn_delay_ms: default_autonomous_turn_delay_ms(),
522            autonomous_turn_timeout_secs: default_autonomous_turn_timeout_secs(),
523            max_supervisor_fail_count: default_max_supervisor_fail_count(),
524        }
525    }
526}
527
528fn default_enrichment_limit() -> usize {
529    4
530}
531
532fn default_telemetry_limit() -> usize {
533    8
534}
535
536fn default_background_shell_limit() -> usize {
537    8
538}
539
540/// Background task supervisor configuration, nested under `[agent.supervisor]` in TOML.
541///
542/// Controls per-class concurrency limits and turn-boundary behaviour for the
543/// `BackgroundSupervisor` in `zeph-core`.
544/// All fields have sensible defaults that match the Phase 1 hardcoded values; only change
545/// these if you observe excessive background task drops under load.
546///
547/// # Example (TOML)
548///
549/// ```toml
550/// [agent.supervisor]
551/// enrichment_limit = 4
552/// telemetry_limit = 8
553/// abort_enrichment_on_turn = false
554/// ```
555#[derive(Debug, Clone, Deserialize, Serialize)]
556#[serde(default)]
557pub struct TaskSupervisorConfig {
558    /// Maximum concurrent enrichment tasks (summarization, graph/persona/trajectory extraction).
559    /// Default: `4`.
560    #[serde(default = "default_enrichment_limit")]
561    pub enrichment_limit: usize,
562    /// Maximum concurrent telemetry tasks (audit log writes, graph count sync).
563    /// Default: `8`.
564    #[serde(default = "default_telemetry_limit")]
565    pub telemetry_limit: usize,
566    /// Abort all inflight enrichment tasks at turn boundary to prevent backlog buildup.
567    /// Default: `false`.
568    #[serde(default)]
569    pub abort_enrichment_on_turn: bool,
570    /// Maximum concurrent background shell runs tracked by the supervisor.
571    ///
572    /// Should match `tools.shell.max_background_runs` so both layers agree on capacity.
573    /// Default: `8`.
574    #[serde(default = "default_background_shell_limit")]
575    pub background_shell_limit: usize,
576}
577
578impl Default for TaskSupervisorConfig {
579    fn default() -> Self {
580        Self {
581            enrichment_limit: default_enrichment_limit(),
582            telemetry_limit: default_telemetry_limit(),
583            abort_enrichment_on_turn: false,
584            background_shell_limit: default_background_shell_limit(),
585        }
586    }
587}
588
589/// Sub-agent pool configuration, nested under `[agents]` in TOML.
590///
591/// When `enabled = true`, the agent can spawn isolated sub-agent sessions from
592/// SKILL.md-based agent definitions. Sub-agents inherit the parent's provider pool
593/// unless overridden by `model` in their definition frontmatter.
594///
595/// # Example (TOML)
596///
597/// ```toml
598/// [agents]
599/// enabled = true
600/// delegation_mode = "explicit_request_only"
601/// max_concurrent = 3
602/// max_spawn_depth = 2
603/// ```
604#[derive(Debug, Clone, Deserialize, Serialize)]
605#[serde(default)]
606#[allow(clippy::struct_excessive_bools)] // independent config toggles; bitflags or enum would obscure semantics without reducing complexity
607pub struct SubAgentConfig {
608    /// Enable the sub-agent subsystem. Default: `false`.
609    ///
610    /// Outer kill switch: when `false`, the effective [`delegation_mode`][Self::delegation_mode]
611    /// is always [`DelegationMode::Disabled`] regardless of that field's configured value
612    /// (spec `042-subagent-delegation-mode-parity` FR-002).
613    pub enabled: bool,
614    /// Whether the main agent may spawn sub-agents, and who may trigger it: `disabled` /
615    /// `explicit_request_only` / `proactive`. Default: [`DelegationMode::Proactive`] (preserves
616    /// the subsystem's unconstrained behavior prior to this field's introduction, per FR-008).
617    /// Overridable via `ZEPH_AGENTS_DELEGATION_MODE` or the `--delegation-mode` CLI flag.
618    #[serde(default)]
619    pub delegation_mode: DelegationMode,
620    /// Maximum number of sub-agents that can run concurrently.
621    #[serde(default = "default_max_concurrent")]
622    pub max_concurrent: usize,
623    /// Additional directories to search for `.agent.md` definition files.
624    pub extra_dirs: Vec<PathBuf>,
625    /// User-level agents directory.
626    #[serde(default)]
627    pub user_agents_dir: Option<PathBuf>,
628    /// Default permission mode applied to sub-agents that do not specify one.
629    pub default_permission_mode: Option<PermissionMode>,
630    /// Global denylist applied to all sub-agents in addition to per-agent `tools.except`.
631    #[serde(default)]
632    pub default_disallowed_tools: Vec<String>,
633    /// Allow sub-agents to use `bypass_permissions` mode.
634    #[serde(default)]
635    pub allow_bypass_permissions: bool,
636    /// Default memory scope applied to sub-agents that do not set `memory` in their definition.
637    #[serde(default)]
638    pub default_memory_scope: Option<MemoryScope>,
639    /// Lifecycle hooks executed when any sub-agent starts or stops.
640    #[serde(default)]
641    pub hooks: SubAgentLifecycleHooks,
642    /// Directory where transcript JSONL files and meta sidecars are stored.
643    #[serde(default)]
644    pub transcript_dir: Option<PathBuf>,
645    /// Enable writing JSONL transcripts for sub-agent sessions.
646    #[serde(default = "default_transcript_enabled")]
647    pub transcript_enabled: bool,
648    /// Maximum number of `.jsonl` transcript files to keep.
649    #[serde(default = "default_transcript_max_files")]
650    pub transcript_max_files: usize,
651    /// Forward each running sub-agent's full, untruncated per-turn text/thinking output to
652    /// an active consumer surface (TUI runtime detail view and/or `--bare` stdout) as it is
653    /// produced, instead of only the 120-char once-per-turn status snippet (issue #6359,
654    /// spec `068-subagent-transcript-forward`). Default: `false` — disabling it (the
655    /// default) preserves today's exact `SubAgentStatus`/`collect()` behavior byte-for-byte.
656    /// Mirrors `CLAUDE_CODE_FORWARD_SUBAGENT_TEXT`; overridable via
657    /// `ZEPH_AGENTS_FORWARD_TRANSCRIPT` or the `--forward-subagent-text` CLI flag.
658    #[serde(default)]
659    pub forward_transcript: bool,
660    /// Number of recent parent conversation turns to pass to spawned sub-agents.
661    /// Set to 0 to disable history propagation.
662    #[serde(default = "default_context_window_turns")]
663    pub context_window_turns: usize,
664    /// Maximum nesting depth for sub-agent spawns.
665    #[serde(default = "default_max_spawn_depth")]
666    pub max_spawn_depth: u32,
667    /// How parent context is injected into the sub-agent's task prompt.
668    #[serde(default)]
669    pub context_injection_mode: ContextInjectionMode,
670    /// Whether to sanitize parent conversation history before passing to a spawned sub-agent.
671    ///
672    /// Defaults to [`ParentContextPolicy::InheritSanitized`] which runs each text message part
673    /// through the IPI sanitizer, stripping prompt-injection payloads that may have entered the
674    /// parent history via tool results, web scrapes, or A2A messages.
675    #[serde(default)]
676    pub parent_context_policy: ParentContextPolicy,
677    /// Maximum number of parent messages to inject, independent of `context_window_turns`.
678    ///
679    /// Acts as a hard upper bound on context propagation volume to limit the blast radius
680    /// of poisoned histories.  When `max_parent_messages < context_window_turns * 2` this cap
681    /// wins and fewer messages are passed; otherwise `context_window_turns * 2` is the binding
682    /// limit.  The tighter of the two limits always applies.
683    #[serde(default = "default_max_parent_messages")]
684    pub max_parent_messages: usize,
685    /// Maximum character count for the `Summary` context injection mode.
686    ///
687    /// When `context_injection_mode = "summary"`, the extracted summary is truncated
688    /// to this many characters at a UTF-8 char boundary before being prepended to the
689    /// sub-agent's task prompt.  Consistent with the `max_state_chars` naming convention.
690    ///
691    /// Default: `600` (≈200 tokens at 3 chars/token).
692    #[serde(default = "default_summary_max_chars")]
693    pub summary_max_chars: usize,
694    /// Maximum wall time in seconds for a single LLM call inside a sub-agent turn.
695    ///
696    /// If the provider does not return a response within this window, the call is
697    /// cancelled and the sub-agent turn fails with a timeout error. Default: 120.
698    #[serde(default = "default_llm_timeout_secs")]
699    pub llm_timeout_secs: u64,
700    /// Worktree isolation settings propagated from the top-level `[worktree]` section.
701    ///
702    /// Passed to the subagent manager's spawn function so it can determine whether
703    /// and how to create a per-agent git worktree without needing a reference to
704    /// the full `Config`.
705    ///
706    /// # Invariant
707    ///
708    /// This field is always populated from `Config::worktree` in `runner.rs` bootstrap.
709    /// Do not set defaults independently — changes here will not take effect in production
710    /// because the bootstrap overwrites this value before passing it to `SubAgentManager`.
711    #[serde(default)]
712    pub worktree: crate::worktree::WorktreeConfig,
713}
714
715impl Default for SubAgentConfig {
716    fn default() -> Self {
717        Self {
718            enabled: false,
719            delegation_mode: DelegationMode::default(),
720            max_concurrent: default_max_concurrent(),
721            extra_dirs: Vec::new(),
722            user_agents_dir: None,
723            default_permission_mode: None,
724            default_disallowed_tools: Vec::new(),
725            allow_bypass_permissions: false,
726            default_memory_scope: None,
727            hooks: SubAgentLifecycleHooks::default(),
728            transcript_dir: None,
729            transcript_enabled: default_transcript_enabled(),
730            transcript_max_files: default_transcript_max_files(),
731            forward_transcript: false,
732            context_window_turns: default_context_window_turns(),
733            max_spawn_depth: default_max_spawn_depth(),
734            context_injection_mode: ContextInjectionMode::default(),
735            parent_context_policy: ParentContextPolicy::default(),
736            max_parent_messages: default_max_parent_messages(),
737            summary_max_chars: default_summary_max_chars(),
738            llm_timeout_secs: default_llm_timeout_secs(),
739            worktree: crate::worktree::WorktreeConfig::default(),
740        }
741    }
742}
743
744impl SubAgentConfig {
745    /// Resolve [`enabled`][Self::enabled] and [`delegation_mode`][Self::delegation_mode] into
746    /// the single effective mode that must be enforced at every spawn call site (spec
747    /// `042-subagent-delegation-mode-parity` FR-002, issue #5857).
748    ///
749    /// `enabled` is the outer kill switch: `enabled = false` always resolves to
750    /// [`DelegationMode::Disabled`], regardless of the configured `delegation_mode` value.
751    ///
752    /// # Examples
753    ///
754    /// ```rust
755    /// use zeph_config::{DelegationMode, SubAgentConfig};
756    ///
757    /// let mut cfg = SubAgentConfig {
758    ///     enabled: false,
759    ///     delegation_mode: DelegationMode::Proactive,
760    ///     ..SubAgentConfig::default()
761    /// };
762    /// assert_eq!(cfg.effective_delegation_mode(), DelegationMode::Disabled);
763    ///
764    /// cfg.enabled = true;
765    /// assert_eq!(cfg.effective_delegation_mode(), DelegationMode::Proactive);
766    /// ```
767    #[must_use]
768    pub fn effective_delegation_mode(&self) -> DelegationMode {
769        if self.enabled {
770            self.delegation_mode
771        } else {
772            DelegationMode::Disabled
773        }
774    }
775}
776
777/// Config-level lifecycle hooks fired when any sub-agent starts or stops.
778#[derive(Debug, Clone, Default, Deserialize, Serialize)]
779#[serde(default)]
780pub struct SubAgentLifecycleHooks {
781    /// Hooks run after a sub-agent is spawned (fire-and-forget).
782    pub start: Vec<HookDef>,
783    /// Hooks run after a sub-agent finishes or is cancelled (fire-and-forget).
784    pub stop: Vec<HookDef>,
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790
791    #[test]
792    fn subagent_config_defaults() {
793        let cfg = SubAgentConfig::default();
794        assert_eq!(cfg.context_window_turns, 10);
795        assert_eq!(cfg.max_spawn_depth, 3);
796        assert_eq!(
797            cfg.context_injection_mode,
798            ContextInjectionMode::LastAssistantTurn
799        );
800        assert_eq!(
801            cfg.parent_context_policy,
802            ParentContextPolicy::InheritSanitized
803        );
804        assert_eq!(cfg.max_parent_messages, 20);
805        assert!(
806            !cfg.forward_transcript,
807            "forward_transcript must default to false (NFR-003)"
808        );
809    }
810
811    #[test]
812    fn subagent_config_delegation_mode_defaults_proactive() {
813        let cfg = SubAgentConfig::default();
814        assert_eq!(cfg.delegation_mode, DelegationMode::Proactive);
815    }
816
817    #[test]
818    fn subagent_config_deserialize_delegation_mode() {
819        let toml_str = "enabled = true\ndelegation_mode = \"explicit_request_only\"";
820        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
821        assert_eq!(cfg.delegation_mode, DelegationMode::ExplicitRequestOnly);
822    }
823
824    #[test]
825    fn subagent_config_delegation_mode_omitted_defaults_proactive() {
826        let toml_str = "enabled = true";
827        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
828        assert_eq!(cfg.delegation_mode, DelegationMode::Proactive);
829    }
830
831    #[test]
832    fn subagent_config_delegation_mode_rejects_unknown_value() {
833        let toml_str = "delegation_mode = \"sometimes\"";
834        let result: Result<SubAgentConfig, _> = toml::from_str(toml_str);
835        assert!(result.is_err(), "unrecognized value must fail to parse");
836    }
837
838    #[test]
839    fn permits_explicit_allow_list() {
840        assert!(DelegationMode::Proactive.permits_explicit());
841        assert!(DelegationMode::ExplicitRequestOnly.permits_explicit());
842        assert!(!DelegationMode::Disabled.permits_explicit());
843    }
844
845    #[test]
846    fn effective_delegation_mode_disabled_when_not_enabled() {
847        let cfg = SubAgentConfig {
848            enabled: false,
849            delegation_mode: DelegationMode::Proactive,
850            ..SubAgentConfig::default()
851        };
852        assert_eq!(cfg.effective_delegation_mode(), DelegationMode::Disabled);
853    }
854
855    #[test]
856    fn effective_delegation_mode_passes_through_when_enabled() {
857        for mode in [
858            DelegationMode::Disabled,
859            DelegationMode::ExplicitRequestOnly,
860            DelegationMode::Proactive,
861        ] {
862            let cfg = SubAgentConfig {
863                enabled: true,
864                delegation_mode: mode,
865                ..SubAgentConfig::default()
866            };
867            assert_eq!(cfg.effective_delegation_mode(), mode);
868        }
869    }
870
871    #[test]
872    fn subagent_config_deserialize_forward_transcript() {
873        let toml_str = "forward_transcript = true";
874        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
875        assert!(cfg.forward_transcript);
876    }
877
878    #[test]
879    fn subagent_config_forward_transcript_omitted_defaults_false() {
880        let toml_str = "enabled = true";
881        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
882        assert!(!cfg.forward_transcript);
883    }
884
885    #[test]
886    fn subagent_config_deserialize_new_fields() {
887        let toml_str = r#"
888            enabled = true
889            context_window_turns = 5
890            max_spawn_depth = 2
891            context_injection_mode = "none"
892        "#;
893        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
894        assert_eq!(cfg.context_window_turns, 5);
895        assert_eq!(cfg.max_spawn_depth, 2);
896        assert_eq!(cfg.context_injection_mode, ContextInjectionMode::None);
897    }
898
899    #[test]
900    fn subagent_config_deserialize_parent_context_policy() {
901        let toml_str = r#"
902            parent_context_policy = "none"
903            max_parent_messages = 10
904        "#;
905        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
906        assert_eq!(cfg.parent_context_policy, ParentContextPolicy::None);
907        assert_eq!(cfg.max_parent_messages, 10);
908    }
909
910    #[test]
911    fn subagent_config_deserialize_parent_context_policy_inherit_sanitized() {
912        let toml_str = r#"
913            parent_context_policy = "inherit_sanitized"
914        "#;
915        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
916        assert_eq!(
917            cfg.parent_context_policy,
918            ParentContextPolicy::InheritSanitized
919        );
920    }
921
922    #[test]
923    fn model_spec_deserialize_inherit() {
924        let spec: ModelSpec = serde_json::from_str("\"inherit\"").unwrap();
925        assert_eq!(spec, ModelSpec::Inherit);
926    }
927
928    #[test]
929    fn model_spec_deserialize_named() {
930        let spec: ModelSpec = serde_json::from_str("\"fast\"").unwrap();
931        assert_eq!(spec, ModelSpec::Named("fast".to_owned()));
932    }
933
934    #[test]
935    fn model_spec_as_str() {
936        assert_eq!(ModelSpec::Inherit.as_str(), "inherit");
937        assert_eq!(ModelSpec::Named("x".to_owned()).as_str(), "x");
938    }
939
940    #[test]
941    fn focus_config_auto_consolidate_min_window_default_is_six() {
942        let cfg = FocusConfig::default();
943        assert_eq!(cfg.auto_consolidate_min_window, 6);
944    }
945
946    #[test]
947    fn focus_config_auto_consolidate_min_window_deserializes() {
948        let toml_str = "auto_consolidate_min_window = 10";
949        let cfg: FocusConfig = toml::from_str(toml_str).unwrap();
950        assert_eq!(cfg.auto_consolidate_min_window, 10);
951    }
952
953    #[test]
954    fn goal_config_new_field_defaults() {
955        let cfg = GoalConfig::default();
956        assert_eq!(cfg.autonomous_turn_timeout_secs, 300);
957        assert_eq!(cfg.max_supervisor_fail_count, 3);
958    }
959
960    #[test]
961    fn goal_config_new_fields_deserialize() {
962        let toml_str = r"
963            autonomous_turn_timeout_secs = 120
964            max_supervisor_fail_count = 5
965        ";
966        let cfg: GoalConfig = toml::from_str(toml_str).unwrap();
967        assert_eq!(cfg.autonomous_turn_timeout_secs, 120);
968        assert_eq!(cfg.max_supervisor_fail_count, 5);
969    }
970
971    #[test]
972    fn goal_config_omitted_new_fields_use_defaults() {
973        let toml_str = "enabled = true";
974        let cfg: GoalConfig = toml::from_str(toml_str).unwrap();
975        assert_eq!(cfg.autonomous_turn_timeout_secs, 300);
976        assert_eq!(cfg.max_supervisor_fail_count, 3);
977    }
978}