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
94fn default_max_parent_messages() -> usize {
95    20
96}
97
98fn default_summary_max_chars() -> usize {
99    600
100}
101
102fn default_llm_timeout_secs() -> u64 {
103    120
104}
105
106fn default_max_tool_iterations() -> usize {
107    10
108}
109
110fn default_auto_update_check() -> bool {
111    true
112}
113
114fn default_focus_compression_interval() -> usize {
115    12
116}
117
118fn default_focus_reminder_interval() -> usize {
119    15
120}
121
122fn default_focus_min_messages_per_focus() -> usize {
123    8
124}
125
126fn default_focus_max_knowledge_tokens() -> usize {
127    4096
128}
129
130fn default_focus_auto_consolidate_min_window() -> usize {
131    6
132}
133
134fn default_max_tool_retries() -> usize {
135    2
136}
137
138fn default_max_retry_duration_secs() -> u64 {
139    30
140}
141
142fn default_tool_repeat_threshold() -> usize {
143    2
144}
145
146fn default_tool_filter_top_k() -> usize {
147    6
148}
149
150fn default_tool_filter_min_description_words() -> usize {
151    5
152}
153
154fn default_tool_filter_always_on() -> Vec<String> {
155    vec![
156        "memory_search".into(),
157        "memory_save".into(),
158        "load_skill".into(),
159        "invoke_skill".into(),
160        "bash".into(),
161        "read".into(),
162        "edit".into(),
163    ]
164}
165
166fn default_instruction_auto_detect() -> bool {
167    true
168}
169
170fn default_max_concurrent() -> usize {
171    5
172}
173
174fn default_context_window_turns() -> usize {
175    10
176}
177
178fn default_max_spawn_depth() -> u32 {
179    3
180}
181
182fn default_transcript_enabled() -> bool {
183    true
184}
185
186fn default_transcript_max_files() -> usize {
187    50
188}
189
190/// Configuration for focus-based active context compression (#1850).
191#[derive(Debug, Clone, Deserialize, Serialize)]
192#[serde(default)]
193pub struct FocusConfig {
194    /// Enable focus tools (`start_focus` / `complete_focus`). Default: `false`.
195    pub enabled: bool,
196    /// Suggest focus after this many turns without one. Default: `12`.
197    #[serde(default = "default_focus_compression_interval")]
198    pub compression_interval: usize,
199    /// Remind the agent every N turns when focus is overdue. Default: `15`.
200    #[serde(default = "default_focus_reminder_interval")]
201    pub reminder_interval: usize,
202    /// Minimum messages required before suggesting a focus. Default: `8`.
203    #[serde(default = "default_focus_min_messages_per_focus")]
204    pub min_messages_per_focus: usize,
205    /// Maximum tokens the Knowledge block may grow to before old entries are trimmed.
206    /// Default: `4096`.
207    #[serde(default = "default_focus_max_knowledge_tokens")]
208    pub max_knowledge_tokens: usize,
209    /// Minimum turns since the last auto-consolidation before the next one fires.
210    ///
211    /// Must be >= 1. `Config::validate()` rejects `0` at startup. Default: `6`.
212    #[serde(default = "default_focus_auto_consolidate_min_window")]
213    pub auto_consolidate_min_window: usize,
214}
215
216impl Default for FocusConfig {
217    fn default() -> Self {
218        Self {
219            enabled: false,
220            compression_interval: default_focus_compression_interval(),
221            reminder_interval: default_focus_reminder_interval(),
222            min_messages_per_focus: default_focus_min_messages_per_focus(),
223            max_knowledge_tokens: default_focus_max_knowledge_tokens(),
224            auto_consolidate_min_window: default_focus_auto_consolidate_min_window(),
225        }
226    }
227}
228
229/// Dynamic tool schema filtering configuration (#2020).
230///
231/// When enabled, only a subset of tool definitions is sent to the LLM on each turn,
232/// selected by embedding similarity between the user query and tool descriptions.
233#[derive(Debug, Clone, Deserialize, Serialize)]
234#[serde(default)]
235pub struct ToolFilterConfig {
236    /// Enable dynamic tool schema filtering. Default: `false` (opt-in).
237    pub enabled: bool,
238    /// Number of top-scoring filterable tools to include per turn.
239    /// Set to `0` to include all filterable tools.
240    #[serde(default = "default_tool_filter_top_k")]
241    pub top_k: usize,
242    /// Tool IDs that are never filtered out.
243    #[serde(default = "default_tool_filter_always_on")]
244    pub always_on: Vec<String>,
245    /// MCP tools with fewer description words than this are auto-included.
246    #[serde(default = "default_tool_filter_min_description_words")]
247    pub min_description_words: usize,
248}
249
250impl Default for ToolFilterConfig {
251    fn default() -> Self {
252        Self {
253            enabled: false,
254            top_k: default_tool_filter_top_k(),
255            always_on: default_tool_filter_always_on(),
256            min_description_words: default_tool_filter_min_description_words(),
257        }
258    }
259}
260
261/// Core agent behavior configuration, nested under `[agent]` in TOML.
262///
263/// Controls the agent's name, tool-loop limits, instruction loading, and retry
264/// behavior. All fields have sensible defaults; only `name` is typically changed
265/// by end users.
266///
267/// # Example (TOML)
268///
269/// ```toml
270/// [agent]
271/// name = "Zeph"
272/// max_tool_iterations = 15
273/// max_tool_retries = 3
274/// ```
275#[derive(Debug, Deserialize, Serialize)]
276#[allow(clippy::struct_excessive_bools)] // independent boolean flags; bitflags or enum would obscure semantics without reducing complexity
277pub struct AgentConfig {
278    /// Human-readable agent name surfaced in the TUI and Telegram header. Default: `"Zeph"`.
279    pub name: String,
280    /// Maximum number of tool-call iterations per agent turn before the loop is aborted.
281    /// Must be `<= 100`. Default: `10`.
282    #[serde(default = "default_max_tool_iterations")]
283    pub max_tool_iterations: usize,
284    /// Check for new Zeph releases at startup. Default: `true`.
285    #[serde(default = "default_auto_update_check")]
286    pub auto_update_check: bool,
287    /// Additional instruction files to always load, regardless of provider.
288    #[serde(default)]
289    pub instruction_files: Vec<std::path::PathBuf>,
290    /// When true, automatically detect provider-specific instruction files
291    /// (e.g. `CLAUDE.md` for Claude, `AGENTS.md` for `OpenAI`).
292    #[serde(default = "default_instruction_auto_detect")]
293    pub instruction_auto_detect: bool,
294    /// Maximum retry attempts for transient tool errors (0 to disable).
295    #[serde(default = "default_max_tool_retries")]
296    pub max_tool_retries: usize,
297    /// Number of identical tool+args calls within the recent window to trigger repeat-detection
298    /// abort (0 to disable).
299    #[serde(default = "default_tool_repeat_threshold")]
300    pub tool_repeat_threshold: usize,
301    /// Maximum total wall-clock time (seconds) to spend on retries for a single tool call.
302    #[serde(default = "default_max_retry_duration_secs")]
303    pub max_retry_duration_secs: u64,
304    /// Focus-based active context compression configuration (#1850).
305    #[serde(default)]
306    pub focus: FocusConfig,
307    /// Dynamic tool schema filtering configuration (#2020).
308    #[serde(default)]
309    pub tool_filter: ToolFilterConfig,
310    /// Inject a `<budget>` XML block into the volatile system prompt section so the LLM
311    /// can self-regulate tool calls and cost. Self-suppresses when no budget data is
312    /// available (#2267).
313    #[serde(default = "default_budget_hint_enabled")]
314    pub budget_hint_enabled: bool,
315    /// Background task supervisor tuning. Controls concurrency limits and turn-boundary abort.
316    #[serde(default)]
317    pub supervisor: TaskSupervisorConfig,
318    /// Inject a `<current_time>` reminder into the volatile system prompt block every N agent
319    /// turns (#6361, spec 070 FR-003). Opt-in — defaults to `false` so existing prompt content
320    /// and token budget are unaffected unless explicitly enabled (NFR-005). Complementary to
321    /// the always-available `get_current_time` tool, which covers time-awareness within a
322    /// single long-running turn where this per-turn injection cannot re-fire.
323    #[serde(default = "default_time_reminder_enabled")]
324    pub time_reminder_enabled: bool,
325    /// Number of agent turns between `<current_time>` reminder injections when
326    /// `time_reminder_enabled = true` (#6361, spec 070 FR-004). Named after Codex's
327    /// `reminder_interval_model_requests`, but counts agent turn-cycles (`sidequest.turn_counter`)
328    /// rather than individual model requests — the mandated injection hook
329    /// (`rebuild_system_prompt`) runs once per turn, before the tool loop, so a literal
330    /// per-model-request cadence is unreachable there.
331    #[serde(default = "default_time_reminder_interval_requests")]
332    pub time_reminder_interval_requests: u32,
333}
334
335fn default_budget_hint_enabled() -> bool {
336    true
337}
338
339fn default_time_reminder_enabled() -> bool {
340    false
341}
342
343fn default_time_reminder_interval_requests() -> u32 {
344    10
345}
346
347fn default_goal_max_text_chars() -> usize {
348    2000
349}
350
351fn default_goal_max_history() -> usize {
352    50
353}
354
355fn default_autonomous_max_turns() -> u32 {
356    20
357}
358
359fn default_verify_interval() -> u32 {
360    5
361}
362
363fn default_supervisor_timeout_secs() -> u64 {
364    30
365}
366
367fn default_max_stuck_count() -> u32 {
368    3
369}
370
371fn default_autonomous_turn_delay_ms() -> u64 {
372    500
373}
374
375fn default_autonomous_turn_timeout_secs() -> u64 {
376    300
377}
378
379fn default_max_supervisor_fail_count() -> u32 {
380    3
381}
382
383/// Long-horizon goal lifecycle configuration (`[goals]` TOML section).
384///
385/// When enabled, the agent tracks a single active goal across turns, injecting an
386/// `<active_goal>` block into the volatile system-prompt region and accounting for
387/// token consumption per turn.
388///
389/// Set `autonomous_enabled = true` to allow the agent to run multi-turn goal execution
390/// without waiting for user input between turns. A supervisor LLM call periodically checks
391/// whether the goal condition has been satisfied.
392///
393/// # Example (TOML)
394///
395/// ```toml
396/// [goals]
397/// enabled = true
398/// autonomous_enabled = true
399/// autonomous_max_turns = 20
400/// supervisor_provider = "fast"
401/// verify_interval = 5
402/// supervisor_timeout_secs = 30
403/// max_stuck_count = 3
404/// autonomous_turn_delay_ms = 500
405/// default_token_budget = 50000
406/// ```
407#[derive(Debug, Clone, Deserialize, Serialize)]
408#[serde(default)]
409pub struct GoalConfig {
410    /// Enable the goal lifecycle subsystem. Default: `false`.
411    pub enabled: bool,
412    /// Inject `<active_goal>` block into the volatile system-prompt region. Default: `true`.
413    pub inject_into_system_prompt: bool,
414    /// Maximum characters allowed for goal text at creation time. Default: `2000`.
415    #[serde(default = "default_goal_max_text_chars")]
416    pub max_text_chars: usize,
417    /// Default token budget for new goals (`None` = unlimited). Default: `None`.
418    pub default_token_budget: Option<u64>,
419    /// Maximum number of goals to return in `/goal list`. Default: `50`.
420    #[serde(default = "default_goal_max_history")]
421    pub max_history: usize,
422    /// Enable autonomous multi-turn execution mode (`/goal create ... --auto`). Default: `false`.
423    pub autonomous_enabled: bool,
424    /// Maximum number of turns the agent may run without user input per session. Default: `20`.
425    #[serde(default = "default_autonomous_max_turns")]
426    pub autonomous_max_turns: u32,
427    /// Provider name for the supervisor verifier LLM call (references `[[llm.providers]] name`).
428    /// Falls back to the main provider when `None`.
429    pub supervisor_provider: Option<ProviderName>,
430    /// How many turns to execute between supervisor verification checks. Default: `5`.
431    #[serde(default = "default_verify_interval")]
432    pub verify_interval: u32,
433    /// Timeout in seconds for a single supervisor verification LLM call. Default: `30`.
434    #[serde(default = "default_supervisor_timeout_secs")]
435    pub supervisor_timeout_secs: u64,
436    /// Maximum consecutive stuck-turn detections before the session is aborted. Default: `3`.
437    #[serde(default = "default_max_stuck_count")]
438    pub max_stuck_count: u32,
439    /// Delay in milliseconds between autonomous turns to avoid busy-looping. Default: `500`.
440    #[serde(default = "default_autonomous_turn_delay_ms")]
441    pub autonomous_turn_delay_ms: u64,
442    /// Maximum wall-clock time in seconds for a single autonomous LLM turn before it is
443    /// cancelled and the session transitions to `Stuck`. Default: `300` (5 minutes).
444    #[serde(default = "default_autonomous_turn_timeout_secs")]
445    pub autonomous_turn_timeout_secs: u64,
446    /// Maximum consecutive supervisor verification failures before the session is paused.
447    /// Default: `3`.
448    #[serde(default = "default_max_supervisor_fail_count")]
449    pub max_supervisor_fail_count: u32,
450}
451
452impl Default for GoalConfig {
453    fn default() -> Self {
454        Self {
455            enabled: false,
456            inject_into_system_prompt: true,
457            max_text_chars: default_goal_max_text_chars(),
458            default_token_budget: None,
459            max_history: default_goal_max_history(),
460            autonomous_enabled: false,
461            autonomous_max_turns: default_autonomous_max_turns(),
462            supervisor_provider: None,
463            verify_interval: default_verify_interval(),
464            supervisor_timeout_secs: default_supervisor_timeout_secs(),
465            max_stuck_count: default_max_stuck_count(),
466            autonomous_turn_delay_ms: default_autonomous_turn_delay_ms(),
467            autonomous_turn_timeout_secs: default_autonomous_turn_timeout_secs(),
468            max_supervisor_fail_count: default_max_supervisor_fail_count(),
469        }
470    }
471}
472
473fn default_enrichment_limit() -> usize {
474    4
475}
476
477fn default_telemetry_limit() -> usize {
478    8
479}
480
481fn default_background_shell_limit() -> usize {
482    8
483}
484
485/// Background task supervisor configuration, nested under `[agent.supervisor]` in TOML.
486///
487/// Controls per-class concurrency limits and turn-boundary behaviour for the
488/// `BackgroundSupervisor` in `zeph-core`.
489/// All fields have sensible defaults that match the Phase 1 hardcoded values; only change
490/// these if you observe excessive background task drops under load.
491///
492/// # Example (TOML)
493///
494/// ```toml
495/// [agent.supervisor]
496/// enrichment_limit = 4
497/// telemetry_limit = 8
498/// abort_enrichment_on_turn = false
499/// ```
500#[derive(Debug, Clone, Deserialize, Serialize)]
501#[serde(default)]
502pub struct TaskSupervisorConfig {
503    /// Maximum concurrent enrichment tasks (summarization, graph/persona/trajectory extraction).
504    /// Default: `4`.
505    #[serde(default = "default_enrichment_limit")]
506    pub enrichment_limit: usize,
507    /// Maximum concurrent telemetry tasks (audit log writes, graph count sync).
508    /// Default: `8`.
509    #[serde(default = "default_telemetry_limit")]
510    pub telemetry_limit: usize,
511    /// Abort all inflight enrichment tasks at turn boundary to prevent backlog buildup.
512    /// Default: `false`.
513    #[serde(default)]
514    pub abort_enrichment_on_turn: bool,
515    /// Maximum concurrent background shell runs tracked by the supervisor.
516    ///
517    /// Should match `tools.shell.max_background_runs` so both layers agree on capacity.
518    /// Default: `8`.
519    #[serde(default = "default_background_shell_limit")]
520    pub background_shell_limit: usize,
521}
522
523impl Default for TaskSupervisorConfig {
524    fn default() -> Self {
525        Self {
526            enrichment_limit: default_enrichment_limit(),
527            telemetry_limit: default_telemetry_limit(),
528            abort_enrichment_on_turn: false,
529            background_shell_limit: default_background_shell_limit(),
530        }
531    }
532}
533
534/// Sub-agent pool configuration, nested under `[agents]` in TOML.
535///
536/// When `enabled = true`, the agent can spawn isolated sub-agent sessions from
537/// SKILL.md-based agent definitions. Sub-agents inherit the parent's provider pool
538/// unless overridden by `model` in their definition frontmatter.
539///
540/// # Example (TOML)
541///
542/// ```toml
543/// [agents]
544/// enabled = true
545/// max_concurrent = 3
546/// max_spawn_depth = 2
547/// ```
548#[derive(Debug, Clone, Deserialize, Serialize)]
549#[serde(default)]
550#[allow(clippy::struct_excessive_bools)] // independent config toggles; bitflags or enum would obscure semantics without reducing complexity
551pub struct SubAgentConfig {
552    /// Enable the sub-agent subsystem. Default: `false`.
553    pub enabled: bool,
554    /// Maximum number of sub-agents that can run concurrently.
555    #[serde(default = "default_max_concurrent")]
556    pub max_concurrent: usize,
557    /// Additional directories to search for `.agent.md` definition files.
558    pub extra_dirs: Vec<PathBuf>,
559    /// User-level agents directory.
560    #[serde(default)]
561    pub user_agents_dir: Option<PathBuf>,
562    /// Default permission mode applied to sub-agents that do not specify one.
563    pub default_permission_mode: Option<PermissionMode>,
564    /// Global denylist applied to all sub-agents in addition to per-agent `tools.except`.
565    #[serde(default)]
566    pub default_disallowed_tools: Vec<String>,
567    /// Allow sub-agents to use `bypass_permissions` mode.
568    #[serde(default)]
569    pub allow_bypass_permissions: bool,
570    /// Default memory scope applied to sub-agents that do not set `memory` in their definition.
571    #[serde(default)]
572    pub default_memory_scope: Option<MemoryScope>,
573    /// Lifecycle hooks executed when any sub-agent starts or stops.
574    #[serde(default)]
575    pub hooks: SubAgentLifecycleHooks,
576    /// Directory where transcript JSONL files and meta sidecars are stored.
577    #[serde(default)]
578    pub transcript_dir: Option<PathBuf>,
579    /// Enable writing JSONL transcripts for sub-agent sessions.
580    #[serde(default = "default_transcript_enabled")]
581    pub transcript_enabled: bool,
582    /// Maximum number of `.jsonl` transcript files to keep.
583    #[serde(default = "default_transcript_max_files")]
584    pub transcript_max_files: usize,
585    /// Forward each running sub-agent's full, untruncated per-turn text/thinking output to
586    /// an active consumer surface (TUI runtime detail view and/or `--bare` stdout) as it is
587    /// produced, instead of only the 120-char once-per-turn status snippet (issue #6359,
588    /// spec `068-subagent-transcript-forward`). Default: `false` — disabling it (the
589    /// default) preserves today's exact `SubAgentStatus`/`collect()` behavior byte-for-byte.
590    /// Mirrors `CLAUDE_CODE_FORWARD_SUBAGENT_TEXT`; overridable via
591    /// `ZEPH_AGENTS_FORWARD_TRANSCRIPT` or the `--forward-subagent-text` CLI flag.
592    #[serde(default)]
593    pub forward_transcript: bool,
594    /// Number of recent parent conversation turns to pass to spawned sub-agents.
595    /// Set to 0 to disable history propagation.
596    #[serde(default = "default_context_window_turns")]
597    pub context_window_turns: usize,
598    /// Maximum nesting depth for sub-agent spawns.
599    #[serde(default = "default_max_spawn_depth")]
600    pub max_spawn_depth: u32,
601    /// How parent context is injected into the sub-agent's task prompt.
602    #[serde(default)]
603    pub context_injection_mode: ContextInjectionMode,
604    /// Whether to sanitize parent conversation history before passing to a spawned sub-agent.
605    ///
606    /// Defaults to [`ParentContextPolicy::InheritSanitized`] which runs each text message part
607    /// through the IPI sanitizer, stripping prompt-injection payloads that may have entered the
608    /// parent history via tool results, web scrapes, or A2A messages.
609    #[serde(default)]
610    pub parent_context_policy: ParentContextPolicy,
611    /// Maximum number of parent messages to inject, independent of `context_window_turns`.
612    ///
613    /// Acts as a hard upper bound on context propagation volume to limit the blast radius
614    /// of poisoned histories.  When `max_parent_messages < context_window_turns * 2` this cap
615    /// wins and fewer messages are passed; otherwise `context_window_turns * 2` is the binding
616    /// limit.  The tighter of the two limits always applies.
617    #[serde(default = "default_max_parent_messages")]
618    pub max_parent_messages: usize,
619    /// Maximum character count for the `Summary` context injection mode.
620    ///
621    /// When `context_injection_mode = "summary"`, the extracted summary is truncated
622    /// to this many characters at a UTF-8 char boundary before being prepended to the
623    /// sub-agent's task prompt.  Consistent with the `max_state_chars` naming convention.
624    ///
625    /// Default: `600` (≈200 tokens at 3 chars/token).
626    #[serde(default = "default_summary_max_chars")]
627    pub summary_max_chars: usize,
628    /// Maximum wall time in seconds for a single LLM call inside a sub-agent turn.
629    ///
630    /// If the provider does not return a response within this window, the call is
631    /// cancelled and the sub-agent turn fails with a timeout error. Default: 120.
632    #[serde(default = "default_llm_timeout_secs")]
633    pub llm_timeout_secs: u64,
634    /// Worktree isolation settings propagated from the top-level `[worktree]` section.
635    ///
636    /// Passed to the subagent manager's spawn function so it can determine whether
637    /// and how to create a per-agent git worktree without needing a reference to
638    /// the full `Config`.
639    ///
640    /// # Invariant
641    ///
642    /// This field is always populated from `Config::worktree` in `runner.rs` bootstrap.
643    /// Do not set defaults independently — changes here will not take effect in production
644    /// because the bootstrap overwrites this value before passing it to `SubAgentManager`.
645    #[serde(default)]
646    pub worktree: crate::worktree::WorktreeConfig,
647}
648
649impl Default for SubAgentConfig {
650    fn default() -> Self {
651        Self {
652            enabled: false,
653            max_concurrent: default_max_concurrent(),
654            extra_dirs: Vec::new(),
655            user_agents_dir: None,
656            default_permission_mode: None,
657            default_disallowed_tools: Vec::new(),
658            allow_bypass_permissions: false,
659            default_memory_scope: None,
660            hooks: SubAgentLifecycleHooks::default(),
661            transcript_dir: None,
662            transcript_enabled: default_transcript_enabled(),
663            transcript_max_files: default_transcript_max_files(),
664            forward_transcript: false,
665            context_window_turns: default_context_window_turns(),
666            max_spawn_depth: default_max_spawn_depth(),
667            context_injection_mode: ContextInjectionMode::default(),
668            parent_context_policy: ParentContextPolicy::default(),
669            max_parent_messages: default_max_parent_messages(),
670            summary_max_chars: default_summary_max_chars(),
671            llm_timeout_secs: default_llm_timeout_secs(),
672            worktree: crate::worktree::WorktreeConfig::default(),
673        }
674    }
675}
676
677/// Config-level lifecycle hooks fired when any sub-agent starts or stops.
678#[derive(Debug, Clone, Default, Deserialize, Serialize)]
679#[serde(default)]
680pub struct SubAgentLifecycleHooks {
681    /// Hooks run after a sub-agent is spawned (fire-and-forget).
682    pub start: Vec<HookDef>,
683    /// Hooks run after a sub-agent finishes or is cancelled (fire-and-forget).
684    pub stop: Vec<HookDef>,
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    #[test]
692    fn subagent_config_defaults() {
693        let cfg = SubAgentConfig::default();
694        assert_eq!(cfg.context_window_turns, 10);
695        assert_eq!(cfg.max_spawn_depth, 3);
696        assert_eq!(
697            cfg.context_injection_mode,
698            ContextInjectionMode::LastAssistantTurn
699        );
700        assert_eq!(
701            cfg.parent_context_policy,
702            ParentContextPolicy::InheritSanitized
703        );
704        assert_eq!(cfg.max_parent_messages, 20);
705        assert!(
706            !cfg.forward_transcript,
707            "forward_transcript must default to false (NFR-003)"
708        );
709    }
710
711    #[test]
712    fn subagent_config_deserialize_forward_transcript() {
713        let toml_str = "forward_transcript = true";
714        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
715        assert!(cfg.forward_transcript);
716    }
717
718    #[test]
719    fn subagent_config_forward_transcript_omitted_defaults_false() {
720        let toml_str = "enabled = true";
721        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
722        assert!(!cfg.forward_transcript);
723    }
724
725    #[test]
726    fn subagent_config_deserialize_new_fields() {
727        let toml_str = r#"
728            enabled = true
729            context_window_turns = 5
730            max_spawn_depth = 2
731            context_injection_mode = "none"
732        "#;
733        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
734        assert_eq!(cfg.context_window_turns, 5);
735        assert_eq!(cfg.max_spawn_depth, 2);
736        assert_eq!(cfg.context_injection_mode, ContextInjectionMode::None);
737    }
738
739    #[test]
740    fn subagent_config_deserialize_parent_context_policy() {
741        let toml_str = r#"
742            parent_context_policy = "none"
743            max_parent_messages = 10
744        "#;
745        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
746        assert_eq!(cfg.parent_context_policy, ParentContextPolicy::None);
747        assert_eq!(cfg.max_parent_messages, 10);
748    }
749
750    #[test]
751    fn subagent_config_deserialize_parent_context_policy_inherit_sanitized() {
752        let toml_str = r#"
753            parent_context_policy = "inherit_sanitized"
754        "#;
755        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
756        assert_eq!(
757            cfg.parent_context_policy,
758            ParentContextPolicy::InheritSanitized
759        );
760    }
761
762    #[test]
763    fn model_spec_deserialize_inherit() {
764        let spec: ModelSpec = serde_json::from_str("\"inherit\"").unwrap();
765        assert_eq!(spec, ModelSpec::Inherit);
766    }
767
768    #[test]
769    fn model_spec_deserialize_named() {
770        let spec: ModelSpec = serde_json::from_str("\"fast\"").unwrap();
771        assert_eq!(spec, ModelSpec::Named("fast".to_owned()));
772    }
773
774    #[test]
775    fn model_spec_as_str() {
776        assert_eq!(ModelSpec::Inherit.as_str(), "inherit");
777        assert_eq!(ModelSpec::Named("x".to_owned()).as_str(), "x");
778    }
779
780    #[test]
781    fn focus_config_auto_consolidate_min_window_default_is_six() {
782        let cfg = FocusConfig::default();
783        assert_eq!(cfg.auto_consolidate_min_window, 6);
784    }
785
786    #[test]
787    fn focus_config_auto_consolidate_min_window_deserializes() {
788        let toml_str = "auto_consolidate_min_window = 10";
789        let cfg: FocusConfig = toml::from_str(toml_str).unwrap();
790        assert_eq!(cfg.auto_consolidate_min_window, 10);
791    }
792
793    #[test]
794    fn goal_config_new_field_defaults() {
795        let cfg = GoalConfig::default();
796        assert_eq!(cfg.autonomous_turn_timeout_secs, 300);
797        assert_eq!(cfg.max_supervisor_fail_count, 3);
798    }
799
800    #[test]
801    fn goal_config_new_fields_deserialize() {
802        let toml_str = r"
803            autonomous_turn_timeout_secs = 120
804            max_supervisor_fail_count = 5
805        ";
806        let cfg: GoalConfig = toml::from_str(toml_str).unwrap();
807        assert_eq!(cfg.autonomous_turn_timeout_secs, 120);
808        assert_eq!(cfg.max_supervisor_fail_count, 5);
809    }
810
811    #[test]
812    fn goal_config_omitted_new_fields_use_defaults() {
813        let toml_str = "enabled = true";
814        let cfg: GoalConfig = toml::from_str(toml_str).unwrap();
815        assert_eq!(cfg.autonomous_turn_timeout_secs, 300);
816        assert_eq!(cfg.max_supervisor_fail_count, 3);
817    }
818}