Skip to main content

telos_agent/
config.rs

1//! Configuration types for an [`AgentSession`](crate::AgentSession).
2//!
3//! [`AgentConfig`] aggregates everything a session needs: model behaviour
4//! (system prompt, optional iteration cap), execution environment (cwd, env), and the
5//! pluggable extension points (policies, storage, compaction, permissions).
6
7use std::collections::HashMap;
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, Ordering};
11
12use crate::agent::compaction::{HistoryCompactionStrategy, SummaryHistoryCompaction};
13use crate::agent::policies::PolicyRegistry;
14use crate::agent::prompt::PromptProfile;
15use crate::diagnostics::ToolDiagnosticsSink;
16use crate::error::AgentError;
17use crate::storage::Storage;
18use crate::tools::approval::ApprovalHandler;
19
20/// Return the small set of host environment variables needed for tools to
21/// launch platform-native child processes without inheriting the full process
22/// environment.
23pub fn platform_base_env() -> HashMap<String, String> {
24    let mut env = HashMap::new();
25    for key in platform_base_env_keys() {
26        if let Some((actual_key, value)) =
27            std::env::vars().find(|(name, _)| name.eq_ignore_ascii_case(key))
28        {
29            env.insert(actual_key, value);
30        }
31    }
32    env
33}
34
35#[cfg(windows)]
36fn platform_base_env_keys() -> &'static [&'static str] {
37    &[
38        "Path",
39        "SystemRoot",
40        "WINDIR",
41        "USERPROFILE",
42        "TEMP",
43        "TMP",
44        "APPDATA",
45        "LOCALAPPDATA",
46        "ComSpec",
47        "PATHEXT",
48        "PSModulePath",
49    ]
50}
51
52#[cfg(not(windows))]
53fn platform_base_env_keys() -> &'static [&'static str] {
54    &["PATH", "HOME"]
55}
56
57/// Shared cancellation state for one agent runtime.
58///
59/// The atomic flag preserves cheap synchronous checks, while the notify handle
60/// wakes async waits that would otherwise remain parked until a provider or
61/// tool produces another event.
62#[derive(Clone, Debug)]
63pub struct CancellationState {
64    flag: Arc<AtomicBool>,
65    notify: Arc<tokio::sync::Notify>,
66}
67
68impl CancellationState {
69    pub fn new() -> Self {
70        Self {
71            flag: Arc::new(AtomicBool::new(false)),
72            notify: Arc::new(tokio::sync::Notify::new()),
73        }
74    }
75
76    pub fn from_flag(flag: Arc<AtomicBool>) -> Self {
77        Self { flag, notify: Arc::new(tokio::sync::Notify::new()) }
78    }
79
80    pub fn flag(&self) -> Arc<AtomicBool> {
81        Arc::clone(&self.flag)
82    }
83
84    pub fn is_cancelled(&self) -> bool {
85        self.flag.load(Ordering::Relaxed)
86    }
87
88    pub fn cancel(&self) {
89        self.flag.store(true, Ordering::Relaxed);
90        self.notify.notify_waiters();
91    }
92
93    pub fn reset(&self) {
94        self.flag.store(false, Ordering::Relaxed);
95    }
96
97    pub async fn cancelled(&self) {
98        if self.is_cancelled() {
99            return;
100        }
101
102        loop {
103            self.notify.notified().await;
104            if self.is_cancelled() {
105                return;
106            }
107        }
108    }
109}
110
111impl Default for CancellationState {
112    fn default() -> Self {
113        Self::new()
114    }
115}
116
117/// Workflow path — the agent tailors its runtime behaviour and prompt guidance
118/// to match the expected scope and risk of the task.
119///
120/// | Path      | Typical task                                     |
121/// |-----------|--------------------------------------------------|
122/// | `Fast`    | Single-file fix, clear bug, small config change  |
123/// | `Standard`| Multi-file change, local restructure             |
124/// | `Heavy`   | New feature, cross-module refactor, 3+ steps     |
125///
126/// The path adjusts iteration caps, timeouts, and concurrency defaults. It
127/// also injects matching behavioural guidance into the system prompt so the
128/// model knows whether to work directly or follow a fuller plan cycle.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
130pub enum TaskPath {
131    /// Single-file changes, clear bugs, small config — execute directly.
132    Fast,
133    /// Multi-file changes, local restructures — map context, verify incrementally.
134    #[default]
135    Standard,
136    /// New features, cross-module refactors — design, plan, execute in phases.
137    Heavy,
138}
139
140/// Configuration for an [`AgentSession`](crate::AgentSession).
141///
142/// Build with [`Default::default`] and override fields as needed. All fields
143/// are public so callers don't need a builder for simple cases.
144#[derive(Clone)]
145pub struct AgentConfig {
146    /// Workflow path classification for the current task. Influences iteration
147    /// budget, timeout, concurrency, and the system-prompt guidance injected at
148    /// turn time.
149    pub path: TaskPath,
150    /// Optional base instruction appended to the identity section of the
151    /// system prompt. For full control, use `prompt_assembly` instead.
152    pub base_system_prompt: Option<String>,
153    /// Optional pre-built prompt assembly. When set, the runtime uses this
154    /// instead of constructing the prompt from `base_system_prompt` alone.
155    pub prompt_assembly: Option<std::sync::Arc<crate::agent::prompt::PromptAssembly>>,
156    /// Controls how much built-in prompt guidance is injected when the runtime
157    /// constructs the default prompt assembly.
158    pub prompt_profile: PromptProfile,
159    /// Optional maximum number of model ⇄ tool round-trips per turn before the
160    /// loop aborts with [`AgentError::MaxIterations`].
161    ///
162    /// `None` means the model/tool loop is allowed to continue until the model
163    /// finishes, the turn is cancelled, or another runtime limit is reached.
164    pub max_iterations: Option<usize>,
165    /// Working directory used as the root for filesystem tools and shell commands.
166    pub cwd: PathBuf,
167    /// Environment variables exposed to shell-based tools.
168    pub env: HashMap<String, String>,
169    /// Hard cap on the character length of any individual tool result. Anything
170    /// longer is replaced with a truncated preview to protect the context window.
171    pub max_tool_result_chars: usize,
172    /// Hard cap on the aggregate character length of all tool results within a
173    /// single message. When N parallel tool calls collectively exceed this, the
174    /// largest results are truncated to fit. Prevents a turn with many tool
175    /// calls from flooding the context window even when each result individually
176    /// stays under `max_tool_result_chars`.
177    pub max_message_tool_results_chars: usize,
178    /// Registry of semantic runtime policies.
179    pub policies: Arc<PolicyRegistry>,
180    /// Optional persistent backing store for session messages.
181    pub storage: Option<Arc<dyn Storage>>,
182    /// Optional sink for sanitized tool failure diagnostics.
183    pub tool_diagnostics: Option<Arc<dyn ToolDiagnosticsSink>>,
184    /// Optional history-level compaction strategy (summarisation, etc.).
185    pub compaction: Option<Arc<dyn HistoryCompactionStrategy>>,
186    /// Optional rule-based permission engine consulted before every tool call.
187    pub permission_engine: Option<crate::tools::permissions::PermissionEngine>,
188    /// Optional handler invoked when a tool call requires explicit human approval.
189    pub approval_handler: Option<Arc<dyn ApprovalHandler>>,
190    /// Maximum number of concurrency-safe tools to run in parallel within a single batch.
191    pub tool_concurrency_limit: usize,
192    /// Optional token budget that triggers proactive compaction.
193    pub token_budget: Option<TokenBudget>,
194    /// Retry configuration for transient provider failures.
195    /// When `None`, provider calls are not retried.
196    pub retry: RetryConfig,
197    /// Whether to automatically validate tool arguments against the tool's
198    /// `input_schema` after the tool's own `validate` method runs.
199    pub auto_validate_schema: bool,
200    /// Shared cancellation state. Hosts call `cancel()` to interrupt a running
201    /// turn and `reset()` before starting the next turn.
202    pub cancellation: CancellationState,
203    /// Per-tool execution timeout in milliseconds. When set, any tool that runs
204    /// longer than this will be interrupted and its result will be an
205    /// `is_error: true` timeout so the model can recover.
206    pub tool_timeout_ms: Option<u64>,
207    /// Maximum number of bytes the built-in file tools will read from a single
208    /// file. Files larger than this are rejected to avoid OOMing the agent.
209    pub max_file_read_bytes: usize,
210    /// Optional skill registry. When set, the Skill tool and the default prompt
211    /// assembly can use registered skills (including bundled Superpowers skills).
212    pub skill_registry: Option<Arc<crate::knowledge::skills::SkillRegistry>>,
213    /// Optional plugin registry. When set, enabled plugins' components are
214    /// applied to tool/policy/skill registries at session startup.
215    pub plugin_registry: Option<Arc<crate::integrations::plugin::PluginRegistry>>,
216    /// Optional task manager used by long-running tools such as background
217    /// subagents to publish lifecycle state and output.
218    pub task_manager: Option<Arc<crate::knowledge::tasks::TaskManager>>,
219    /// Optional memory injector for dynamic per-turn memory scoring.
220    /// When set, memories are scored against the user's current input
221    /// and injected as system reminders before the first provider call
222    /// of each turn.
223    pub memory_injector: Option<Arc<crate::agent::context::MemoryInjector>>,
224    /// Optional skill injector for dynamic per-turn skill discovery.
225    /// When set, top matching skills are injected as system reminders
226    /// before the first provider call of each turn.
227    pub skill_injector: Option<Arc<crate::agent::context::SkillInjector>>,
228    /// Optional MCP manager. When set, MCP server tools are bridged into the
229    /// tool registry and an MCP tools section is added to the prompt assembly.
230    pub mcp_manager: Option<Arc<crate::integrations::mcp::McpManager>>,
231    /// Optional configuration for the bidirectional HTTP event channel.
232    /// When set and `enabled` is `true`, the session starts an embedded HTTP
233    /// server on the configured address, accepting external event injection
234    /// (`POST /inject`) and streaming TurnEvents via SSE (`GET /events`).
235    pub event_channel: Option<crate::integrations::event_channel::EventChannelConfig>,
236}
237
238impl std::fmt::Debug for AgentConfig {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        // Redact `env` values because they may contain secrets; keys are kept
241        // for debugging. Other fields are considered non-sensitive.
242        let env_keys: Vec<&String> = self.env.keys().collect();
243        f.debug_struct("AgentConfig")
244            .field("path", &self.path)
245            .field("base_system_prompt", &self.base_system_prompt)
246            .field("prompt_assembly", &self.prompt_assembly.as_ref().map(|_| "<set>"))
247            .field("prompt_profile", &self.prompt_profile)
248            .field("max_iterations", &self.max_iterations)
249            .field("cwd", &self.cwd)
250            .field("env", &format!("{} keys: [REDACTED]", env_keys.len()))
251            .field("max_tool_result_chars", &self.max_tool_result_chars)
252            .field("max_message_tool_results_chars", &self.max_message_tool_results_chars)
253            .field("policies", &self.policies)
254            .field("storage", &self.storage)
255            .field("tool_diagnostics", &self.tool_diagnostics.as_ref().map(|_| "<set>"))
256            .field("compaction", &self.compaction)
257            .field("permission_engine", &self.permission_engine)
258            .field("approval_handler", &self.approval_handler)
259            .field("tool_concurrency_limit", &self.tool_concurrency_limit)
260            .field("token_budget", &self.token_budget)
261            .field("retry", &self.retry)
262            .field("auto_validate_schema", &self.auto_validate_schema)
263            .field("cancellation", &self.cancellation)
264            .field("tool_timeout_ms", &self.tool_timeout_ms)
265            .field("max_file_read_bytes", &self.max_file_read_bytes)
266            .field("skill_registry", &self.skill_registry.as_ref().map(|_| "<set>"))
267            .field("plugin_registry", &self.plugin_registry.as_ref().map(|_| "<set>"))
268            .field("task_manager", &self.task_manager.as_ref().map(|_| "<set>"))
269            .field("memory_injector", &self.memory_injector.as_ref().map(|_| "<set>"))
270            .field("skill_injector", &self.skill_injector.as_ref().map(|_| "<set>"))
271            .field("mcp_manager", &self.mcp_manager.as_ref().map(|_| "<set>"))
272            .field("event_channel", &self.event_channel)
273            .finish()
274    }
275}
276
277impl Default for AgentConfig {
278    fn default() -> Self {
279        Self {
280            path: TaskPath::default(),
281            base_system_prompt: None,
282            prompt_assembly: None,
283            prompt_profile: PromptProfile::default(),
284            max_iterations: None,
285            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
286            // Start with a minimal platform environment for shell tools. Callers
287            // that need specific variables can add them explicitly; inheriting
288            // the entire process environment risks leaking secrets to arbitrary
289            // tool calls.
290            env: platform_base_env(),
291            max_tool_result_chars: 50_000,
292            max_message_tool_results_chars: 300_000,
293            policies: Arc::new(PolicyRegistry::new()),
294            storage: None,
295            tool_diagnostics: None,
296            compaction: Some(Arc::new(SummaryHistoryCompaction {
297                keep_recent: 12,
298                max_summary_input_tokens: 800_000,
299                summary_output_tokens: 4_000,
300            })),
301            permission_engine: None,
302            approval_handler: None,
303            tool_concurrency_limit: 10,
304            token_budget: None,
305            retry: RetryConfig::default(),
306            auto_validate_schema: true,
307            cancellation: CancellationState::new(),
308            tool_timeout_ms: None,
309            max_file_read_bytes: 50 * 1024 * 1024,
310            skill_registry: None,
311            plugin_registry: None,
312            task_manager: None,
313            memory_injector: None,
314            skill_injector: None,
315            mcp_manager: None,
316            event_channel: None,
317        }
318    }
319}
320
321/// Token budget that triggers automatic compaction before the hard limit is hit.
322///
323/// The runtime estimates request size locally (via
324/// [`ModelProvider::estimate_tokens`](crate::ModelProvider::estimate_tokens))
325/// and compacts when usage exceeds `compact_at_tokens`. If a request still
326/// exceeds `max_tokens` after compaction, the turn ends to avoid an API
327/// rejection.
328///
329/// `compact_at_tokens` defaults to 80% of `max_tokens`.
330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
331pub struct TokenBudget {
332    /// Hard ceiling — the turn ends before the model is called if exceeded.
333    pub max_tokens: usize,
334    /// Soft ceiling — proactively compact when crossed.
335    pub compact_at_tokens: usize,
336}
337
338impl TokenBudget {
339    /// Build a budget with `compact_at_tokens` set to 80% of `max_tokens`.
340    pub fn new(max_tokens: usize) -> Self {
341        Self { max_tokens, compact_at_tokens: ((max_tokens as f64) * 0.8).ceil() as usize }
342    }
343}
344
345/// Retry behaviour for transient provider failures.
346///
347/// When a provider call fails with a retryable error (network errors, 429, 5xx),
348/// the runtime will retry with exponential backoff up to `max_retries` times.
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct RetryConfig {
351    /// Maximum number of retry attempts (0 = no retries).
352    pub max_retries: usize,
353    /// Initial backoff delay in milliseconds.
354    pub base_delay_ms: u64,
355    /// Maximum backoff delay in milliseconds.
356    pub max_delay_ms: u64,
357}
358
359impl Default for RetryConfig {
360    fn default() -> Self {
361        Self { max_retries: 3, base_delay_ms: 500, max_delay_ms: 10_000 }
362    }
363}
364
365impl RetryConfig {
366    /// No retries at all — a convenient sentinel that can replace `Option<RetryConfig>`.
367    pub const NONE: Self = Self { max_retries: 0, base_delay_ms: 0, max_delay_ms: 0 };
368
369    /// Exponential backoff delay for the given attempt (1-indexed).
370    pub fn delay_for(&self, attempt: usize) -> std::time::Duration {
371        // Cap the shift to avoid undefined behaviour / overflow when attempt is
372        // very large (shifting a u64 by >= 64 bits is UB in Rust).
373        let shift = attempt.saturating_sub(1).min(63);
374        let delay = self.base_delay_ms.saturating_mul(1u64 << shift);
375        let capped = delay.min(self.max_delay_ms);
376        std::time::Duration::from_millis(capped)
377    }
378}
379
380impl AgentConfig {
381    /// Request cancellation and wake any runtime await that is listening for it.
382    pub fn request_cancel(&self) {
383        self.cancellation.cancel();
384    }
385
386    /// Clear a previous cancellation request before starting a new turn.
387    pub fn reset_cancel(&self) {
388        self.cancellation.reset();
389    }
390
391    /// Validate configuration values and return a structured error for any
392    /// dangerous or nonsensical combination.
393    pub fn validate(&self) -> Result<(), AgentError> {
394        if self.max_iterations == Some(0) {
395            return Err(AgentError::Config("max_iterations must be greater than 0".into()));
396        }
397        if self.tool_concurrency_limit == 0 {
398            return Err(AgentError::Config("tool_concurrency_limit must be greater than 0".into()));
399        }
400        if self.max_file_read_bytes == 0 {
401            return Err(AgentError::Config("max_file_read_bytes must be greater than 0".into()));
402        }
403        if self.max_tool_result_chars == 0 {
404            return Err(AgentError::Config("max_tool_result_chars must be greater than 0".into()));
405        }
406        if let Some(budget) = self.token_budget {
407            if budget.max_tokens == 0 {
408                return Err(AgentError::Config(
409                    "token_budget.max_tokens must be greater than 0".into(),
410                ));
411            }
412            if budget.compact_at_tokens == 0 {
413                return Err(AgentError::Config(
414                    "token_budget.compact_at_tokens must be greater than 0".into(),
415                ));
416            }
417            if budget.compact_at_tokens > budget.max_tokens {
418                return Err(AgentError::Config(
419                    "token_budget.compact_at_tokens cannot exceed max_tokens".into(),
420                ));
421            }
422        }
423        Ok(())
424    }
425
426    /// Replace `base_system_prompt` with the default modular prompt assembly.
427    ///
428    /// This is the recommended way to use telos-agent's built-in default
429    /// prompt sections without manually wiring each section.
430    pub fn with_default_prompt_assembly(
431        mut self,
432        tools: Arc<crate::tools::api::ToolRegistry>,
433    ) -> Result<Self, AgentError> {
434        let assembly = crate::agent::prompt::default_coding_assembly_for_profile(
435            tools,
436            self.cwd.clone(),
437            self.skill_registry.clone(),
438            self.path,
439            self.prompt_profile,
440        );
441        self.base_system_prompt = None;
442        self.prompt_assembly = Some(Arc::new(assembly));
443        Ok(self)
444    }
445
446    /// Apply path-appropriate defaults for timeouts, token budgets, and
447    /// concurrency. Call after setting custom values if you want the path to
448    /// override them.
449    ///
450    /// | Knob                   | Fast      | Standard  | Heavy     |
451    /// |------------------------|-----------|-----------|-----------|
452    /// | `max_iterations`       | None      | None      | None      |
453    /// | `tool_concurrency_limit`| 10       | 10        | 5         |
454    /// | `token_budget`         | None      | None      | 308k      |
455    /// | `tool_timeout_ms`      | 30_000    | None      | 60_000    |
456    pub fn with_path(mut self, path: TaskPath) -> Self {
457        self.path = path;
458        match path {
459            TaskPath::Fast => {
460                self.max_iterations = None;
461                self.tool_timeout_ms = Some(30_000);
462                // Fast tasks shouldn't need compaction — keep budget off.
463                self.token_budget = None;
464            }
465            TaskPath::Standard => {
466                self.max_iterations = None;
467                self.tool_timeout_ms = None;
468                // 1M context window — compact early to leave headroom for output.
469                self.token_budget = Some(TokenBudget::new(900_000));
470            }
471            TaskPath::Heavy => {
472                self.max_iterations = None;
473                self.tool_timeout_ms = Some(60_000);
474                self.token_budget = Some(TokenBudget::new(950_000));
475                self.tool_concurrency_limit = 5;
476            }
477        }
478        self
479    }
480
481    /// Load bundled skills into a fresh skill registry attached to this config.
482    pub fn with_bundled_skills(mut self) -> Self {
483        let mut registry = crate::knowledge::skills::SkillRegistry::new();
484        registry.load_bundled_skills();
485        let registry = Arc::new(registry);
486        self.skill_injector =
487            Some(Arc::new(crate::agent::context::SkillInjector::new(Arc::clone(&registry))));
488        self.skill_registry = Some(registry);
489        self
490    }
491
492    /// Apply plugin components into the agent registries.
493    ///
494    /// Call this before creating an [`AgentSession`](crate::AgentSession) to
495    /// populate tools/policies/skills/mcp/prompt with enabled plugin content.
496    /// Always returns the registries (even on error) so callers can
497    /// continue with degraded state.
498    pub fn apply_plugins(
499        &self,
500        mut tools: crate::tools::api::ToolRegistry,
501        mut policies: crate::agent::policies::PolicyRegistry,
502        mut skills: crate::knowledge::skills::SkillRegistry,
503        mut mcp: crate::integrations::mcp::McpManager,
504        mut prompt: crate::agent::prompt::PromptAssembly,
505    ) -> (
506        crate::tools::api::ToolRegistry,
507        crate::agent::policies::PolicyRegistry,
508        crate::knowledge::skills::SkillRegistry,
509        crate::integrations::mcp::McpManager,
510        crate::agent::prompt::PromptAssembly,
511        Result<(), Vec<crate::integrations::plugin::PluginError>>,
512    ) {
513        let result = if let Some(registry) = &self.plugin_registry {
514            registry.apply(&mut tools, &mut policies, &self.env, &mut skills, &mut mcp, &mut prompt)
515        } else {
516            Ok(())
517        };
518        (tools, policies, skills, mcp, prompt, result)
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn token_budget_new_sets_compact_at_80_percent() {
528        let budget = TokenBudget::new(1000);
529        assert_eq!(budget.max_tokens, 1000);
530        assert_eq!(budget.compact_at_tokens, 800);
531    }
532
533    #[test]
534    fn token_budget_rounds_up() {
535        let budget = TokenBudget::new(100);
536        // 80% of 100 = 80, ceil(80) = 80
537        assert_eq!(budget.compact_at_tokens, 80);
538        let budget2 = TokenBudget::new(101);
539        // 80% of 101 = 80.8, ceil = 81
540        assert_eq!(budget2.compact_at_tokens, 81);
541    }
542
543    #[test]
544    #[cfg(not(windows))]
545    fn default_env_contains_unix_runtime_env() {
546        let config = AgentConfig::default();
547        assert!(config.env.contains_key("PATH"));
548        assert_eq!(config.env.get("HOME"), std::env::var("HOME").ok().as_ref());
549    }
550
551    #[test]
552    #[cfg(windows)]
553    fn default_env_preserves_windows_runtime_env() {
554        let config = AgentConfig::default();
555        let expected = [
556            "Path",
557            "SystemRoot",
558            "WINDIR",
559            "USERPROFILE",
560            "TEMP",
561            "TMP",
562            "APPDATA",
563            "LOCALAPPDATA",
564            "ComSpec",
565            "PATHEXT",
566            "PSModulePath",
567        ];
568
569        let mut present = 0;
570        for key in expected {
571            if let Some((actual_key, actual_value)) =
572                std::env::vars().find(|(name, _)| name.eq_ignore_ascii_case(key))
573            {
574                present += 1;
575                assert_eq!(
576                    config
577                        .env
578                        .iter()
579                        .find(|(name, _)| name.eq_ignore_ascii_case(&actual_key))
580                        .map(|(_, value)| value.as_str()),
581                    Some(actual_value.as_str()),
582                    "missing preserved env var {actual_key}"
583                );
584            }
585        }
586        assert!(present > 0, "test expected at least one Windows runtime env var");
587    }
588
589    #[test]
590    #[cfg(windows)]
591    fn platform_base_env_does_not_invent_unix_path_on_windows() {
592        let env = platform_base_env();
593        assert_ne!(
594            env.iter()
595                .find(|(name, _)| name.eq_ignore_ascii_case("PATH"))
596                .map(|(_, value)| value.as_str()),
597            Some("/usr/local/bin:/usr/bin:/bin")
598        );
599    }
600
601    #[test]
602    fn default_config_validates() {
603        let config = AgentConfig::default();
604        assert!(config.validate().is_ok());
605    }
606
607    #[test]
608    fn zero_max_iterations_invalid() {
609        let config = AgentConfig { max_iterations: Some(0), ..AgentConfig::default() };
610        assert!(config.validate().is_err());
611    }
612
613    #[test]
614    fn token_budget_compact_exceeding_max_invalid() {
615        let config = AgentConfig {
616            token_budget: Some(TokenBudget { max_tokens: 100, compact_at_tokens: 200 }),
617            ..AgentConfig::default()
618        };
619        assert!(config.validate().is_err());
620    }
621}