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        "bash".into(),
115        "read".into(),
116        "edit".into(),
117    ]
118}
119
120fn default_instruction_auto_detect() -> bool {
121    true
122}
123
124fn default_max_concurrent() -> usize {
125    5
126}
127
128fn default_context_window_turns() -> usize {
129    10
130}
131
132fn default_max_spawn_depth() -> u32 {
133    3
134}
135
136fn default_transcript_enabled() -> bool {
137    true
138}
139
140fn default_transcript_max_files() -> usize {
141    50
142}
143
144/// Configuration for focus-based active context compression (#1850).
145#[derive(Debug, Clone, Deserialize, Serialize)]
146#[serde(default)]
147pub struct FocusConfig {
148    /// Enable focus tools (`start_focus` / `complete_focus`). Default: `false`.
149    pub enabled: bool,
150    /// Suggest focus after this many turns without one. Default: `12`.
151    #[serde(default = "default_focus_compression_interval")]
152    pub compression_interval: usize,
153    /// Remind the agent every N turns when focus is overdue. Default: `15`.
154    #[serde(default = "default_focus_reminder_interval")]
155    pub reminder_interval: usize,
156    /// Minimum messages required before suggesting a focus. Default: `8`.
157    #[serde(default = "default_focus_min_messages_per_focus")]
158    pub min_messages_per_focus: usize,
159    /// Maximum tokens the Knowledge block may grow to before old entries are trimmed.
160    /// Default: `4096`.
161    #[serde(default = "default_focus_max_knowledge_tokens")]
162    pub max_knowledge_tokens: usize,
163}
164
165impl Default for FocusConfig {
166    fn default() -> Self {
167        Self {
168            enabled: false,
169            compression_interval: default_focus_compression_interval(),
170            reminder_interval: default_focus_reminder_interval(),
171            min_messages_per_focus: default_focus_min_messages_per_focus(),
172            max_knowledge_tokens: default_focus_max_knowledge_tokens(),
173        }
174    }
175}
176
177/// Dynamic tool schema filtering configuration (#2020).
178///
179/// When enabled, only a subset of tool definitions is sent to the LLM on each turn,
180/// selected by embedding similarity between the user query and tool descriptions.
181#[derive(Debug, Clone, Deserialize, Serialize)]
182#[serde(default)]
183pub struct ToolFilterConfig {
184    /// Enable dynamic tool schema filtering. Default: `false` (opt-in).
185    pub enabled: bool,
186    /// Number of top-scoring filterable tools to include per turn.
187    /// Set to `0` to include all filterable tools.
188    #[serde(default = "default_tool_filter_top_k")]
189    pub top_k: usize,
190    /// Tool IDs that are never filtered out.
191    #[serde(default = "default_tool_filter_always_on")]
192    pub always_on: Vec<String>,
193    /// MCP tools with fewer description words than this are auto-included.
194    #[serde(default = "default_tool_filter_min_description_words")]
195    pub min_description_words: usize,
196}
197
198impl Default for ToolFilterConfig {
199    fn default() -> Self {
200        Self {
201            enabled: false,
202            top_k: default_tool_filter_top_k(),
203            always_on: default_tool_filter_always_on(),
204            min_description_words: default_tool_filter_min_description_words(),
205        }
206    }
207}
208
209#[derive(Debug, Deserialize, Serialize)]
210pub struct AgentConfig {
211    pub name: String,
212    #[serde(default = "default_max_tool_iterations")]
213    pub max_tool_iterations: usize,
214    #[serde(default = "default_auto_update_check")]
215    pub auto_update_check: bool,
216    /// Additional instruction files to always load, regardless of provider.
217    #[serde(default)]
218    pub instruction_files: Vec<std::path::PathBuf>,
219    /// When true, automatically detect provider-specific instruction files
220    /// (e.g. `CLAUDE.md` for Claude, `AGENTS.md` for `OpenAI`).
221    #[serde(default = "default_instruction_auto_detect")]
222    pub instruction_auto_detect: bool,
223    /// Maximum retry attempts for transient tool errors (0 to disable).
224    #[serde(default = "default_max_tool_retries")]
225    pub max_tool_retries: usize,
226    /// Number of identical tool+args calls within the recent window to trigger repeat-detection
227    /// abort (0 to disable).
228    #[serde(default = "default_tool_repeat_threshold")]
229    pub tool_repeat_threshold: usize,
230    /// Maximum total wall-clock time (seconds) to spend on retries for a single tool call.
231    #[serde(default = "default_max_retry_duration_secs")]
232    pub max_retry_duration_secs: u64,
233    /// Focus-based active context compression configuration (#1850).
234    #[serde(default)]
235    pub focus: FocusConfig,
236    /// Dynamic tool schema filtering configuration (#2020).
237    #[serde(default)]
238    pub tool_filter: ToolFilterConfig,
239    /// Inject a `<budget>` XML block into the volatile system prompt section so the LLM
240    /// can self-regulate tool calls and cost. Self-suppresses when no budget data is
241    /// available (#2267).
242    #[serde(default = "default_budget_hint_enabled")]
243    pub budget_hint_enabled: bool,
244}
245
246fn default_budget_hint_enabled() -> bool {
247    true
248}
249
250#[derive(Debug, Clone, Deserialize, Serialize)]
251#[serde(default)]
252pub struct SubAgentConfig {
253    pub enabled: bool,
254    /// Maximum number of sub-agents that can run concurrently.
255    #[serde(default = "default_max_concurrent")]
256    pub max_concurrent: usize,
257    pub extra_dirs: Vec<PathBuf>,
258    /// User-level agents directory.
259    #[serde(default)]
260    pub user_agents_dir: Option<PathBuf>,
261    /// Default permission mode applied to sub-agents that do not specify one.
262    pub default_permission_mode: Option<PermissionMode>,
263    /// Global denylist applied to all sub-agents in addition to per-agent `tools.except`.
264    #[serde(default)]
265    pub default_disallowed_tools: Vec<String>,
266    /// Allow sub-agents to use `bypass_permissions` mode.
267    #[serde(default)]
268    pub allow_bypass_permissions: bool,
269    /// Default memory scope applied to sub-agents that do not set `memory` in their definition.
270    #[serde(default)]
271    pub default_memory_scope: Option<MemoryScope>,
272    /// Lifecycle hooks executed when any sub-agent starts or stops.
273    #[serde(default)]
274    pub hooks: SubAgentLifecycleHooks,
275    /// Directory where transcript JSONL files and meta sidecars are stored.
276    #[serde(default)]
277    pub transcript_dir: Option<PathBuf>,
278    /// Enable writing JSONL transcripts for sub-agent sessions.
279    #[serde(default = "default_transcript_enabled")]
280    pub transcript_enabled: bool,
281    /// Maximum number of `.jsonl` transcript files to keep.
282    #[serde(default = "default_transcript_max_files")]
283    pub transcript_max_files: usize,
284    /// Number of recent parent conversation turns to pass to spawned sub-agents.
285    /// Set to 0 to disable history propagation.
286    #[serde(default = "default_context_window_turns")]
287    pub context_window_turns: usize,
288    /// Maximum nesting depth for sub-agent spawns.
289    #[serde(default = "default_max_spawn_depth")]
290    pub max_spawn_depth: u32,
291    /// How parent context is injected into the sub-agent's task prompt.
292    #[serde(default)]
293    pub context_injection_mode: ContextInjectionMode,
294}
295
296impl Default for SubAgentConfig {
297    fn default() -> Self {
298        Self {
299            enabled: false,
300            max_concurrent: default_max_concurrent(),
301            extra_dirs: Vec::new(),
302            user_agents_dir: None,
303            default_permission_mode: None,
304            default_disallowed_tools: Vec::new(),
305            allow_bypass_permissions: false,
306            default_memory_scope: None,
307            hooks: SubAgentLifecycleHooks::default(),
308            transcript_dir: None,
309            transcript_enabled: default_transcript_enabled(),
310            transcript_max_files: default_transcript_max_files(),
311            context_window_turns: default_context_window_turns(),
312            max_spawn_depth: default_max_spawn_depth(),
313            context_injection_mode: ContextInjectionMode::default(),
314        }
315    }
316}
317
318/// Config-level lifecycle hooks fired when any sub-agent starts or stops.
319#[derive(Debug, Clone, Default, Deserialize, Serialize)]
320#[serde(default)]
321pub struct SubAgentLifecycleHooks {
322    /// Hooks run after a sub-agent is spawned (fire-and-forget).
323    pub start: Vec<HookDef>,
324    /// Hooks run after a sub-agent finishes or is cancelled (fire-and-forget).
325    pub stop: Vec<HookDef>,
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn subagent_config_defaults() {
334        let cfg = SubAgentConfig::default();
335        assert_eq!(cfg.context_window_turns, 10);
336        assert_eq!(cfg.max_spawn_depth, 3);
337        assert_eq!(
338            cfg.context_injection_mode,
339            ContextInjectionMode::LastAssistantTurn
340        );
341    }
342
343    #[test]
344    fn subagent_config_deserialize_new_fields() {
345        let toml_str = r#"
346            enabled = true
347            context_window_turns = 5
348            max_spawn_depth = 2
349            context_injection_mode = "none"
350        "#;
351        let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
352        assert_eq!(cfg.context_window_turns, 5);
353        assert_eq!(cfg.max_spawn_depth, 2);
354        assert_eq!(cfg.context_injection_mode, ContextInjectionMode::None);
355    }
356
357    #[test]
358    fn model_spec_deserialize_inherit() {
359        let spec: ModelSpec = serde_json::from_str("\"inherit\"").unwrap();
360        assert_eq!(spec, ModelSpec::Inherit);
361    }
362
363    #[test]
364    fn model_spec_deserialize_named() {
365        let spec: ModelSpec = serde_json::from_str("\"fast\"").unwrap();
366        assert_eq!(spec, ModelSpec::Named("fast".to_owned()));
367    }
368
369    #[test]
370    fn model_spec_as_str() {
371        assert_eq!(ModelSpec::Inherit.as_str(), "inherit");
372        assert_eq!(ModelSpec::Named("x".to_owned()).as_str(), "x");
373    }
374}