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)]
15pub enum ModelSpec {
16    /// Use the parent agent's active provider at spawn time.
17    Inherit,
18    /// Use a specific named provider from `[[llm.providers]]`.
19    Named(String),
20}
21
22impl ModelSpec {
23    /// Return the string representation: `"inherit"` or the provider name.
24    #[must_use]
25    pub fn as_str(&self) -> &str {
26        match self {
27            ModelSpec::Inherit => "inherit",
28            ModelSpec::Named(s) => s.as_str(),
29        }
30    }
31}
32
33impl Serialize for ModelSpec {
34    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
35        match self {
36            ModelSpec::Inherit => serializer.serialize_str("inherit"),
37            ModelSpec::Named(s) => serializer.serialize_str(s),
38        }
39    }
40}
41
42impl<'de> Deserialize<'de> for ModelSpec {
43    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
44        let s = String::deserialize(deserializer)?;
45        if s == "inherit" {
46            Ok(ModelSpec::Inherit)
47        } else {
48            Ok(ModelSpec::Named(s))
49        }
50    }
51}
52
53/// Controls how the parent agent's conversation history is sanitized before passing to a
54/// spawned sub-agent.
55///
56/// Prompt injection is a documented attack vector when the parent history contains untrusted
57/// content from web scrapes, tool results, or A2A messages.  `InheritSanitized` is the safe
58/// default: messages pass through `ContentSanitizer` (in `zeph-sanitizer`) before injection.
59///
60/// # Examples
61///
62/// ```toml
63/// [subagent]
64/// parent_context_policy = "inherit_sanitized"   # default
65/// ```
66#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
67#[serde(rename_all = "snake_case")]
68pub enum ParentContextPolicy {
69    /// Pass the parent history verbatim — legacy behaviour, no sanitization.
70    Inherit,
71    /// Sanitize text parts of each message through the IPI pipeline before injection.
72    #[default]
73    InheritSanitized,
74    /// Do not inject any parent history into the sub-agent context.
75    None,
76}
77
78/// Controls how parent agent context is injected into a spawned sub-agent's task prompt.
79#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
80#[serde(rename_all = "snake_case")]
81pub enum ContextInjectionMode {
82    /// No parent context injected.
83    None,
84    /// Prepend the last assistant turn from parent history as a preamble.
85    #[default]
86    LastAssistantTurn,
87    /// LLM-generated summary of parent context (not yet implemented in Phase 1).
88    Summary,
89}
90
91fn default_max_parent_messages() -> usize {
92    20
93}
94
95fn default_summary_max_chars() -> usize {
96    600
97}
98
99fn default_max_tool_iterations() -> usize {
100    10
101}
102
103fn default_auto_update_check() -> bool {
104    true
105}
106
107fn default_focus_compression_interval() -> usize {
108    12
109}
110
111fn default_focus_reminder_interval() -> usize {
112    15
113}
114
115fn default_focus_min_messages_per_focus() -> usize {
116    8
117}
118
119fn default_focus_max_knowledge_tokens() -> usize {
120    4096
121}
122
123fn default_focus_auto_consolidate_min_window() -> usize {
124    6
125}
126
127fn default_max_tool_retries() -> usize {
128    2
129}
130
131fn default_max_retry_duration_secs() -> u64 {
132    30
133}
134
135fn default_tool_repeat_threshold() -> usize {
136    2
137}
138
139fn default_tool_filter_top_k() -> usize {
140    6
141}
142
143fn default_tool_filter_min_description_words() -> usize {
144    5
145}
146
147fn default_tool_filter_always_on() -> Vec<String> {
148    vec![
149        "memory_search".into(),
150        "memory_save".into(),
151        "load_skill".into(),
152        "invoke_skill".into(),
153        "bash".into(),
154        "read".into(),
155        "edit".into(),
156    ]
157}
158
159fn default_instruction_auto_detect() -> bool {
160    true
161}
162
163fn default_max_concurrent() -> usize {
164    5
165}
166
167fn default_context_window_turns() -> usize {
168    10
169}
170
171fn default_max_spawn_depth() -> u32 {
172    3
173}
174
175fn default_transcript_enabled() -> bool {
176    true
177}
178
179fn default_transcript_max_files() -> usize {
180    50
181}
182
183/// Configuration for focus-based active context compression (#1850).
184#[derive(Debug, Clone, Deserialize, Serialize)]
185#[serde(default)]
186pub struct FocusConfig {
187    /// Enable focus tools (`start_focus` / `complete_focus`). Default: `false`.
188    pub enabled: bool,
189    /// Suggest focus after this many turns without one. Default: `12`.
190    #[serde(default = "default_focus_compression_interval")]
191    pub compression_interval: usize,
192    /// Remind the agent every N turns when focus is overdue. Default: `15`.
193    #[serde(default = "default_focus_reminder_interval")]
194    pub reminder_interval: usize,
195    /// Minimum messages required before suggesting a focus. Default: `8`.
196    #[serde(default = "default_focus_min_messages_per_focus")]
197    pub min_messages_per_focus: usize,
198    /// Maximum tokens the Knowledge block may grow to before old entries are trimmed.
199    /// Default: `4096`.
200    #[serde(default = "default_focus_max_knowledge_tokens")]
201    pub max_knowledge_tokens: usize,
202    /// Minimum turns since the last auto-consolidation before the next one fires.
203    ///
204    /// Must be >= 1. `Config::validate()` rejects `0` at startup. Default: `6`.
205    #[serde(default = "default_focus_auto_consolidate_min_window")]
206    pub auto_consolidate_min_window: usize,
207}
208
209impl Default for FocusConfig {
210    fn default() -> Self {
211        Self {
212            enabled: false,
213            compression_interval: default_focus_compression_interval(),
214            reminder_interval: default_focus_reminder_interval(),
215            min_messages_per_focus: default_focus_min_messages_per_focus(),
216            max_knowledge_tokens: default_focus_max_knowledge_tokens(),
217            auto_consolidate_min_window: default_focus_auto_consolidate_min_window(),
218        }
219    }
220}
221
222/// Dynamic tool schema filtering configuration (#2020).
223///
224/// When enabled, only a subset of tool definitions is sent to the LLM on each turn,
225/// selected by embedding similarity between the user query and tool descriptions.
226#[derive(Debug, Clone, Deserialize, Serialize)]
227#[serde(default)]
228pub struct ToolFilterConfig {
229    /// Enable dynamic tool schema filtering. Default: `false` (opt-in).
230    pub enabled: bool,
231    /// Number of top-scoring filterable tools to include per turn.
232    /// Set to `0` to include all filterable tools.
233    #[serde(default = "default_tool_filter_top_k")]
234    pub top_k: usize,
235    /// Tool IDs that are never filtered out.
236    #[serde(default = "default_tool_filter_always_on")]
237    pub always_on: Vec<String>,
238    /// MCP tools with fewer description words than this are auto-included.
239    #[serde(default = "default_tool_filter_min_description_words")]
240    pub min_description_words: usize,
241}
242
243impl Default for ToolFilterConfig {
244    fn default() -> Self {
245        Self {
246            enabled: false,
247            top_k: default_tool_filter_top_k(),
248            always_on: default_tool_filter_always_on(),
249            min_description_words: default_tool_filter_min_description_words(),
250        }
251    }
252}
253
254/// Core agent behavior configuration, nested under `[agent]` in TOML.
255///
256/// Controls the agent's name, tool-loop limits, instruction loading, and retry
257/// behavior. All fields have sensible defaults; only `name` is typically changed
258/// by end users.
259///
260/// # Example (TOML)
261///
262/// ```toml
263/// [agent]
264/// name = "Zeph"
265/// max_tool_iterations = 15
266/// max_tool_retries = 3
267/// ```
268#[derive(Debug, Deserialize, Serialize)]
269pub struct AgentConfig {
270    /// Human-readable agent name surfaced in the TUI and Telegram header. Default: `"Zeph"`.
271    pub name: String,
272    /// Maximum number of tool-call iterations per agent turn before the loop is aborted.
273    /// Must be `<= 100`. Default: `10`.
274    #[serde(default = "default_max_tool_iterations")]
275    pub max_tool_iterations: usize,
276    /// Check for new Zeph releases at startup. Default: `true`.
277    #[serde(default = "default_auto_update_check")]
278    pub auto_update_check: bool,
279    /// Additional instruction files to always load, regardless of provider.
280    #[serde(default)]
281    pub instruction_files: Vec<std::path::PathBuf>,
282    /// When true, automatically detect provider-specific instruction files
283    /// (e.g. `CLAUDE.md` for Claude, `AGENTS.md` for `OpenAI`).
284    #[serde(default = "default_instruction_auto_detect")]
285    pub instruction_auto_detect: bool,
286    /// Maximum retry attempts for transient tool errors (0 to disable).
287    #[serde(default = "default_max_tool_retries")]
288    pub max_tool_retries: usize,
289    /// Number of identical tool+args calls within the recent window to trigger repeat-detection
290    /// abort (0 to disable).
291    #[serde(default = "default_tool_repeat_threshold")]
292    pub tool_repeat_threshold: usize,
293    /// Maximum total wall-clock time (seconds) to spend on retries for a single tool call.
294    #[serde(default = "default_max_retry_duration_secs")]
295    pub max_retry_duration_secs: u64,
296    /// Focus-based active context compression configuration (#1850).
297    #[serde(default)]
298    pub focus: FocusConfig,
299    /// Dynamic tool schema filtering configuration (#2020).
300    #[serde(default)]
301    pub tool_filter: ToolFilterConfig,
302    /// Inject a `<budget>` XML block into the volatile system prompt section so the LLM
303    /// can self-regulate tool calls and cost. Self-suppresses when no budget data is
304    /// available (#2267).
305    #[serde(default = "default_budget_hint_enabled")]
306    pub budget_hint_enabled: bool,
307    /// Background task supervisor tuning. Controls concurrency limits and turn-boundary abort.
308    #[serde(default)]
309    pub supervisor: TaskSupervisorConfig,
310}
311
312fn default_budget_hint_enabled() -> bool {
313    true
314}
315
316fn default_goal_max_text_chars() -> usize {
317    2000
318}
319
320fn default_goal_max_history() -> usize {
321    50
322}
323
324fn default_autonomous_max_turns() -> u32 {
325    20
326}
327
328fn default_verify_interval() -> u32 {
329    5
330}
331
332fn default_supervisor_timeout_secs() -> u64 {
333    30
334}
335
336fn default_max_stuck_count() -> u32 {
337    3
338}
339
340fn default_autonomous_turn_delay_ms() -> u64 {
341    500
342}
343
344fn default_autonomous_turn_timeout_secs() -> u64 {
345    300
346}
347
348fn default_max_supervisor_fail_count() -> u32 {
349    3
350}
351
352/// Long-horizon goal lifecycle configuration (`[goals]` TOML section).
353///
354/// When enabled, the agent tracks a single active goal across turns, injecting an
355/// `<active_goal>` block into the volatile system-prompt region and accounting for
356/// token consumption per turn.
357///
358/// Set `autonomous_enabled = true` to allow the agent to run multi-turn goal execution
359/// without waiting for user input between turns. A supervisor LLM call periodically checks
360/// whether the goal condition has been satisfied.
361///
362/// # Example (TOML)
363///
364/// ```toml
365/// [goals]
366/// enabled = true
367/// autonomous_enabled = true
368/// autonomous_max_turns = 20
369/// supervisor_provider = "fast"
370/// verify_interval = 5
371/// supervisor_timeout_secs = 30
372/// max_stuck_count = 3
373/// autonomous_turn_delay_ms = 500
374/// default_token_budget = 50000
375/// ```
376#[derive(Debug, Clone, Deserialize, Serialize)]
377#[serde(default)]
378pub struct GoalConfig {
379    /// Enable the goal lifecycle subsystem. Default: `false`.
380    pub enabled: bool,
381    /// Inject `<active_goal>` block into the volatile system-prompt region. Default: `true`.
382    pub inject_into_system_prompt: bool,
383    /// Maximum characters allowed for goal text at creation time. Default: `2000`.
384    #[serde(default = "default_goal_max_text_chars")]
385    pub max_text_chars: usize,
386    /// Default token budget for new goals (`None` = unlimited). Default: `None`.
387    pub default_token_budget: Option<u64>,
388    /// Maximum number of goals to return in `/goal list`. Default: `50`.
389    #[serde(default = "default_goal_max_history")]
390    pub max_history: usize,
391    /// Enable autonomous multi-turn execution mode (`/goal create ... --auto`). Default: `false`.
392    pub autonomous_enabled: bool,
393    /// Maximum number of turns the agent may run without user input per session. Default: `20`.
394    #[serde(default = "default_autonomous_max_turns")]
395    pub autonomous_max_turns: u32,
396    /// Provider name for the supervisor verifier LLM call (references `[[llm.providers]] name`).
397    /// Falls back to the main provider when `None`.
398    pub supervisor_provider: Option<ProviderName>,
399    /// How many turns to execute between supervisor verification checks. Default: `5`.
400    #[serde(default = "default_verify_interval")]
401    pub verify_interval: u32,
402    /// Timeout in seconds for a single supervisor verification LLM call. Default: `30`.
403    #[serde(default = "default_supervisor_timeout_secs")]
404    pub supervisor_timeout_secs: u64,
405    /// Maximum consecutive stuck-turn detections before the session is aborted. Default: `3`.
406    #[serde(default = "default_max_stuck_count")]
407    pub max_stuck_count: u32,
408    /// Delay in milliseconds between autonomous turns to avoid busy-looping. Default: `500`.
409    #[serde(default = "default_autonomous_turn_delay_ms")]
410    pub autonomous_turn_delay_ms: u64,
411    /// Maximum wall-clock time in seconds for a single autonomous LLM turn before it is
412    /// cancelled and the session transitions to `Stuck`. Default: `300` (5 minutes).
413    #[serde(default = "default_autonomous_turn_timeout_secs")]
414    pub autonomous_turn_timeout_secs: u64,
415    /// Maximum consecutive supervisor verification failures before the session is paused.
416    /// Default: `3`.
417    #[serde(default = "default_max_supervisor_fail_count")]
418    pub max_supervisor_fail_count: u32,
419}
420
421impl Default for GoalConfig {
422    fn default() -> Self {
423        Self {
424            enabled: false,
425            inject_into_system_prompt: true,
426            max_text_chars: default_goal_max_text_chars(),
427            default_token_budget: None,
428            max_history: default_goal_max_history(),
429            autonomous_enabled: false,
430            autonomous_max_turns: default_autonomous_max_turns(),
431            supervisor_provider: None,
432            verify_interval: default_verify_interval(),
433            supervisor_timeout_secs: default_supervisor_timeout_secs(),
434            max_stuck_count: default_max_stuck_count(),
435            autonomous_turn_delay_ms: default_autonomous_turn_delay_ms(),
436            autonomous_turn_timeout_secs: default_autonomous_turn_timeout_secs(),
437            max_supervisor_fail_count: default_max_supervisor_fail_count(),
438        }
439    }
440}
441
442fn default_enrichment_limit() -> usize {
443    4
444}
445
446fn default_telemetry_limit() -> usize {
447    8
448}
449
450fn default_background_shell_limit() -> usize {
451    8
452}
453
454/// Background task supervisor configuration, nested under `[agent.supervisor]` in TOML.
455///
456/// Controls per-class concurrency limits and turn-boundary behaviour for the
457/// `BackgroundSupervisor` in `zeph-core`.
458/// All fields have sensible defaults that match the Phase 1 hardcoded values; only change
459/// these if you observe excessive background task drops under load.
460///
461/// # Example (TOML)
462///
463/// ```toml
464/// [agent.supervisor]
465/// enrichment_limit = 4
466/// telemetry_limit = 8
467/// abort_enrichment_on_turn = false
468/// ```
469#[derive(Debug, Clone, Deserialize, Serialize)]
470#[serde(default)]
471pub struct TaskSupervisorConfig {
472    /// Maximum concurrent enrichment tasks (summarization, graph/persona/trajectory extraction).
473    /// Default: `4`.
474    #[serde(default = "default_enrichment_limit")]
475    pub enrichment_limit: usize,
476    /// Maximum concurrent telemetry tasks (audit log writes, graph count sync).
477    /// Default: `8`.
478    #[serde(default = "default_telemetry_limit")]
479    pub telemetry_limit: usize,
480    /// Abort all inflight enrichment tasks at turn boundary to prevent backlog buildup.
481    /// Default: `false`.
482    #[serde(default)]
483    pub abort_enrichment_on_turn: bool,
484    /// Maximum concurrent background shell runs tracked by the supervisor.
485    ///
486    /// Should match `tools.shell.max_background_runs` so both layers agree on capacity.
487    /// Default: `8`.
488    #[serde(default = "default_background_shell_limit")]
489    pub background_shell_limit: usize,
490}
491
492impl Default for TaskSupervisorConfig {
493    fn default() -> Self {
494        Self {
495            enrichment_limit: default_enrichment_limit(),
496            telemetry_limit: default_telemetry_limit(),
497            abort_enrichment_on_turn: false,
498            background_shell_limit: default_background_shell_limit(),
499        }
500    }
501}
502
503/// Sub-agent pool configuration, nested under `[agents]` in TOML.
504///
505/// When `enabled = true`, the agent can spawn isolated sub-agent sessions from
506/// SKILL.md-based agent definitions. Sub-agents inherit the parent's provider pool
507/// unless overridden by `model` in their definition frontmatter.
508///
509/// # Example (TOML)
510///
511/// ```toml
512/// [agents]
513/// enabled = true
514/// max_concurrent = 3
515/// max_spawn_depth = 2
516/// ```
517#[derive(Debug, Clone, Deserialize, Serialize)]
518#[serde(default)]
519pub struct SubAgentConfig {
520    /// Enable the sub-agent subsystem. Default: `false`.
521    pub enabled: bool,
522    /// Maximum number of sub-agents that can run concurrently.
523    #[serde(default = "default_max_concurrent")]
524    pub max_concurrent: usize,
525    /// Additional directories to search for `.agent.md` definition files.
526    pub extra_dirs: Vec<PathBuf>,
527    /// User-level agents directory.
528    #[serde(default)]
529    pub user_agents_dir: Option<PathBuf>,
530    /// Default permission mode applied to sub-agents that do not specify one.
531    pub default_permission_mode: Option<PermissionMode>,
532    /// Global denylist applied to all sub-agents in addition to per-agent `tools.except`.
533    #[serde(default)]
534    pub default_disallowed_tools: Vec<String>,
535    /// Allow sub-agents to use `bypass_permissions` mode.
536    #[serde(default)]
537    pub allow_bypass_permissions: bool,
538    /// Default memory scope applied to sub-agents that do not set `memory` in their definition.
539    #[serde(default)]
540    pub default_memory_scope: Option<MemoryScope>,
541    /// Lifecycle hooks executed when any sub-agent starts or stops.
542    #[serde(default)]
543    pub hooks: SubAgentLifecycleHooks,
544    /// Directory where transcript JSONL files and meta sidecars are stored.
545    #[serde(default)]
546    pub transcript_dir: Option<PathBuf>,
547    /// Enable writing JSONL transcripts for sub-agent sessions.
548    #[serde(default = "default_transcript_enabled")]
549    pub transcript_enabled: bool,
550    /// Maximum number of `.jsonl` transcript files to keep.
551    #[serde(default = "default_transcript_max_files")]
552    pub transcript_max_files: usize,
553    /// Number of recent parent conversation turns to pass to spawned sub-agents.
554    /// Set to 0 to disable history propagation.
555    #[serde(default = "default_context_window_turns")]
556    pub context_window_turns: usize,
557    /// Maximum nesting depth for sub-agent spawns.
558    #[serde(default = "default_max_spawn_depth")]
559    pub max_spawn_depth: u32,
560    /// How parent context is injected into the sub-agent's task prompt.
561    #[serde(default)]
562    pub context_injection_mode: ContextInjectionMode,
563    /// Whether to sanitize parent conversation history before passing to a spawned sub-agent.
564    ///
565    /// Defaults to [`ParentContextPolicy::InheritSanitized`] which runs each text message part
566    /// through the IPI sanitizer, stripping prompt-injection payloads that may have entered the
567    /// parent history via tool results, web scrapes, or A2A messages.
568    #[serde(default)]
569    pub parent_context_policy: ParentContextPolicy,
570    /// Maximum number of parent messages to inject, independent of `context_window_turns`.
571    ///
572    /// Acts as a hard upper bound on context propagation volume to limit the blast radius
573    /// of poisoned histories.  When `max_parent_messages < context_window_turns * 2` this cap
574    /// wins and fewer messages are passed; otherwise `context_window_turns * 2` is the binding
575    /// limit.  The tighter of the two limits always applies.
576    #[serde(default = "default_max_parent_messages")]
577    pub max_parent_messages: usize,
578    /// Maximum character count for the `Summary` context injection mode.
579    ///
580    /// When `context_injection_mode = "summary"`, the extracted summary is truncated
581    /// to this many characters at a UTF-8 char boundary before being prepended to the
582    /// sub-agent's task prompt.  Consistent with the `max_state_chars` naming convention.
583    ///
584    /// Default: `600` (≈200 tokens at 3 chars/token).
585    #[serde(default = "default_summary_max_chars")]
586    pub summary_max_chars: usize,
587}
588
589impl Default for SubAgentConfig {
590    fn default() -> Self {
591        Self {
592            enabled: false,
593            max_concurrent: default_max_concurrent(),
594            extra_dirs: Vec::new(),
595            user_agents_dir: None,
596            default_permission_mode: None,
597            default_disallowed_tools: Vec::new(),
598            allow_bypass_permissions: false,
599            default_memory_scope: None,
600            hooks: SubAgentLifecycleHooks::default(),
601            transcript_dir: None,
602            transcript_enabled: default_transcript_enabled(),
603            transcript_max_files: default_transcript_max_files(),
604            context_window_turns: default_context_window_turns(),
605            max_spawn_depth: default_max_spawn_depth(),
606            context_injection_mode: ContextInjectionMode::default(),
607            parent_context_policy: ParentContextPolicy::default(),
608            max_parent_messages: default_max_parent_messages(),
609            summary_max_chars: default_summary_max_chars(),
610        }
611    }
612}
613
614/// Config-level lifecycle hooks fired when any sub-agent starts or stops.
615#[derive(Debug, Clone, Default, Deserialize, Serialize)]
616#[serde(default)]
617pub struct SubAgentLifecycleHooks {
618    /// Hooks run after a sub-agent is spawned (fire-and-forget).
619    pub start: Vec<HookDef>,
620    /// Hooks run after a sub-agent finishes or is cancelled (fire-and-forget).
621    pub stop: Vec<HookDef>,
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    #[test]
629    fn subagent_config_defaults() {
630        let cfg = SubAgentConfig::default();
631        assert_eq!(cfg.context_window_turns, 10);
632        assert_eq!(cfg.max_spawn_depth, 3);
633        assert_eq!(
634            cfg.context_injection_mode,
635            ContextInjectionMode::LastAssistantTurn
636        );
637        assert_eq!(
638            cfg.parent_context_policy,
639            ParentContextPolicy::InheritSanitized
640        );
641        assert_eq!(cfg.max_parent_messages, 20);
642    }
643
644    #[test]
645    fn subagent_config_deserialize_new_fields() {
646        let toml_str = r#"
647            enabled = true
648            context_window_turns = 5
649            max_spawn_depth = 2
650            context_injection_mode = "none"
651        "#;
652        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
653        assert_eq!(cfg.context_window_turns, 5);
654        assert_eq!(cfg.max_spawn_depth, 2);
655        assert_eq!(cfg.context_injection_mode, ContextInjectionMode::None);
656    }
657
658    #[test]
659    fn subagent_config_deserialize_parent_context_policy() {
660        let toml_str = r#"
661            parent_context_policy = "none"
662            max_parent_messages = 10
663        "#;
664        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
665        assert_eq!(cfg.parent_context_policy, ParentContextPolicy::None);
666        assert_eq!(cfg.max_parent_messages, 10);
667    }
668
669    #[test]
670    fn subagent_config_deserialize_parent_context_policy_inherit_sanitized() {
671        let toml_str = r#"
672            parent_context_policy = "inherit_sanitized"
673        "#;
674        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
675        assert_eq!(
676            cfg.parent_context_policy,
677            ParentContextPolicy::InheritSanitized
678        );
679    }
680
681    #[test]
682    fn model_spec_deserialize_inherit() {
683        let spec: ModelSpec = serde_json::from_str("\"inherit\"").unwrap();
684        assert_eq!(spec, ModelSpec::Inherit);
685    }
686
687    #[test]
688    fn model_spec_deserialize_named() {
689        let spec: ModelSpec = serde_json::from_str("\"fast\"").unwrap();
690        assert_eq!(spec, ModelSpec::Named("fast".to_owned()));
691    }
692
693    #[test]
694    fn model_spec_as_str() {
695        assert_eq!(ModelSpec::Inherit.as_str(), "inherit");
696        assert_eq!(ModelSpec::Named("x".to_owned()).as_str(), "x");
697    }
698
699    #[test]
700    fn focus_config_auto_consolidate_min_window_default_is_six() {
701        let cfg = FocusConfig::default();
702        assert_eq!(cfg.auto_consolidate_min_window, 6);
703    }
704
705    #[test]
706    fn focus_config_auto_consolidate_min_window_deserializes() {
707        let toml_str = "auto_consolidate_min_window = 10";
708        let cfg: FocusConfig = toml::from_str(toml_str).unwrap();
709        assert_eq!(cfg.auto_consolidate_min_window, 10);
710    }
711
712    #[test]
713    fn goal_config_new_field_defaults() {
714        let cfg = GoalConfig::default();
715        assert_eq!(cfg.autonomous_turn_timeout_secs, 300);
716        assert_eq!(cfg.max_supervisor_fail_count, 3);
717    }
718
719    #[test]
720    fn goal_config_new_fields_deserialize() {
721        let toml_str = r"
722            autonomous_turn_timeout_secs = 120
723            max_supervisor_fail_count = 5
724        ";
725        let cfg: GoalConfig = toml::from_str(toml_str).unwrap();
726        assert_eq!(cfg.autonomous_turn_timeout_secs, 120);
727        assert_eq!(cfg.max_supervisor_fail_count, 5);
728    }
729
730    #[test]
731    fn goal_config_omitted_new_fields_use_defaults() {
732        let toml_str = "enabled = true";
733        let cfg: GoalConfig = toml::from_str(toml_str).unwrap();
734        assert_eq!(cfg.autonomous_turn_timeout_secs, 300);
735        assert_eq!(cfg.max_supervisor_fail_count, 3);
736    }
737}