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::subagent::{HookDef, MemoryScope, PermissionMode};
9
10/// Specifies which LLM provider a sub-agent should use.
11///
12/// Used in `SubAgentDef.model` frontmatter field.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ModelSpec {
15    /// Use the parent agent's active provider at spawn time.
16    Inherit,
17    /// Use a specific named provider from `[[llm.providers]]`.
18    Named(String),
19}
20
21impl ModelSpec {
22    /// Return the string representation: `"inherit"` or the provider name.
23    #[must_use]
24    pub fn as_str(&self) -> &str {
25        match self {
26            ModelSpec::Inherit => "inherit",
27            ModelSpec::Named(s) => s.as_str(),
28        }
29    }
30}
31
32impl Serialize for ModelSpec {
33    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
34        match self {
35            ModelSpec::Inherit => serializer.serialize_str("inherit"),
36            ModelSpec::Named(s) => serializer.serialize_str(s),
37        }
38    }
39}
40
41impl<'de> Deserialize<'de> for ModelSpec {
42    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
43        let s = String::deserialize(deserializer)?;
44        if s == "inherit" {
45            Ok(ModelSpec::Inherit)
46        } else {
47            Ok(ModelSpec::Named(s))
48        }
49    }
50}
51
52/// Controls how parent agent context is injected into a spawned sub-agent's task prompt.
53#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "snake_case")]
55pub enum ContextInjectionMode {
56    /// No parent context injected.
57    None,
58    /// Prepend the last assistant turn from parent history as a preamble.
59    #[default]
60    LastAssistantTurn,
61    /// LLM-generated summary of parent context (not yet implemented in Phase 1).
62    Summary,
63}
64
65fn default_max_tool_iterations() -> usize {
66    10
67}
68
69fn default_auto_update_check() -> bool {
70    true
71}
72
73fn default_focus_compression_interval() -> usize {
74    12
75}
76
77fn default_focus_reminder_interval() -> usize {
78    15
79}
80
81fn default_focus_min_messages_per_focus() -> usize {
82    8
83}
84
85fn default_focus_max_knowledge_tokens() -> usize {
86    4096
87}
88
89fn default_focus_auto_consolidate_min_window() -> usize {
90    6
91}
92
93fn default_max_tool_retries() -> usize {
94    2
95}
96
97fn default_max_retry_duration_secs() -> u64 {
98    30
99}
100
101fn default_tool_repeat_threshold() -> usize {
102    2
103}
104
105fn default_tool_filter_top_k() -> usize {
106    6
107}
108
109fn default_tool_filter_min_description_words() -> usize {
110    5
111}
112
113fn default_tool_filter_always_on() -> Vec<String> {
114    vec![
115        "memory_search".into(),
116        "memory_save".into(),
117        "load_skill".into(),
118        "invoke_skill".into(),
119        "bash".into(),
120        "read".into(),
121        "edit".into(),
122    ]
123}
124
125fn default_instruction_auto_detect() -> bool {
126    true
127}
128
129fn default_max_concurrent() -> usize {
130    5
131}
132
133fn default_context_window_turns() -> usize {
134    10
135}
136
137fn default_max_spawn_depth() -> u32 {
138    3
139}
140
141fn default_transcript_enabled() -> bool {
142    true
143}
144
145fn default_transcript_max_files() -> usize {
146    50
147}
148
149/// Configuration for focus-based active context compression (#1850).
150#[derive(Debug, Clone, Deserialize, Serialize)]
151#[serde(default)]
152pub struct FocusConfig {
153    /// Enable focus tools (`start_focus` / `complete_focus`). Default: `false`.
154    pub enabled: bool,
155    /// Suggest focus after this many turns without one. Default: `12`.
156    #[serde(default = "default_focus_compression_interval")]
157    pub compression_interval: usize,
158    /// Remind the agent every N turns when focus is overdue. Default: `15`.
159    #[serde(default = "default_focus_reminder_interval")]
160    pub reminder_interval: usize,
161    /// Minimum messages required before suggesting a focus. Default: `8`.
162    #[serde(default = "default_focus_min_messages_per_focus")]
163    pub min_messages_per_focus: usize,
164    /// Maximum tokens the Knowledge block may grow to before old entries are trimmed.
165    /// Default: `4096`.
166    #[serde(default = "default_focus_max_knowledge_tokens")]
167    pub max_knowledge_tokens: usize,
168    /// Minimum turns since the last auto-consolidation before the next one fires.
169    ///
170    /// Must be >= 1. `Config::validate()` rejects `0` at startup. Default: `6`.
171    #[serde(default = "default_focus_auto_consolidate_min_window")]
172    pub auto_consolidate_min_window: usize,
173}
174
175impl Default for FocusConfig {
176    fn default() -> Self {
177        Self {
178            enabled: false,
179            compression_interval: default_focus_compression_interval(),
180            reminder_interval: default_focus_reminder_interval(),
181            min_messages_per_focus: default_focus_min_messages_per_focus(),
182            max_knowledge_tokens: default_focus_max_knowledge_tokens(),
183            auto_consolidate_min_window: default_focus_auto_consolidate_min_window(),
184        }
185    }
186}
187
188/// Dynamic tool schema filtering configuration (#2020).
189///
190/// When enabled, only a subset of tool definitions is sent to the LLM on each turn,
191/// selected by embedding similarity between the user query and tool descriptions.
192#[derive(Debug, Clone, Deserialize, Serialize)]
193#[serde(default)]
194pub struct ToolFilterConfig {
195    /// Enable dynamic tool schema filtering. Default: `false` (opt-in).
196    pub enabled: bool,
197    /// Number of top-scoring filterable tools to include per turn.
198    /// Set to `0` to include all filterable tools.
199    #[serde(default = "default_tool_filter_top_k")]
200    pub top_k: usize,
201    /// Tool IDs that are never filtered out.
202    #[serde(default = "default_tool_filter_always_on")]
203    pub always_on: Vec<String>,
204    /// MCP tools with fewer description words than this are auto-included.
205    #[serde(default = "default_tool_filter_min_description_words")]
206    pub min_description_words: usize,
207}
208
209impl Default for ToolFilterConfig {
210    fn default() -> Self {
211        Self {
212            enabled: false,
213            top_k: default_tool_filter_top_k(),
214            always_on: default_tool_filter_always_on(),
215            min_description_words: default_tool_filter_min_description_words(),
216        }
217    }
218}
219
220/// Core agent behavior configuration, nested under `[agent]` in TOML.
221///
222/// Controls the agent's name, tool-loop limits, instruction loading, and retry
223/// behavior. All fields have sensible defaults; only `name` is typically changed
224/// by end users.
225///
226/// # Example (TOML)
227///
228/// ```toml
229/// [agent]
230/// name = "Zeph"
231/// max_tool_iterations = 15
232/// max_tool_retries = 3
233/// ```
234#[derive(Debug, Deserialize, Serialize)]
235pub struct AgentConfig {
236    /// Human-readable agent name surfaced in the TUI and Telegram header. Default: `"Zeph"`.
237    pub name: String,
238    /// Maximum number of tool-call iterations per agent turn before the loop is aborted.
239    /// Must be `<= 100`. Default: `10`.
240    #[serde(default = "default_max_tool_iterations")]
241    pub max_tool_iterations: usize,
242    /// Check for new Zeph releases at startup. Default: `true`.
243    #[serde(default = "default_auto_update_check")]
244    pub auto_update_check: bool,
245    /// Additional instruction files to always load, regardless of provider.
246    #[serde(default)]
247    pub instruction_files: Vec<std::path::PathBuf>,
248    /// When true, automatically detect provider-specific instruction files
249    /// (e.g. `CLAUDE.md` for Claude, `AGENTS.md` for `OpenAI`).
250    #[serde(default = "default_instruction_auto_detect")]
251    pub instruction_auto_detect: bool,
252    /// Maximum retry attempts for transient tool errors (0 to disable).
253    #[serde(default = "default_max_tool_retries")]
254    pub max_tool_retries: usize,
255    /// Number of identical tool+args calls within the recent window to trigger repeat-detection
256    /// abort (0 to disable).
257    #[serde(default = "default_tool_repeat_threshold")]
258    pub tool_repeat_threshold: usize,
259    /// Maximum total wall-clock time (seconds) to spend on retries for a single tool call.
260    #[serde(default = "default_max_retry_duration_secs")]
261    pub max_retry_duration_secs: u64,
262    /// Focus-based active context compression configuration (#1850).
263    #[serde(default)]
264    pub focus: FocusConfig,
265    /// Dynamic tool schema filtering configuration (#2020).
266    #[serde(default)]
267    pub tool_filter: ToolFilterConfig,
268    /// Inject a `<budget>` XML block into the volatile system prompt section so the LLM
269    /// can self-regulate tool calls and cost. Self-suppresses when no budget data is
270    /// available (#2267).
271    #[serde(default = "default_budget_hint_enabled")]
272    pub budget_hint_enabled: bool,
273    /// Background task supervisor tuning. Controls concurrency limits and turn-boundary abort.
274    #[serde(default)]
275    pub supervisor: TaskSupervisorConfig,
276}
277
278fn default_budget_hint_enabled() -> bool {
279    true
280}
281
282fn default_goal_max_text_chars() -> usize {
283    2000
284}
285
286fn default_goal_max_history() -> usize {
287    50
288}
289
290/// Long-horizon goal lifecycle configuration (`[goals]` TOML section).
291///
292/// When enabled, the agent tracks a single active goal across turns, injecting an
293/// `<active_goal>` block into the volatile system-prompt region and accounting for
294/// token consumption per turn.
295///
296/// # Example (TOML)
297///
298/// ```toml
299/// [goals]
300/// enabled = true
301/// default_token_budget = 50000
302/// ```
303#[derive(Debug, Clone, Deserialize, Serialize)]
304#[serde(default)]
305pub struct GoalConfig {
306    /// Enable the goal lifecycle subsystem. Default: `false`.
307    pub enabled: bool,
308    /// Inject `<active_goal>` block into the volatile system-prompt region. Default: `true`.
309    pub inject_into_system_prompt: bool,
310    /// Maximum characters allowed for goal text at creation time. Default: `2000`.
311    #[serde(default = "default_goal_max_text_chars")]
312    pub max_text_chars: usize,
313    /// Default token budget for new goals (`None` = unlimited). Default: `None`.
314    pub default_token_budget: Option<u64>,
315    /// Maximum number of goals to return in `/goal list`. Default: `50`.
316    #[serde(default = "default_goal_max_history")]
317    pub max_history: usize,
318}
319
320impl Default for GoalConfig {
321    fn default() -> Self {
322        Self {
323            enabled: false,
324            inject_into_system_prompt: true,
325            max_text_chars: default_goal_max_text_chars(),
326            default_token_budget: None,
327            max_history: default_goal_max_history(),
328        }
329    }
330}
331
332fn default_enrichment_limit() -> usize {
333    4
334}
335
336fn default_telemetry_limit() -> usize {
337    8
338}
339
340fn default_background_shell_limit() -> usize {
341    8
342}
343
344/// Background task supervisor configuration, nested under `[agent.supervisor]` in TOML.
345///
346/// Controls per-class concurrency limits and turn-boundary behaviour for the
347/// `BackgroundSupervisor` in `zeph-core`.
348/// All fields have sensible defaults that match the Phase 1 hardcoded values; only change
349/// these if you observe excessive background task drops under load.
350///
351/// # Example (TOML)
352///
353/// ```toml
354/// [agent.supervisor]
355/// enrichment_limit = 4
356/// telemetry_limit = 8
357/// abort_enrichment_on_turn = false
358/// ```
359#[derive(Debug, Clone, Deserialize, Serialize)]
360#[serde(default)]
361pub struct TaskSupervisorConfig {
362    /// Maximum concurrent enrichment tasks (summarization, graph/persona/trajectory extraction).
363    /// Default: `4`.
364    #[serde(default = "default_enrichment_limit")]
365    pub enrichment_limit: usize,
366    /// Maximum concurrent telemetry tasks (audit log writes, graph count sync).
367    /// Default: `8`.
368    #[serde(default = "default_telemetry_limit")]
369    pub telemetry_limit: usize,
370    /// Abort all inflight enrichment tasks at turn boundary to prevent backlog buildup.
371    /// Default: `false`.
372    #[serde(default)]
373    pub abort_enrichment_on_turn: bool,
374    /// Maximum concurrent background shell runs tracked by the supervisor.
375    ///
376    /// Should match `tools.shell.max_background_runs` so both layers agree on capacity.
377    /// Default: `8`.
378    #[serde(default = "default_background_shell_limit")]
379    pub background_shell_limit: usize,
380}
381
382impl Default for TaskSupervisorConfig {
383    fn default() -> Self {
384        Self {
385            enrichment_limit: default_enrichment_limit(),
386            telemetry_limit: default_telemetry_limit(),
387            abort_enrichment_on_turn: false,
388            background_shell_limit: default_background_shell_limit(),
389        }
390    }
391}
392
393/// Sub-agent pool configuration, nested under `[agents]` in TOML.
394///
395/// When `enabled = true`, the agent can spawn isolated sub-agent sessions from
396/// SKILL.md-based agent definitions. Sub-agents inherit the parent's provider pool
397/// unless overridden by `model` in their definition frontmatter.
398///
399/// # Example (TOML)
400///
401/// ```toml
402/// [agents]
403/// enabled = true
404/// max_concurrent = 3
405/// max_spawn_depth = 2
406/// ```
407#[derive(Debug, Clone, Deserialize, Serialize)]
408#[serde(default)]
409pub struct SubAgentConfig {
410    /// Enable the sub-agent subsystem. Default: `false`.
411    pub enabled: bool,
412    /// Maximum number of sub-agents that can run concurrently.
413    #[serde(default = "default_max_concurrent")]
414    pub max_concurrent: usize,
415    /// Additional directories to search for `.agent.md` definition files.
416    pub extra_dirs: Vec<PathBuf>,
417    /// User-level agents directory.
418    #[serde(default)]
419    pub user_agents_dir: Option<PathBuf>,
420    /// Default permission mode applied to sub-agents that do not specify one.
421    pub default_permission_mode: Option<PermissionMode>,
422    /// Global denylist applied to all sub-agents in addition to per-agent `tools.except`.
423    #[serde(default)]
424    pub default_disallowed_tools: Vec<String>,
425    /// Allow sub-agents to use `bypass_permissions` mode.
426    #[serde(default)]
427    pub allow_bypass_permissions: bool,
428    /// Default memory scope applied to sub-agents that do not set `memory` in their definition.
429    #[serde(default)]
430    pub default_memory_scope: Option<MemoryScope>,
431    /// Lifecycle hooks executed when any sub-agent starts or stops.
432    #[serde(default)]
433    pub hooks: SubAgentLifecycleHooks,
434    /// Directory where transcript JSONL files and meta sidecars are stored.
435    #[serde(default)]
436    pub transcript_dir: Option<PathBuf>,
437    /// Enable writing JSONL transcripts for sub-agent sessions.
438    #[serde(default = "default_transcript_enabled")]
439    pub transcript_enabled: bool,
440    /// Maximum number of `.jsonl` transcript files to keep.
441    #[serde(default = "default_transcript_max_files")]
442    pub transcript_max_files: usize,
443    /// Number of recent parent conversation turns to pass to spawned sub-agents.
444    /// Set to 0 to disable history propagation.
445    #[serde(default = "default_context_window_turns")]
446    pub context_window_turns: usize,
447    /// Maximum nesting depth for sub-agent spawns.
448    #[serde(default = "default_max_spawn_depth")]
449    pub max_spawn_depth: u32,
450    /// How parent context is injected into the sub-agent's task prompt.
451    #[serde(default)]
452    pub context_injection_mode: ContextInjectionMode,
453}
454
455impl Default for SubAgentConfig {
456    fn default() -> Self {
457        Self {
458            enabled: false,
459            max_concurrent: default_max_concurrent(),
460            extra_dirs: Vec::new(),
461            user_agents_dir: None,
462            default_permission_mode: None,
463            default_disallowed_tools: Vec::new(),
464            allow_bypass_permissions: false,
465            default_memory_scope: None,
466            hooks: SubAgentLifecycleHooks::default(),
467            transcript_dir: None,
468            transcript_enabled: default_transcript_enabled(),
469            transcript_max_files: default_transcript_max_files(),
470            context_window_turns: default_context_window_turns(),
471            max_spawn_depth: default_max_spawn_depth(),
472            context_injection_mode: ContextInjectionMode::default(),
473        }
474    }
475}
476
477/// Config-level lifecycle hooks fired when any sub-agent starts or stops.
478#[derive(Debug, Clone, Default, Deserialize, Serialize)]
479#[serde(default)]
480pub struct SubAgentLifecycleHooks {
481    /// Hooks run after a sub-agent is spawned (fire-and-forget).
482    pub start: Vec<HookDef>,
483    /// Hooks run after a sub-agent finishes or is cancelled (fire-and-forget).
484    pub stop: Vec<HookDef>,
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn subagent_config_defaults() {
493        let cfg = SubAgentConfig::default();
494        assert_eq!(cfg.context_window_turns, 10);
495        assert_eq!(cfg.max_spawn_depth, 3);
496        assert_eq!(
497            cfg.context_injection_mode,
498            ContextInjectionMode::LastAssistantTurn
499        );
500    }
501
502    #[test]
503    fn subagent_config_deserialize_new_fields() {
504        let toml_str = r#"
505            enabled = true
506            context_window_turns = 5
507            max_spawn_depth = 2
508            context_injection_mode = "none"
509        "#;
510        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
511        assert_eq!(cfg.context_window_turns, 5);
512        assert_eq!(cfg.max_spawn_depth, 2);
513        assert_eq!(cfg.context_injection_mode, ContextInjectionMode::None);
514    }
515
516    #[test]
517    fn model_spec_deserialize_inherit() {
518        let spec: ModelSpec = serde_json::from_str("\"inherit\"").unwrap();
519        assert_eq!(spec, ModelSpec::Inherit);
520    }
521
522    #[test]
523    fn model_spec_deserialize_named() {
524        let spec: ModelSpec = serde_json::from_str("\"fast\"").unwrap();
525        assert_eq!(spec, ModelSpec::Named("fast".to_owned()));
526    }
527
528    #[test]
529    fn model_spec_as_str() {
530        assert_eq!(ModelSpec::Inherit.as_str(), "inherit");
531        assert_eq!(ModelSpec::Named("x".to_owned()).as_str(), "x");
532    }
533
534    #[test]
535    fn focus_config_auto_consolidate_min_window_default_is_six() {
536        let cfg = FocusConfig::default();
537        assert_eq!(cfg.auto_consolidate_min_window, 6);
538    }
539
540    #[test]
541    fn focus_config_auto_consolidate_min_window_deserializes() {
542        let toml_str = "auto_consolidate_min_window = 10";
543        let cfg: FocusConfig = toml::from_str(toml_str).unwrap();
544        assert_eq!(cfg.auto_consolidate_min_window, 10);
545    }
546}