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_max_spawns_per_session() -> usize {
230 100
231}
232
233fn default_context_window_turns() -> usize {
234 10
235}
236
237fn default_max_spawn_depth() -> u32 {
238 3
239}
240
241fn default_transcript_enabled() -> bool {
242 true
243}
244
245fn default_transcript_max_files() -> usize {
246 50
247}
248
249/// Configuration for focus-based active context compression (#1850).
250#[derive(Debug, Clone, Deserialize, Serialize)]
251#[serde(default)]
252pub struct FocusConfig {
253 /// Enable focus tools (`start_focus` / `complete_focus`). Default: `false`.
254 pub enabled: bool,
255 /// Suggest focus after this many turns without one. Default: `12`.
256 #[serde(default = "default_focus_compression_interval")]
257 pub compression_interval: usize,
258 /// Remind the agent every N turns when focus is overdue. Default: `15`.
259 #[serde(default = "default_focus_reminder_interval")]
260 pub reminder_interval: usize,
261 /// Minimum messages required before suggesting a focus. Default: `8`.
262 #[serde(default = "default_focus_min_messages_per_focus")]
263 pub min_messages_per_focus: usize,
264 /// Maximum tokens the Knowledge block may grow to before old entries are trimmed.
265 /// Default: `4096`.
266 #[serde(default = "default_focus_max_knowledge_tokens")]
267 pub max_knowledge_tokens: usize,
268 /// Minimum turns since the last auto-consolidation before the next one fires.
269 ///
270 /// Must be >= 1. `Config::validate()` rejects `0` at startup. Default: `6`.
271 #[serde(default = "default_focus_auto_consolidate_min_window")]
272 pub auto_consolidate_min_window: usize,
273}
274
275impl Default for FocusConfig {
276 fn default() -> Self {
277 Self {
278 enabled: false,
279 compression_interval: default_focus_compression_interval(),
280 reminder_interval: default_focus_reminder_interval(),
281 min_messages_per_focus: default_focus_min_messages_per_focus(),
282 max_knowledge_tokens: default_focus_max_knowledge_tokens(),
283 auto_consolidate_min_window: default_focus_auto_consolidate_min_window(),
284 }
285 }
286}
287
288/// Dynamic tool schema filtering configuration (#2020).
289///
290/// When enabled, only a subset of tool definitions is sent to the LLM on each turn,
291/// selected by embedding similarity between the user query and tool descriptions.
292#[derive(Debug, Clone, Deserialize, Serialize)]
293#[serde(default)]
294pub struct ToolFilterConfig {
295 /// Enable dynamic tool schema filtering. Default: `false` (opt-in).
296 pub enabled: bool,
297 /// Number of top-scoring filterable tools to include per turn.
298 /// Set to `0` to include all filterable tools.
299 #[serde(default = "default_tool_filter_top_k")]
300 pub top_k: usize,
301 /// Tool IDs that are never filtered out.
302 #[serde(default = "default_tool_filter_always_on")]
303 pub always_on: Vec<String>,
304 /// MCP tools with fewer description words than this are auto-included.
305 #[serde(default = "default_tool_filter_min_description_words")]
306 pub min_description_words: usize,
307}
308
309impl Default for ToolFilterConfig {
310 fn default() -> Self {
311 Self {
312 enabled: false,
313 top_k: default_tool_filter_top_k(),
314 always_on: default_tool_filter_always_on(),
315 min_description_words: default_tool_filter_min_description_words(),
316 }
317 }
318}
319
320/// Core agent behavior configuration, nested under `[agent]` in TOML.
321///
322/// Controls the agent's name, tool-loop limits, instruction loading, and retry
323/// behavior. All fields have sensible defaults; only `name` is typically changed
324/// by end users.
325///
326/// # Example (TOML)
327///
328/// ```toml
329/// [agent]
330/// name = "Zeph"
331/// max_tool_iterations = 15
332/// max_tool_retries = 3
333/// ```
334#[derive(Debug, Deserialize, Serialize)]
335#[allow(clippy::struct_excessive_bools)] // independent boolean flags; bitflags or enum would obscure semantics without reducing complexity
336pub struct AgentConfig {
337 /// Human-readable agent name surfaced in the TUI and Telegram header. Default: `"Zeph"`.
338 pub name: String,
339 /// Maximum number of tool-call iterations per agent turn before the loop is aborted.
340 /// Must be `<= 100`. Default: `10`.
341 #[serde(default = "default_max_tool_iterations")]
342 pub max_tool_iterations: usize,
343 /// Check for new Zeph releases at startup. Default: `true`.
344 #[serde(default = "default_auto_update_check")]
345 pub auto_update_check: bool,
346 /// Additional instruction files to always load, regardless of provider.
347 #[serde(default)]
348 pub instruction_files: Vec<std::path::PathBuf>,
349 /// When true, automatically detect provider-specific instruction files
350 /// (e.g. `CLAUDE.md` for Claude, `AGENTS.md` for `OpenAI`).
351 #[serde(default = "default_instruction_auto_detect")]
352 pub instruction_auto_detect: bool,
353 /// Maximum retry attempts for transient tool errors (0 to disable).
354 #[serde(default = "default_max_tool_retries")]
355 pub max_tool_retries: usize,
356 /// Number of identical tool+args calls within the recent window to trigger repeat-detection
357 /// abort (0 to disable).
358 #[serde(default = "default_tool_repeat_threshold")]
359 pub tool_repeat_threshold: usize,
360 /// Maximum total wall-clock time (seconds) to spend on retries for a single tool call.
361 #[serde(default = "default_max_retry_duration_secs")]
362 pub max_retry_duration_secs: u64,
363 /// Focus-based active context compression configuration (#1850).
364 #[serde(default)]
365 pub focus: FocusConfig,
366 /// Dynamic tool schema filtering configuration (#2020).
367 #[serde(default)]
368 pub tool_filter: ToolFilterConfig,
369 /// Inject a `<budget>` XML block into the volatile system prompt section so the LLM
370 /// can self-regulate tool calls and cost. Self-suppresses when no budget data is
371 /// available (#2267).
372 #[serde(default = "default_budget_hint_enabled")]
373 pub budget_hint_enabled: bool,
374 /// Background task supervisor tuning. Controls concurrency limits and turn-boundary abort.
375 #[serde(default)]
376 pub supervisor: TaskSupervisorConfig,
377 /// Inject a `<current_time>` reminder into the volatile system prompt block every N agent
378 /// turns (#6361, spec 070 FR-003). Opt-in — defaults to `false` so existing prompt content
379 /// and token budget are unaffected unless explicitly enabled (NFR-005). Complementary to
380 /// the always-available `get_current_time` tool, which covers time-awareness within a
381 /// single long-running turn where this per-turn injection cannot re-fire.
382 #[serde(default = "default_time_reminder_enabled")]
383 pub time_reminder_enabled: bool,
384 /// Number of agent turns between `<current_time>` reminder injections when
385 /// `time_reminder_enabled = true` (#6361, spec 070 FR-004). Named after Codex's
386 /// `reminder_interval_model_requests`, but counts agent turn-cycles (`sidequest.turn_counter`)
387 /// rather than individual model requests — the mandated injection hook
388 /// (`rebuild_system_prompt`) runs once per turn, before the tool loop, so a literal
389 /// per-model-request cadence is unreachable there.
390 #[serde(default = "default_time_reminder_interval_requests")]
391 pub time_reminder_interval_requests: u32,
392}
393
394fn default_budget_hint_enabled() -> bool {
395 true
396}
397
398fn default_time_reminder_enabled() -> bool {
399 false
400}
401
402fn default_time_reminder_interval_requests() -> u32 {
403 10
404}
405
406fn default_goal_max_text_chars() -> usize {
407 2000
408}
409
410fn default_goal_max_history() -> usize {
411 50
412}
413
414fn default_autonomous_max_turns() -> u32 {
415 20
416}
417
418fn default_verify_interval() -> u32 {
419 5
420}
421
422fn default_supervisor_timeout_secs() -> u64 {
423 30
424}
425
426fn default_max_stuck_count() -> u32 {
427 3
428}
429
430fn default_autonomous_turn_delay_ms() -> u64 {
431 500
432}
433
434fn default_autonomous_turn_timeout_secs() -> u64 {
435 300
436}
437
438fn default_max_supervisor_fail_count() -> u32 {
439 3
440}
441
442/// Long-horizon goal lifecycle configuration (`[goals]` TOML section).
443///
444/// When enabled, the agent tracks a single active goal across turns, injecting an
445/// `<active_goal>` block into the volatile system-prompt region and accounting for
446/// token consumption per turn.
447///
448/// Set `autonomous_enabled = true` to allow the agent to run multi-turn goal execution
449/// without waiting for user input between turns. A supervisor LLM call periodically checks
450/// whether the goal condition has been satisfied.
451///
452/// # Example (TOML)
453///
454/// ```toml
455/// [goals]
456/// enabled = true
457/// autonomous_enabled = true
458/// autonomous_max_turns = 20
459/// supervisor_provider = "fast"
460/// verify_interval = 5
461/// supervisor_timeout_secs = 30
462/// max_stuck_count = 3
463/// autonomous_turn_delay_ms = 500
464/// default_token_budget = 50000
465/// ```
466#[derive(Debug, Clone, Deserialize, Serialize)]
467#[serde(default)]
468pub struct GoalConfig {
469 /// Enable the goal lifecycle subsystem. Default: `false`.
470 pub enabled: bool,
471 /// Inject `<active_goal>` block into the volatile system-prompt region. Default: `true`.
472 pub inject_into_system_prompt: bool,
473 /// Maximum characters allowed for goal text at creation time. Default: `2000`.
474 #[serde(default = "default_goal_max_text_chars")]
475 pub max_text_chars: usize,
476 /// Default token budget for new goals (`None` = unlimited). Default: `None`.
477 pub default_token_budget: Option<u64>,
478 /// Maximum number of goals to return in `/goal list`. Default: `50`.
479 #[serde(default = "default_goal_max_history")]
480 pub max_history: usize,
481 /// Enable autonomous multi-turn execution mode (`/goal create ... --auto`). Default: `false`.
482 pub autonomous_enabled: bool,
483 /// Maximum number of turns the agent may run without user input per session. Default: `20`.
484 #[serde(default = "default_autonomous_max_turns")]
485 pub autonomous_max_turns: u32,
486 /// Provider name for the supervisor verifier LLM call (references `[[llm.providers]] name`).
487 /// Falls back to the main provider when `None`.
488 pub supervisor_provider: Option<ProviderName>,
489 /// How many turns to execute between supervisor verification checks. Default: `5`.
490 #[serde(default = "default_verify_interval")]
491 pub verify_interval: u32,
492 /// Timeout in seconds for a single supervisor verification LLM call. Default: `30`.
493 #[serde(default = "default_supervisor_timeout_secs")]
494 pub supervisor_timeout_secs: u64,
495 /// Maximum consecutive stuck-turn detections before the session is aborted. Default: `3`.
496 #[serde(default = "default_max_stuck_count")]
497 pub max_stuck_count: u32,
498 /// Delay in milliseconds between autonomous turns to avoid busy-looping. Default: `500`.
499 #[serde(default = "default_autonomous_turn_delay_ms")]
500 pub autonomous_turn_delay_ms: u64,
501 /// Maximum wall-clock time in seconds for a single autonomous LLM turn before it is
502 /// cancelled and the session transitions to `Stuck`. Default: `300` (5 minutes).
503 #[serde(default = "default_autonomous_turn_timeout_secs")]
504 pub autonomous_turn_timeout_secs: u64,
505 /// Maximum consecutive supervisor verification failures before the session is paused.
506 /// Default: `3`.
507 #[serde(default = "default_max_supervisor_fail_count")]
508 pub max_supervisor_fail_count: u32,
509}
510
511impl Default for GoalConfig {
512 fn default() -> Self {
513 Self {
514 enabled: false,
515 inject_into_system_prompt: true,
516 max_text_chars: default_goal_max_text_chars(),
517 default_token_budget: None,
518 max_history: default_goal_max_history(),
519 autonomous_enabled: false,
520 autonomous_max_turns: default_autonomous_max_turns(),
521 supervisor_provider: None,
522 verify_interval: default_verify_interval(),
523 supervisor_timeout_secs: default_supervisor_timeout_secs(),
524 max_stuck_count: default_max_stuck_count(),
525 autonomous_turn_delay_ms: default_autonomous_turn_delay_ms(),
526 autonomous_turn_timeout_secs: default_autonomous_turn_timeout_secs(),
527 max_supervisor_fail_count: default_max_supervisor_fail_count(),
528 }
529 }
530}
531
532fn default_enrichment_limit() -> usize {
533 4
534}
535
536fn default_telemetry_limit() -> usize {
537 8
538}
539
540fn default_background_shell_limit() -> usize {
541 8
542}
543
544/// Background task supervisor configuration, nested under `[agent.supervisor]` in TOML.
545///
546/// Controls per-class concurrency limits and turn-boundary behaviour for the
547/// `BackgroundSupervisor` in `zeph-core`.
548/// All fields have sensible defaults that match the Phase 1 hardcoded values; only change
549/// these if you observe excessive background task drops under load.
550///
551/// # Example (TOML)
552///
553/// ```toml
554/// [agent.supervisor]
555/// enrichment_limit = 4
556/// telemetry_limit = 8
557/// abort_enrichment_on_turn = false
558/// ```
559#[derive(Debug, Clone, Deserialize, Serialize)]
560#[serde(default)]
561pub struct TaskSupervisorConfig {
562 /// Maximum concurrent enrichment tasks (summarization, graph/persona/trajectory extraction).
563 /// Default: `4`.
564 #[serde(default = "default_enrichment_limit")]
565 pub enrichment_limit: usize,
566 /// Maximum concurrent telemetry tasks (audit log writes, graph count sync).
567 /// Default: `8`.
568 #[serde(default = "default_telemetry_limit")]
569 pub telemetry_limit: usize,
570 /// Abort all inflight enrichment tasks at turn boundary to prevent backlog buildup.
571 /// Default: `false`.
572 #[serde(default)]
573 pub abort_enrichment_on_turn: bool,
574 /// Maximum concurrent background shell runs tracked by the supervisor.
575 ///
576 /// Should match `tools.shell.max_background_runs` so both layers agree on capacity.
577 /// Default: `8`.
578 #[serde(default = "default_background_shell_limit")]
579 pub background_shell_limit: usize,
580}
581
582impl Default for TaskSupervisorConfig {
583 fn default() -> Self {
584 Self {
585 enrichment_limit: default_enrichment_limit(),
586 telemetry_limit: default_telemetry_limit(),
587 abort_enrichment_on_turn: false,
588 background_shell_limit: default_background_shell_limit(),
589 }
590 }
591}
592
593/// Sub-agent pool configuration, nested under `[agents]` in TOML.
594///
595/// When `enabled = true`, the agent can spawn isolated sub-agent sessions from
596/// SKILL.md-based agent definitions. Sub-agents inherit the parent's provider pool
597/// unless overridden by `model` in their definition frontmatter.
598///
599/// # Example (TOML)
600///
601/// ```toml
602/// [agents]
603/// enabled = true
604/// delegation_mode = "explicit_request_only"
605/// max_concurrent = 3
606/// max_spawn_depth = 2
607/// max_spawns_per_session = 50
608/// ```
609#[derive(Debug, Clone, Deserialize, Serialize)]
610#[serde(default)]
611#[allow(clippy::struct_excessive_bools)] // independent config toggles; bitflags or enum would obscure semantics without reducing complexity
612pub struct SubAgentConfig {
613 /// Enable the sub-agent subsystem. Default: `false`.
614 ///
615 /// Outer kill switch: when `false`, the effective [`delegation_mode`][Self::delegation_mode]
616 /// is always [`DelegationMode::Disabled`] regardless of that field's configured value
617 /// (spec `042-subagent-delegation-mode-parity` FR-002).
618 pub enabled: bool,
619 /// Whether the main agent may spawn sub-agents, and who may trigger it: `disabled` /
620 /// `explicit_request_only` / `proactive`. Default: [`DelegationMode::Proactive`] (preserves
621 /// the subsystem's unconstrained behavior prior to this field's introduction, per FR-008).
622 /// Overridable via `ZEPH_AGENTS_DELEGATION_MODE` or the `--delegation-mode` CLI flag.
623 #[serde(default)]
624 pub delegation_mode: DelegationMode,
625 /// Maximum number of sub-agents that can run concurrently.
626 #[serde(default = "default_max_concurrent")]
627 pub max_concurrent: usize,
628 /// Maximum cumulative number of sub-agents that may be spawned within a single session,
629 /// independent of [`max_concurrent`][Self::max_concurrent] (in-flight limit) and
630 /// [`max_spawn_depth`][Self::max_spawn_depth] (recursion limit). Guards against a shallow,
631 /// low-concurrency but high-frequency sequential delegation loop that neither of those
632 /// limits would catch (issue #6545). `0` = unlimited. The counter resets at session start
633 /// and is shared across every spawn chokepoint (`SubAgentManager::spawn`/`resume` and the
634 /// ACP `/subagent spawn` path). Default: `100`.
635 #[serde(default = "default_max_spawns_per_session")]
636 pub max_spawns_per_session: usize,
637 /// Additional directories to search for `.agent.md` definition files.
638 pub extra_dirs: Vec<PathBuf>,
639 /// User-level agents directory.
640 #[serde(default)]
641 pub user_agents_dir: Option<PathBuf>,
642 /// Default permission mode applied to sub-agents that do not specify one.
643 pub default_permission_mode: Option<PermissionMode>,
644 /// Global denylist applied to all sub-agents in addition to per-agent `tools.except`.
645 #[serde(default)]
646 pub default_disallowed_tools: Vec<String>,
647 /// Allow sub-agents to use `bypass_permissions` mode.
648 #[serde(default)]
649 pub allow_bypass_permissions: bool,
650 /// Default memory scope applied to sub-agents that do not set `memory` in their definition.
651 #[serde(default)]
652 pub default_memory_scope: Option<MemoryScope>,
653 /// Lifecycle hooks executed when any sub-agent starts or stops.
654 #[serde(default)]
655 pub hooks: SubAgentLifecycleHooks,
656 /// Directory where transcript JSONL files and meta sidecars are stored.
657 #[serde(default)]
658 pub transcript_dir: Option<PathBuf>,
659 /// Enable writing JSONL transcripts for sub-agent sessions.
660 #[serde(default = "default_transcript_enabled")]
661 pub transcript_enabled: bool,
662 /// Maximum number of `.jsonl` transcript files to keep.
663 #[serde(default = "default_transcript_max_files")]
664 pub transcript_max_files: usize,
665 /// Forward each running sub-agent's full, untruncated per-turn text/thinking output to
666 /// an active consumer surface (TUI runtime detail view and/or `--bare` stdout) as it is
667 /// produced, instead of only the 120-char once-per-turn status snippet (issue #6359,
668 /// spec `068-subagent-transcript-forward`). Default: `false` — disabling it (the
669 /// default) preserves today's exact `SubAgentStatus`/`collect()` behavior byte-for-byte.
670 /// Mirrors `CLAUDE_CODE_FORWARD_SUBAGENT_TEXT`; overridable via
671 /// `ZEPH_AGENTS_FORWARD_TRANSCRIPT` or the `--forward-subagent-text` CLI flag.
672 #[serde(default)]
673 pub forward_transcript: bool,
674 /// Number of recent parent conversation turns to pass to spawned sub-agents.
675 /// Set to 0 to disable history propagation.
676 #[serde(default = "default_context_window_turns")]
677 pub context_window_turns: usize,
678 /// Maximum nesting depth for sub-agent spawns.
679 #[serde(default = "default_max_spawn_depth")]
680 pub max_spawn_depth: u32,
681 /// How parent context is injected into the sub-agent's task prompt.
682 #[serde(default)]
683 pub context_injection_mode: ContextInjectionMode,
684 /// Whether to sanitize parent conversation history before passing to a spawned sub-agent.
685 ///
686 /// Defaults to [`ParentContextPolicy::InheritSanitized`] which runs each text message part
687 /// through the IPI sanitizer, stripping prompt-injection payloads that may have entered the
688 /// parent history via tool results, web scrapes, or A2A messages.
689 #[serde(default)]
690 pub parent_context_policy: ParentContextPolicy,
691 /// Maximum number of parent messages to inject, independent of `context_window_turns`.
692 ///
693 /// Acts as a hard upper bound on context propagation volume to limit the blast radius
694 /// of poisoned histories. When `max_parent_messages < context_window_turns * 2` this cap
695 /// wins and fewer messages are passed; otherwise `context_window_turns * 2` is the binding
696 /// limit. The tighter of the two limits always applies.
697 #[serde(default = "default_max_parent_messages")]
698 pub max_parent_messages: usize,
699 /// Maximum character count for the `Summary` context injection mode.
700 ///
701 /// When `context_injection_mode = "summary"`, the extracted summary is truncated
702 /// to this many characters at a UTF-8 char boundary before being prepended to the
703 /// sub-agent's task prompt. Consistent with the `max_state_chars` naming convention.
704 ///
705 /// Default: `600` (≈200 tokens at 3 chars/token).
706 #[serde(default = "default_summary_max_chars")]
707 pub summary_max_chars: usize,
708 /// Maximum wall time in seconds for a single LLM call inside a sub-agent turn.
709 ///
710 /// If the provider does not return a response within this window, the call is
711 /// cancelled and the sub-agent turn fails with a timeout error. Default: 120.
712 #[serde(default = "default_llm_timeout_secs")]
713 pub llm_timeout_secs: u64,
714 /// Worktree isolation settings propagated from the top-level `[worktree]` section.
715 ///
716 /// Passed to the subagent manager's spawn function so it can determine whether
717 /// and how to create a per-agent git worktree without needing a reference to
718 /// the full `Config`.
719 ///
720 /// # Invariant
721 ///
722 /// This field is always populated from `Config::worktree` in `runner.rs` bootstrap.
723 /// Do not set defaults independently — changes here will not take effect in production
724 /// because the bootstrap overwrites this value before passing it to `SubAgentManager`.
725 #[serde(default)]
726 pub worktree: crate::worktree::WorktreeConfig,
727}
728
729impl Default for SubAgentConfig {
730 fn default() -> Self {
731 Self {
732 enabled: false,
733 delegation_mode: DelegationMode::default(),
734 max_concurrent: default_max_concurrent(),
735 max_spawns_per_session: default_max_spawns_per_session(),
736 extra_dirs: Vec::new(),
737 user_agents_dir: None,
738 default_permission_mode: None,
739 default_disallowed_tools: Vec::new(),
740 allow_bypass_permissions: false,
741 default_memory_scope: None,
742 hooks: SubAgentLifecycleHooks::default(),
743 transcript_dir: None,
744 transcript_enabled: default_transcript_enabled(),
745 transcript_max_files: default_transcript_max_files(),
746 forward_transcript: false,
747 context_window_turns: default_context_window_turns(),
748 max_spawn_depth: default_max_spawn_depth(),
749 context_injection_mode: ContextInjectionMode::default(),
750 parent_context_policy: ParentContextPolicy::default(),
751 max_parent_messages: default_max_parent_messages(),
752 summary_max_chars: default_summary_max_chars(),
753 llm_timeout_secs: default_llm_timeout_secs(),
754 worktree: crate::worktree::WorktreeConfig::default(),
755 }
756 }
757}
758
759impl SubAgentConfig {
760 /// Resolve [`enabled`][Self::enabled] and [`delegation_mode`][Self::delegation_mode] into
761 /// the single effective mode that must be enforced at every spawn call site (spec
762 /// `042-subagent-delegation-mode-parity` FR-002, issue #5857).
763 ///
764 /// `enabled` is the outer kill switch: `enabled = false` always resolves to
765 /// [`DelegationMode::Disabled`], regardless of the configured `delegation_mode` value.
766 ///
767 /// # Examples
768 ///
769 /// ```rust
770 /// use zeph_config::{DelegationMode, SubAgentConfig};
771 ///
772 /// let mut cfg = SubAgentConfig {
773 /// enabled: false,
774 /// delegation_mode: DelegationMode::Proactive,
775 /// ..SubAgentConfig::default()
776 /// };
777 /// assert_eq!(cfg.effective_delegation_mode(), DelegationMode::Disabled);
778 ///
779 /// cfg.enabled = true;
780 /// assert_eq!(cfg.effective_delegation_mode(), DelegationMode::Proactive);
781 /// ```
782 #[must_use]
783 pub fn effective_delegation_mode(&self) -> DelegationMode {
784 if self.enabled {
785 self.delegation_mode
786 } else {
787 DelegationMode::Disabled
788 }
789 }
790}
791
792/// Config-level lifecycle hooks fired when any sub-agent starts or stops.
793#[derive(Debug, Clone, Default, Deserialize, Serialize)]
794#[serde(default)]
795pub struct SubAgentLifecycleHooks {
796 /// Hooks run after a sub-agent is spawned (fire-and-forget).
797 pub start: Vec<HookDef>,
798 /// Hooks run after a sub-agent finishes or is cancelled (fire-and-forget).
799 pub stop: Vec<HookDef>,
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805
806 #[test]
807 fn subagent_config_defaults() {
808 let cfg = SubAgentConfig::default();
809 assert_eq!(cfg.context_window_turns, 10);
810 assert_eq!(cfg.max_spawn_depth, 3);
811 assert_eq!(
812 cfg.context_injection_mode,
813 ContextInjectionMode::LastAssistantTurn
814 );
815 assert_eq!(
816 cfg.parent_context_policy,
817 ParentContextPolicy::InheritSanitized
818 );
819 assert_eq!(cfg.max_parent_messages, 20);
820 assert!(
821 !cfg.forward_transcript,
822 "forward_transcript must default to false (NFR-003)"
823 );
824 }
825
826 #[test]
827 fn subagent_config_max_spawns_per_session_default_direct() {
828 // Direct-Default assertion (distinct from the serde round-trip below): catches a
829 // forgotten `impl Default` entry, which the per-field serde attribute would otherwise
830 // mask on every serde-loaded path while every non-serde caller silently got `0`.
831 assert_eq!(SubAgentConfig::default().max_spawns_per_session, 100);
832 }
833
834 #[test]
835 fn subagent_config_max_spawns_per_session_omitted_key_defaults_100() {
836 // Serde round-trip: a present `[agents]` section with the key absent must not
837 // silently resolve to `0` (unlimited) — that would recreate the exact "safety net
838 // ships disabled" defect class #6469/PR #6528 fixed for the tool-call/cost guardrails.
839 let toml_str = "enabled = true\nmax_concurrent = 3";
840 let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
841 assert_eq!(cfg.max_spawns_per_session, 100);
842 }
843
844 #[test]
845 fn subagent_config_deserialize_max_spawns_per_session() {
846 let toml_str = "max_spawns_per_session = 50";
847 let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
848 assert_eq!(cfg.max_spawns_per_session, 50);
849 }
850
851 #[test]
852 fn subagent_config_delegation_mode_defaults_proactive() {
853 let cfg = SubAgentConfig::default();
854 assert_eq!(cfg.delegation_mode, DelegationMode::Proactive);
855 }
856
857 #[test]
858 fn subagent_config_deserialize_delegation_mode() {
859 let toml_str = "enabled = true\ndelegation_mode = \"explicit_request_only\"";
860 let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
861 assert_eq!(cfg.delegation_mode, DelegationMode::ExplicitRequestOnly);
862 }
863
864 #[test]
865 fn subagent_config_delegation_mode_omitted_defaults_proactive() {
866 let toml_str = "enabled = true";
867 let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
868 assert_eq!(cfg.delegation_mode, DelegationMode::Proactive);
869 }
870
871 #[test]
872 fn subagent_config_delegation_mode_rejects_unknown_value() {
873 let toml_str = "delegation_mode = \"sometimes\"";
874 let result: Result<SubAgentConfig, _> = toml::from_str(toml_str);
875 assert!(result.is_err(), "unrecognized value must fail to parse");
876 }
877
878 #[test]
879 fn permits_explicit_allow_list() {
880 assert!(DelegationMode::Proactive.permits_explicit());
881 assert!(DelegationMode::ExplicitRequestOnly.permits_explicit());
882 assert!(!DelegationMode::Disabled.permits_explicit());
883 }
884
885 #[test]
886 fn effective_delegation_mode_disabled_when_not_enabled() {
887 let cfg = SubAgentConfig {
888 enabled: false,
889 delegation_mode: DelegationMode::Proactive,
890 ..SubAgentConfig::default()
891 };
892 assert_eq!(cfg.effective_delegation_mode(), DelegationMode::Disabled);
893 }
894
895 #[test]
896 fn effective_delegation_mode_passes_through_when_enabled() {
897 for mode in [
898 DelegationMode::Disabled,
899 DelegationMode::ExplicitRequestOnly,
900 DelegationMode::Proactive,
901 ] {
902 let cfg = SubAgentConfig {
903 enabled: true,
904 delegation_mode: mode,
905 ..SubAgentConfig::default()
906 };
907 assert_eq!(cfg.effective_delegation_mode(), mode);
908 }
909 }
910
911 #[test]
912 fn subagent_config_deserialize_forward_transcript() {
913 let toml_str = "forward_transcript = true";
914 let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
915 assert!(cfg.forward_transcript);
916 }
917
918 #[test]
919 fn subagent_config_forward_transcript_omitted_defaults_false() {
920 let toml_str = "enabled = true";
921 let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
922 assert!(!cfg.forward_transcript);
923 }
924
925 #[test]
926 fn subagent_config_deserialize_new_fields() {
927 let toml_str = r#"
928 enabled = true
929 context_window_turns = 5
930 max_spawn_depth = 2
931 context_injection_mode = "none"
932 "#;
933 let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
934 assert_eq!(cfg.context_window_turns, 5);
935 assert_eq!(cfg.max_spawn_depth, 2);
936 assert_eq!(cfg.context_injection_mode, ContextInjectionMode::None);
937 }
938
939 #[test]
940 fn subagent_config_deserialize_parent_context_policy() {
941 let toml_str = r#"
942 parent_context_policy = "none"
943 max_parent_messages = 10
944 "#;
945 let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
946 assert_eq!(cfg.parent_context_policy, ParentContextPolicy::None);
947 assert_eq!(cfg.max_parent_messages, 10);
948 }
949
950 #[test]
951 fn subagent_config_deserialize_parent_context_policy_inherit_sanitized() {
952 let toml_str = r#"
953 parent_context_policy = "inherit_sanitized"
954 "#;
955 let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
956 assert_eq!(
957 cfg.parent_context_policy,
958 ParentContextPolicy::InheritSanitized
959 );
960 }
961
962 #[test]
963 fn model_spec_deserialize_inherit() {
964 let spec: ModelSpec = serde_json::from_str("\"inherit\"").unwrap();
965 assert_eq!(spec, ModelSpec::Inherit);
966 }
967
968 #[test]
969 fn model_spec_deserialize_named() {
970 let spec: ModelSpec = serde_json::from_str("\"fast\"").unwrap();
971 assert_eq!(spec, ModelSpec::Named("fast".to_owned()));
972 }
973
974 #[test]
975 fn model_spec_as_str() {
976 assert_eq!(ModelSpec::Inherit.as_str(), "inherit");
977 assert_eq!(ModelSpec::Named("x".to_owned()).as_str(), "x");
978 }
979
980 #[test]
981 fn focus_config_auto_consolidate_min_window_default_is_six() {
982 let cfg = FocusConfig::default();
983 assert_eq!(cfg.auto_consolidate_min_window, 6);
984 }
985
986 #[test]
987 fn focus_config_auto_consolidate_min_window_deserializes() {
988 let toml_str = "auto_consolidate_min_window = 10";
989 let cfg: FocusConfig = toml::from_str(toml_str).unwrap();
990 assert_eq!(cfg.auto_consolidate_min_window, 10);
991 }
992
993 #[test]
994 fn goal_config_new_field_defaults() {
995 let cfg = GoalConfig::default();
996 assert_eq!(cfg.autonomous_turn_timeout_secs, 300);
997 assert_eq!(cfg.max_supervisor_fail_count, 3);
998 }
999
1000 #[test]
1001 fn goal_config_new_fields_deserialize() {
1002 let toml_str = r"
1003 autonomous_turn_timeout_secs = 120
1004 max_supervisor_fail_count = 5
1005 ";
1006 let cfg: GoalConfig = toml::from_str(toml_str).unwrap();
1007 assert_eq!(cfg.autonomous_turn_timeout_secs, 120);
1008 assert_eq!(cfg.max_supervisor_fail_count, 5);
1009 }
1010
1011 #[test]
1012 fn goal_config_omitted_new_fields_use_defaults() {
1013 let toml_str = "enabled = true";
1014 let cfg: GoalConfig = toml::from_str(toml_str).unwrap();
1015 assert_eq!(cfg.autonomous_turn_timeout_secs, 300);
1016 assert_eq!(cfg.max_supervisor_fail_count, 3);
1017 }
1018}