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_max_tool_retries() -> usize {
90    2
91}
92
93fn default_max_retry_duration_secs() -> u64 {
94    30
95}
96
97fn default_tool_repeat_threshold() -> usize {
98    2
99}
100
101fn default_tool_filter_top_k() -> usize {
102    6
103}
104
105fn default_tool_filter_min_description_words() -> usize {
106    5
107}
108
109fn default_tool_filter_always_on() -> Vec<String> {
110    vec![
111        "memory_search".into(),
112        "memory_save".into(),
113        "load_skill".into(),
114        "invoke_skill".into(),
115        "bash".into(),
116        "read".into(),
117        "edit".into(),
118    ]
119}
120
121fn default_instruction_auto_detect() -> bool {
122    true
123}
124
125fn default_max_concurrent() -> usize {
126    5
127}
128
129fn default_context_window_turns() -> usize {
130    10
131}
132
133fn default_max_spawn_depth() -> u32 {
134    3
135}
136
137fn default_transcript_enabled() -> bool {
138    true
139}
140
141fn default_transcript_max_files() -> usize {
142    50
143}
144
145/// Configuration for focus-based active context compression (#1850).
146#[derive(Debug, Clone, Deserialize, Serialize)]
147#[serde(default)]
148pub struct FocusConfig {
149    /// Enable focus tools (`start_focus` / `complete_focus`). Default: `false`.
150    pub enabled: bool,
151    /// Suggest focus after this many turns without one. Default: `12`.
152    #[serde(default = "default_focus_compression_interval")]
153    pub compression_interval: usize,
154    /// Remind the agent every N turns when focus is overdue. Default: `15`.
155    #[serde(default = "default_focus_reminder_interval")]
156    pub reminder_interval: usize,
157    /// Minimum messages required before suggesting a focus. Default: `8`.
158    #[serde(default = "default_focus_min_messages_per_focus")]
159    pub min_messages_per_focus: usize,
160    /// Maximum tokens the Knowledge block may grow to before old entries are trimmed.
161    /// Default: `4096`.
162    #[serde(default = "default_focus_max_knowledge_tokens")]
163    pub max_knowledge_tokens: usize,
164}
165
166impl Default for FocusConfig {
167    fn default() -> Self {
168        Self {
169            enabled: false,
170            compression_interval: default_focus_compression_interval(),
171            reminder_interval: default_focus_reminder_interval(),
172            min_messages_per_focus: default_focus_min_messages_per_focus(),
173            max_knowledge_tokens: default_focus_max_knowledge_tokens(),
174        }
175    }
176}
177
178/// Dynamic tool schema filtering configuration (#2020).
179///
180/// When enabled, only a subset of tool definitions is sent to the LLM on each turn,
181/// selected by embedding similarity between the user query and tool descriptions.
182#[derive(Debug, Clone, Deserialize, Serialize)]
183#[serde(default)]
184pub struct ToolFilterConfig {
185    /// Enable dynamic tool schema filtering. Default: `false` (opt-in).
186    pub enabled: bool,
187    /// Number of top-scoring filterable tools to include per turn.
188    /// Set to `0` to include all filterable tools.
189    #[serde(default = "default_tool_filter_top_k")]
190    pub top_k: usize,
191    /// Tool IDs that are never filtered out.
192    #[serde(default = "default_tool_filter_always_on")]
193    pub always_on: Vec<String>,
194    /// MCP tools with fewer description words than this are auto-included.
195    #[serde(default = "default_tool_filter_min_description_words")]
196    pub min_description_words: usize,
197}
198
199impl Default for ToolFilterConfig {
200    fn default() -> Self {
201        Self {
202            enabled: false,
203            top_k: default_tool_filter_top_k(),
204            always_on: default_tool_filter_always_on(),
205            min_description_words: default_tool_filter_min_description_words(),
206        }
207    }
208}
209
210/// Core agent behavior configuration, nested under `[agent]` in TOML.
211///
212/// Controls the agent's name, tool-loop limits, instruction loading, and retry
213/// behavior. All fields have sensible defaults; only `name` is typically changed
214/// by end users.
215///
216/// # Example (TOML)
217///
218/// ```toml
219/// [agent]
220/// name = "Zeph"
221/// max_tool_iterations = 15
222/// max_tool_retries = 3
223/// ```
224#[derive(Debug, Deserialize, Serialize)]
225pub struct AgentConfig {
226    /// Human-readable agent name surfaced in the TUI and Telegram header. Default: `"Zeph"`.
227    pub name: String,
228    /// Maximum number of tool-call iterations per agent turn before the loop is aborted.
229    /// Must be `<= 100`. Default: `10`.
230    #[serde(default = "default_max_tool_iterations")]
231    pub max_tool_iterations: usize,
232    /// Check for new Zeph releases at startup. Default: `true`.
233    #[serde(default = "default_auto_update_check")]
234    pub auto_update_check: bool,
235    /// Additional instruction files to always load, regardless of provider.
236    #[serde(default)]
237    pub instruction_files: Vec<std::path::PathBuf>,
238    /// When true, automatically detect provider-specific instruction files
239    /// (e.g. `CLAUDE.md` for Claude, `AGENTS.md` for `OpenAI`).
240    #[serde(default = "default_instruction_auto_detect")]
241    pub instruction_auto_detect: bool,
242    /// Maximum retry attempts for transient tool errors (0 to disable).
243    #[serde(default = "default_max_tool_retries")]
244    pub max_tool_retries: usize,
245    /// Number of identical tool+args calls within the recent window to trigger repeat-detection
246    /// abort (0 to disable).
247    #[serde(default = "default_tool_repeat_threshold")]
248    pub tool_repeat_threshold: usize,
249    /// Maximum total wall-clock time (seconds) to spend on retries for a single tool call.
250    #[serde(default = "default_max_retry_duration_secs")]
251    pub max_retry_duration_secs: u64,
252    /// Focus-based active context compression configuration (#1850).
253    #[serde(default)]
254    pub focus: FocusConfig,
255    /// Dynamic tool schema filtering configuration (#2020).
256    #[serde(default)]
257    pub tool_filter: ToolFilterConfig,
258    /// Inject a `<budget>` XML block into the volatile system prompt section so the LLM
259    /// can self-regulate tool calls and cost. Self-suppresses when no budget data is
260    /// available (#2267).
261    #[serde(default = "default_budget_hint_enabled")]
262    pub budget_hint_enabled: bool,
263    /// Background task supervisor tuning. Controls concurrency limits and turn-boundary abort.
264    #[serde(default)]
265    pub supervisor: TaskSupervisorConfig,
266}
267
268fn default_budget_hint_enabled() -> bool {
269    true
270}
271
272fn default_enrichment_limit() -> usize {
273    4
274}
275
276fn default_telemetry_limit() -> usize {
277    8
278}
279
280/// Background task supervisor configuration, nested under `[agent.supervisor]` in TOML.
281///
282/// Controls per-class concurrency limits and turn-boundary behaviour for the
283/// `BackgroundSupervisor` in `zeph-core`.
284/// All fields have sensible defaults that match the Phase 1 hardcoded values; only change
285/// these if you observe excessive background task drops under load.
286///
287/// # Example (TOML)
288///
289/// ```toml
290/// [agent.supervisor]
291/// enrichment_limit = 4
292/// telemetry_limit = 8
293/// abort_enrichment_on_turn = false
294/// ```
295#[derive(Debug, Clone, Deserialize, Serialize)]
296#[serde(default)]
297pub struct TaskSupervisorConfig {
298    /// Maximum concurrent enrichment tasks (summarization, graph/persona/trajectory extraction).
299    /// Default: `4`.
300    #[serde(default = "default_enrichment_limit")]
301    pub enrichment_limit: usize,
302    /// Maximum concurrent telemetry tasks (audit log writes, graph count sync).
303    /// Default: `8`.
304    #[serde(default = "default_telemetry_limit")]
305    pub telemetry_limit: usize,
306    /// Abort all inflight enrichment tasks at turn boundary to prevent backlog buildup.
307    /// Default: `false`.
308    #[serde(default)]
309    pub abort_enrichment_on_turn: bool,
310}
311
312impl Default for TaskSupervisorConfig {
313    fn default() -> Self {
314        Self {
315            enrichment_limit: default_enrichment_limit(),
316            telemetry_limit: default_telemetry_limit(),
317            abort_enrichment_on_turn: false,
318        }
319    }
320}
321
322/// Sub-agent pool configuration, nested under `[agents]` in TOML.
323///
324/// When `enabled = true`, the agent can spawn isolated sub-agent sessions from
325/// SKILL.md-based agent definitions. Sub-agents inherit the parent's provider pool
326/// unless overridden by `model` in their definition frontmatter.
327///
328/// # Example (TOML)
329///
330/// ```toml
331/// [agents]
332/// enabled = true
333/// max_concurrent = 3
334/// max_spawn_depth = 2
335/// ```
336#[derive(Debug, Clone, Deserialize, Serialize)]
337#[serde(default)]
338pub struct SubAgentConfig {
339    /// Enable the sub-agent subsystem. Default: `false`.
340    pub enabled: bool,
341    /// Maximum number of sub-agents that can run concurrently.
342    #[serde(default = "default_max_concurrent")]
343    pub max_concurrent: usize,
344    /// Additional directories to search for `.agent.md` definition files.
345    pub extra_dirs: Vec<PathBuf>,
346    /// User-level agents directory.
347    #[serde(default)]
348    pub user_agents_dir: Option<PathBuf>,
349    /// Default permission mode applied to sub-agents that do not specify one.
350    pub default_permission_mode: Option<PermissionMode>,
351    /// Global denylist applied to all sub-agents in addition to per-agent `tools.except`.
352    #[serde(default)]
353    pub default_disallowed_tools: Vec<String>,
354    /// Allow sub-agents to use `bypass_permissions` mode.
355    #[serde(default)]
356    pub allow_bypass_permissions: bool,
357    /// Default memory scope applied to sub-agents that do not set `memory` in their definition.
358    #[serde(default)]
359    pub default_memory_scope: Option<MemoryScope>,
360    /// Lifecycle hooks executed when any sub-agent starts or stops.
361    #[serde(default)]
362    pub hooks: SubAgentLifecycleHooks,
363    /// Directory where transcript JSONL files and meta sidecars are stored.
364    #[serde(default)]
365    pub transcript_dir: Option<PathBuf>,
366    /// Enable writing JSONL transcripts for sub-agent sessions.
367    #[serde(default = "default_transcript_enabled")]
368    pub transcript_enabled: bool,
369    /// Maximum number of `.jsonl` transcript files to keep.
370    #[serde(default = "default_transcript_max_files")]
371    pub transcript_max_files: usize,
372    /// Number of recent parent conversation turns to pass to spawned sub-agents.
373    /// Set to 0 to disable history propagation.
374    #[serde(default = "default_context_window_turns")]
375    pub context_window_turns: usize,
376    /// Maximum nesting depth for sub-agent spawns.
377    #[serde(default = "default_max_spawn_depth")]
378    pub max_spawn_depth: u32,
379    /// How parent context is injected into the sub-agent's task prompt.
380    #[serde(default)]
381    pub context_injection_mode: ContextInjectionMode,
382}
383
384impl Default for SubAgentConfig {
385    fn default() -> Self {
386        Self {
387            enabled: false,
388            max_concurrent: default_max_concurrent(),
389            extra_dirs: Vec::new(),
390            user_agents_dir: None,
391            default_permission_mode: None,
392            default_disallowed_tools: Vec::new(),
393            allow_bypass_permissions: false,
394            default_memory_scope: None,
395            hooks: SubAgentLifecycleHooks::default(),
396            transcript_dir: None,
397            transcript_enabled: default_transcript_enabled(),
398            transcript_max_files: default_transcript_max_files(),
399            context_window_turns: default_context_window_turns(),
400            max_spawn_depth: default_max_spawn_depth(),
401            context_injection_mode: ContextInjectionMode::default(),
402        }
403    }
404}
405
406/// Config-level lifecycle hooks fired when any sub-agent starts or stops.
407#[derive(Debug, Clone, Default, Deserialize, Serialize)]
408#[serde(default)]
409pub struct SubAgentLifecycleHooks {
410    /// Hooks run after a sub-agent is spawned (fire-and-forget).
411    pub start: Vec<HookDef>,
412    /// Hooks run after a sub-agent finishes or is cancelled (fire-and-forget).
413    pub stop: Vec<HookDef>,
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn subagent_config_defaults() {
422        let cfg = SubAgentConfig::default();
423        assert_eq!(cfg.context_window_turns, 10);
424        assert_eq!(cfg.max_spawn_depth, 3);
425        assert_eq!(
426            cfg.context_injection_mode,
427            ContextInjectionMode::LastAssistantTurn
428        );
429    }
430
431    #[test]
432    fn subagent_config_deserialize_new_fields() {
433        let toml_str = r#"
434            enabled = true
435            context_window_turns = 5
436            max_spawn_depth = 2
437            context_injection_mode = "none"
438        "#;
439        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
440        assert_eq!(cfg.context_window_turns, 5);
441        assert_eq!(cfg.max_spawn_depth, 2);
442        assert_eq!(cfg.context_injection_mode, ContextInjectionMode::None);
443    }
444
445    #[test]
446    fn model_spec_deserialize_inherit() {
447        let spec: ModelSpec = serde_json::from_str("\"inherit\"").unwrap();
448        assert_eq!(spec, ModelSpec::Inherit);
449    }
450
451    #[test]
452    fn model_spec_deserialize_named() {
453        let spec: ModelSpec = serde_json::from_str("\"fast\"").unwrap();
454        assert_eq!(spec, ModelSpec::Named("fast".to_owned()));
455    }
456
457    #[test]
458    fn model_spec_as_str() {
459        assert_eq!(ModelSpec::Inherit.as_str(), "inherit");
460        assert_eq!(ModelSpec::Named("x".to_owned()).as_str(), "x");
461    }
462}