Skip to main content

gglib_core/domain/agent/
config.rs

1//! [`AgentConfig`] — configuration for a single agentic loop run.
2//!
3//! This module also defines the public ceiling constants used by HTTP and CLI
4//! callers to clamp untrusted user input to safe values.  Centralising them
5//! here ensures a single source of truth across all entry points.
6
7use serde::Serialize;
8use thiserror::Error;
9
10// =============================================================================
11// Ceiling constants — shared across HTTP and CLI callers
12// =============================================================================
13
14/// Hard ceiling on `max_iterations` accepted from external callers.
15///
16/// 50 iterations is generous for real workloads.  Prevents a crafted request
17/// from running an unbounded loop at server expense.
18pub const MAX_ITERATIONS_CEILING: usize = 50;
19
20/// Hard ceiling on `max_parallel_tools` accepted from external callers.
21///
22/// 50 concurrent tools per iteration is far beyond any practical need and
23/// prevents thread-pool saturation from crafted requests.  Modern reasoning
24/// models occasionally request large parallel batches (10–25 calls); the
25/// ceiling must comfortably exceed the default to leave headroom for users
26/// who legitimately want to raise the limit.
27pub const MAX_PARALLEL_TOOLS_CEILING: usize = 50;
28
29/// Hard ceiling on `tool_timeout_ms` accepted from external callers (60 s).
30///
31/// Prevents a crafted request from holding server connections open
32/// indefinitely via slow or stalled tool calls.
33pub const MAX_TOOL_TIMEOUT_MS_CEILING: u64 = 60_000;
34
35/// Hard floor on `tool_timeout_ms` accepted from external callers (100 ms).
36///
37/// A value of 0 would silently time out every tool call immediately, making
38/// tool calling unusable without a clear error.  100 ms is still very tight
39/// but allows intentionally fast tools (health checks, no-ops in tests).
40pub const MIN_TOOL_TIMEOUT_MS: u64 = 100;
41
42/// Hard floor on `context_budget_chars` (100 characters).
43///
44/// A budget below this threshold would cause the pruner to discard virtually
45/// all context, leaving the LLM with no meaningful history to reason about.
46pub const MIN_CONTEXT_BUDGET_CHARS: usize = 100;
47
48/// Default value for [`AgentConfig::max_iterations`].
49///
50/// Mirrors `DEFAULT_MAX_TOOL_ITERS = 25` from the TypeScript frontend.
51/// Used both in [`AgentConfig::default`] and in [`super::events::AGENT_EVENT_CHANNEL_CAPACITY`]
52/// so the channel size automatically scales with the iteration ceiling.
53pub const DEFAULT_MAX_ITERATIONS: usize = 25;
54
55/// Default value for [`AgentConfig::max_parallel_tools`].
56///
57/// Set to 25 to comfortably accommodate modern reasoning models (Qwen3-MoE,
58/// DeepSeek-R1, etc.) that routinely request 6–10 parallel tool calls per
59/// turn during exploration-heavy tasks (e.g. codebase reviews).  An overflow
60/// is no longer fatal — the loop now soft-recovers by injecting a synthetic
61/// tool error and asking the model to retry with a smaller batch — but a
62/// generous default avoids triggering that recovery path under normal load.
63///
64/// Used both in [`AgentConfig::default`] and in [`super::events::AGENT_EVENT_CHANNEL_CAPACITY`]
65/// so the channel size accounts for the correct number of concurrent tool events.
66pub const DEFAULT_MAX_PARALLEL_TOOLS: usize = 25;
67
68/// Default value for [`AgentConfig::max_stagnation_steps`].
69///
70/// The agent loop aborts when the same assistant text has been seen more
71/// than this many times, preventing infinite stagnant output.
72pub const DEFAULT_MAX_STAGNATION_STEPS: usize = 5;
73
74/// Hard ceiling on [`AgentConfig::max_stagnation_steps`] accepted from
75/// external callers.
76///
77/// Prevents a persisted setting or API caller from setting
78/// `max_stagnation_steps` to an arbitrarily large value, which would
79/// silently neutralise the stagnation guard and let a stuck model repeat
80/// the same response indefinitely.  Mirrors
81/// [`MAX_OBSERVATION_STEPS_CEILING`].
82pub const MAX_STAGNATION_STEPS_CEILING: usize = 100;
83
84/// Hard ceiling on [`AgentConfig::max_observation_steps`] accepted from
85/// external callers.
86///
87/// Prevents an API or CLI caller from setting `max_observation_steps` to an
88/// arbitrarily large value, which would silently neutralise the observation
89/// guard and allow a confused agent to call observation tools indefinitely.
90/// 100 observation-only iterations is far more than any legitimate browsing
91/// task requires.
92pub const MAX_OBSERVATION_STEPS_CEILING: usize = 100;
93
94/// Default value for [`AgentConfig::max_observation_steps`].
95///
96/// An exploratory-tool-only batch (every call matches a pattern in
97/// [`AgentConfig::observation_tools`]) may repeat up to this many times
98/// before loop detection fires.  15 is generous for multi-page browsing,
99/// multi-directory walking, and paginated API tasks while still catching
100/// a genuinely confused agent within a reasonable token budget.
101pub const DEFAULT_MAX_OBSERVATION_STEPS: usize = 15;
102
103/// Configuration that governs a single agentic loop run.
104///
105/// All fields have sensible defaults via [`Default`]. These used to mirror
106/// TypeScript constants in the frontend's own agent loop; that loop is
107/// backend-driven now, and `streamAgentChat.ts` records these fields as
108/// deliberately absent from it, so nothing in the frontend's loop competes with
109/// the values here. What the settings UI still carries are the *ceilings and
110/// floors* it clamps user input to (`MAX_PARALLEL_TOOLS_CEILING`,
111/// `TOOL_TIMEOUT_MS_FLOOR` and friends), and its own copies of the two defaults
112/// a person can edit — `MAX_TOOL_ITERATIONS`, pinned against this file by
113/// `settingsBounds.test.ts`, and `MAX_STAGNATION_STEPS`, pinned by
114/// `settingsParity.test.ts`.
115///
116/// # Serialisation
117///
118/// `AgentConfig` is intentionally **not** `Deserialize`.  External callers
119/// (HTTP, future config files) must go through a dedicated DTO that exposes
120/// only the safe subset of fields.  This prevents accidental exposure of
121/// internal tuning knobs (pruning parameters, strike limits, etc.) to
122/// untrusted callers.
123#[derive(Debug, Clone, Serialize)]
124#[non_exhaustive]
125pub struct AgentConfig {
126    /// Maximum number of LLM→tool→LLM iterations before the loop is aborted.
127    pub max_iterations: usize,
128
129    /// Maximum number of tool calls that may be executed in parallel per iteration.
130    ///
131    /// **Dual-purpose:** this value is used both as the `Semaphore` concurrency
132    /// cap in `tool_execution` (limiting simultaneous in-flight calls) *and* as
133    /// an upper bound on the batch size the model may request in a single turn.
134    /// If the model emits more tool calls than this limit, the loop terminates
135    /// with [`AgentError::ParallelToolLimitExceeded`](crate::ports::AgentError::ParallelToolLimitExceeded)
136    /// rather than silently
137    /// serialising them.  Setting this to `1` means the model may only request
138    /// **one** tool call per turn; two calls in a single response will abort the
139    /// loop, not run them sequentially.
140    pub max_parallel_tools: usize,
141
142    /// Per-tool execution timeout in milliseconds.
143    pub tool_timeout_ms: u64,
144
145    /// Maximum total character budget across all messages before context pruning
146    /// is applied.
147    pub context_budget_chars: usize,
148
149    /// Maximum number of times the same tool-call batch signature may repeat
150    /// **back to back and answered the same way** before the loop is declared
151    /// stuck and aborted with [`crate::ports::AgentError::LoopDetected`].
152    ///
153    /// Consecutive, not session-wide: a batch with a different signature
154    /// resets the run. So does an answer that differs from the previous
155    /// occurrence's — the same call with a different result is progress that
156    /// happens to look alike, and is not a strike. Contrast
157    /// [`Self::max_stagnation_steps`] below, which is windowed and reads
158    /// nothing but prose turns. See the `loop_detection` module docs for why
159    /// the three differ.
160    ///
161    /// Set to `None` to disable loop detection entirely (useful in tests that
162    /// deliberately repeat the same tool call).
163    pub max_repeated_batch_steps: Option<usize>,
164
165    /// Occurrence limit for identical assistant text within a sliding window,
166    /// before the loop is considered stagnant and aborted with
167    /// [`crate::ports::AgentError::StagnationDetected`].
168    ///
169    /// **Semantics:** Occurrences of the same response text are counted within
170    /// the last `max_stagnation_steps × 4` recorded turns.  The error fires
171    /// when that count exceeds `max_stagnation_steps`.  With the default value
172    /// of `5`, stagnation triggers on the **sixth** identical occurrence.
173    /// With `max_stagnation_steps = 0`, the error fires on the **very first**
174    /// occurrence of any repeated text.
175    ///
176    /// Only turns that called **no tools** are recorded: narration alongside a
177    /// tool call is not stagnation, and the loop detector judges that work.
178    /// A → B → A → B oscillation in the prose is still caught. See ADR 0011.
179    ///
180    /// Set to `None` to disable stagnation detection entirely (useful in tests
181    /// that return a fixed LLM response across many iterations).
182    pub max_stagnation_steps: Option<usize>,
183
184    /// Number of most-recent tool-result messages preserved during the first
185    /// pass of context pruning.
186    ///
187    /// Not exposed as a user-facing option because the value is calibrated
188    /// to balance context retention against token budget; changing it
189    /// independently of `context_budget_chars` can produce incoherent
190    /// conversation histories.
191    #[serde(skip)]
192    pub prune_keep_tool_messages: usize,
193
194    /// Number of non-system messages retained during the emergency tail-prune
195    /// pass (second pass of context pruning).
196    ///
197    /// Same rationale as [`Self::prune_keep_tool_messages`].
198    #[serde(skip)]
199    pub prune_keep_tail_messages: usize,
200
201    // -------------------------------------------------------------------------
202    // Dual-threshold observation guard
203    // -------------------------------------------------------------------------
204    //
205    // Standard loop detection (max_repeated_batch_steps) hashes tool names and
206    // arguments to detect a batch repeated back to back.  It does not detect
207    // cycles: a batch that recurs with other work in between resets the run,
208    // so A -> B -> A -> B escapes it entirely.  See the `loop_detection`
209    // module docs for why that cost was taken.  Observation-only tools (e.g. browser
210    // snapshots, screenshots) legitimately repeat with identical signatures
211    // because they take no meaningful arguments, yet return completely different
212    // page content on each call.  These two fields allow a separate, higher
213    // threshold to be applied when every tool in a batch is classified as an
214    // exploratory tool, preventing false-positive loop aborts during ReAct
215    // observation and navigation cycles while still catching an agent that
216    // repeats one of them back to back without pause.
217    /// Substring patterns used to classify tools as **exploratory**.
218    ///
219    /// "Exploratory" tools are those that drive progress by repeatedly
220    /// querying or traversing a stateful source — page snapshots, navigation,
221    /// clicks, file reads, directory listings, API pagination calls, etc.
222    /// Their repeated invocation with identical arguments is a legitimate
223    /// `ReAct` pattern, not a stuck loop.
224    ///
225    /// A tool call whose **lowercased** name contains any pattern in this list
226    /// as a substring is classified as exploratory, so a short pattern like
227    /// `"read"` also covers `thread_create`.  When **every** call in a batch
228    /// matches, [`Self::max_observation_steps`] is applied as the loop
229    /// detection threshold instead of [`Self::max_repeated_batch_steps`].
230    ///
231    /// **Matching semantics** — substring rather than exact string — are
232    /// intentional: MCP servers routinely prepend namespace prefixes to tool
233    /// names (e.g. `playwright_mcp_browser_snapshot`), so exact matching would
234    /// require users to enumerate every vendor variant.  The pattern `"navigate"`
235    /// matches `browser_navigate`, `db_navigate`, `fs_navigate`, etc.
236    ///
237    /// **BYO-MCP:** users connecting custom MCP servers should extend or replace
238    /// this list via [`AgentConfig::from_user_params`] to include their own
239    /// exploratory tool name fragments (e.g. `"get_dom"`, `"fetch_page"`,
240    /// `"list_dir"`).
241    ///
242    /// An empty list means no tools are ever classified as exploratory;
243    /// the standard [`Self::max_repeated_batch_steps`] threshold applies to all
244    /// batches.
245    ///
246    /// Defaults cover both agent families gglib serves: browser/GUI tools
247    /// (`snapshot`, `screenshot`, `read_page`, `navigate`, `click`) and the
248    /// read-only tools coding agents repeat constantly (`read_file`,
249    /// `list_dir`, `grep_search`, `search_files`, …). See the `Default` impl
250    /// for why every entry is a full tool name rather than a fragment.
251    pub observation_tools: Vec<String>,
252
253    /// Maximum number of times an exploratory-tool-only batch may repeat
254    /// **back to back and be answered the same way** before loop detection
255    /// fires.
256    ///
257    /// Applied **instead of** [`Self::max_repeated_batch_steps`] when every
258    /// tool call in the current batch matches a pattern in
259    /// [`Self::observation_tools`].  A higher value (default: 15) gives the
260    /// agent room to browse multiple pages, walk directory trees, or paginate
261    /// through API results while still aborting an agent that repeats one
262    /// identical batch back to back before it exhausts the token budget.
263    ///
264    /// **Mixed batches** (at least one non-exploratory tool alongside an
265    /// exploratory one) always fall back to [`Self::max_repeated_batch_steps`]
266    /// — the conservative choice.
267    ///
268    /// Clamped to [`MAX_OBSERVATION_STEPS_CEILING`] when supplied via
269    /// [`AgentConfig::from_user_params`] to prevent API callers from providing
270    /// a value large enough to neutralise the guard.
271    ///
272    /// # It is also the ceiling on rescued repeats
273    ///
274    /// A repeat whose answer *changed* is not a strike — an agent polling a
275    /// build for output repeats a batch every turn and is working. That reset
276    /// would, on its own, exempt any tool whose output carries a clock or a
277    /// counter, so a batch may be carried by changing answers only while its
278    /// run stays inside this allowance — unless it is exploratory *and* free
279    /// to repeat. The number is reused rather than invented: an exploratory
280    /// batch already gets it because repeating a call which changes nothing
281    /// is free, and there is no measurement behind a second number.
282    ///
283    /// So this field is read twice for a mutating batch, and for an
284    /// exploratory one that costs something elsewhere — never as a strike
285    /// threshold, always as the ceiling on a moving answer. See
286    /// [ADR 0010](https://github.com/mmogr/gglib/blob/main/docs/adr/0010-the-loop-guard-reads-what-came-back.md).
287    ///
288    /// Set to `None` to disable the elevated threshold entirely; exploratory
289    /// batches then use [`Self::max_repeated_batch_steps`] like any other
290    /// batch, and the rescue ceiling collapses onto that too — which leaves
291    /// the verdict exactly as it was before it read answers at all.
292    ///
293    /// Default: `Some(15)`.
294    pub max_observation_steps: Option<usize>,
295}
296
297impl Default for AgentConfig {
298    fn default() -> Self {
299        Self {
300            max_iterations: DEFAULT_MAX_ITERATIONS,
301            max_parallel_tools: DEFAULT_MAX_PARALLEL_TOOLS,
302            tool_timeout_ms: 30_000,
303            context_budget_chars: 180_000,
304            max_repeated_batch_steps: Some(2),
305            max_stagnation_steps: Some(DEFAULT_MAX_STAGNATION_STEPS),
306            prune_keep_tool_messages: 10,
307            prune_keep_tail_messages: 12,
308            observation_tools: vec![
309                // Browser / GUI agents.
310                "snapshot".into(),
311                "screenshot".into(),
312                "read_page".into(),
313                "navigate".into(),
314                "click".into(),
315                // Coding agents. Without these a VS Code Copilot or Cline
316                // session that reads the same file three times — read, edit,
317                // re-read to verify — is classified as a non-observation
318                // repeat and rejected at `max_repeated_batch_steps` (2)
319                // rather than at `max_observation_steps` (15).
320                //
321                // Matching is `contains` (see `is_observation_batch`), so a
322                // short fragment silently captures unrelated tools: "read"
323                // matches `thread_create`, "list" matches `listen_port`,
324                // "glob" matches `set_global_config`. Every pattern here is
325                // long enough that the only names it can capture are
326                // themselves read-only — `list_dir` also covers
327                // `list_directory` and `list_directory_with_sizes`, which is
328                // why neither is listed separately.
329                //
330                // Deliberately absent for the same reason: bare `read`,
331                // `grep`, `glob` and `view`, which some clients use as whole
332                // tool names. There is no way to match those without also
333                // exempting `thread_*`, `set_global_*` and `preview_*`, and
334                // a guard that silently stops guarding is worse than one
335                // that occasionally refuses honest work.
336                "read_file".into(),
337                "read_text_file".into(),
338                "read_media_file".into(),
339                "read_multiple_files".into(),
340                "list_dir".into(),
341                "list_files".into(),
342                "list_allowed_directories".into(),
343                "directory_tree".into(),
344                "file_search".into(),
345                "grep_search".into(),
346                "search_files".into(),
347                "semantic_search".into(),
348                "codebase_search".into(),
349                "test_search".into(),
350                "get_file_info".into(),
351                "get_errors".into(),
352                "get_changed_files".into(),
353                "get_terminal_output".into(),
354                "list_code_usages".into(),
355                "list_code_definition_names".into(),
356                // The one entry the "changes nothing" rule covers only
357                // locally: repeating it is free on this machine but spends
358                // someone else's rate limit. Kept because re-reading docs
359                // mid-task is ordinary — and held to 15 rather than waived,
360                // by `is_costly_batch`, alongside `navigate` and `click`.
361                "fetch_webpage".into(),
362            ],
363            max_observation_steps: Some(DEFAULT_MAX_OBSERVATION_STEPS),
364        }
365    }
366}
367
368// =============================================================================
369// Validation
370// =============================================================================
371
372/// Error returned when [`AgentConfig::validated`] detects an invalid field.
373///
374/// Each variant names the exact invariant that was violated and carries the
375/// offending value so callers (HTTP handlers, CLI) can surface a precise
376/// diagnostic without re-inspecting the config.
377#[derive(Debug, Clone, PartialEq, Eq, Error)]
378pub enum AgentConfigError {
379    /// `max_iterations` must be ≥ 1 — zero would make the loop exit
380    /// immediately as `MaxIterationsReached(0)` without ever calling the LLM.
381    #[error("max_iterations must be >= 1, got {0}")]
382    MaxIterationsZero(usize),
383
384    /// `max_parallel_tools` must be ≥ 1 — zero would deadlock the
385    /// `Semaphore` used for tool-call concurrency (no permit can ever be
386    /// acquired).
387    #[error("max_parallel_tools must be >= 1, got {0} (0 would deadlock the semaphore)")]
388    MaxParallelToolsZero(usize),
389
390    /// `tool_timeout_ms` must be ≥ [`MIN_TOOL_TIMEOUT_MS`] — a value below
391    /// the floor would silently time out every tool call, making tool
392    /// calling unusable without a clear error.
393    #[error("tool_timeout_ms must be >= {MIN_TOOL_TIMEOUT_MS}, got {0}")]
394    ToolTimeoutTooLow(u64),
395    /// `context_budget_chars` must be >= [`MIN_CONTEXT_BUDGET_CHARS`] — a value
396    /// below the floor would cause the pruner to discard virtually all context.
397    #[error("context_budget_chars must be >= {MIN_CONTEXT_BUDGET_CHARS}, got {0}")]
398    ContextBudgetTooLow(usize),
399}
400
401impl AgentConfig {
402    /// Build an `AgentConfig` from user-supplied overrides.
403    ///
404    /// Each `Some` numeric value is clamped to the safe `[floor, ceiling]`
405    /// range before assignment; `None` fields retain their [`Default`] values.
406    /// The result is validated before returning.
407    ///
408    /// This is the **single entry-point** for HTTP, Tauri, and CLI callers,
409    /// eliminating duplicated clamping logic at every call site.
410    ///
411    /// # Observation-tool parameters
412    ///
413    /// - `observation_tools: Some(vec)` — **replaces** the default pattern list
414    ///   entirely.  Pass the complete list you want, including any defaults you
415    ///   wish to preserve.  `Some(vec![])` disables observation classification
416    ///   (standard threshold applies to all batches).  `None` keeps the
417    ///   built-in defaults, which cover browser tools and the read-only tools
418    ///   coding agents repeat (see `AgentConfig::default`).
419    ///
420    /// - `max_observation_steps: Some(n)` — clamped to
421    ///   `[1, MAX_OBSERVATION_STEPS_CEILING]`.  `None` keeps the built-in
422    ///   default of `Some(15)`.
423    ///
424    /// # Stagnation parameter
425    ///
426    /// `max_stagnation_steps: Some(n)` — clamped to
427    /// `[1, MAX_STAGNATION_STEPS_CEILING]`; `None` keeps the built-in default
428    /// of `Some(DEFAULT_MAX_STAGNATION_STEPS)`.  The floor is 1 because a
429    /// value of 0 would abort on the *first* occurrence of any response.
430    /// Callers pass the persisted `Settings::max_stagnation_steps` here; it
431    /// is deliberately not exposed per-request.
432    ///
433    /// # Errors
434    ///
435    /// Returns `Err(AgentConfigError)` if the clamped config violates any
436    /// invariant (defense-in-depth — should never happen given the clamping).
437    pub fn from_user_params(
438        max_iterations: Option<usize>,
439        max_parallel_tools: Option<usize>,
440        tool_timeout_ms: Option<u64>,
441        observation_tools: Option<Vec<String>>,
442        max_observation_steps: Option<usize>,
443        max_stagnation_steps: Option<usize>,
444    ) -> Result<Self, AgentConfigError> {
445        let mut cfg = Self::default();
446        if let Some(n) = max_iterations {
447            cfg.max_iterations = n.clamp(1, MAX_ITERATIONS_CEILING);
448        }
449        if let Some(n) = max_parallel_tools {
450            cfg.max_parallel_tools = n.clamp(1, MAX_PARALLEL_TOOLS_CEILING);
451        }
452        if let Some(ms) = tool_timeout_ms {
453            cfg.tool_timeout_ms = ms.clamp(MIN_TOOL_TIMEOUT_MS, MAX_TOOL_TIMEOUT_MS_CEILING);
454        }
455        if let Some(tools) = observation_tools {
456            cfg.observation_tools = tools;
457        }
458        if let Some(n) = max_observation_steps {
459            cfg.max_observation_steps = Some(n.clamp(1, MAX_OBSERVATION_STEPS_CEILING));
460        }
461        if let Some(n) = max_stagnation_steps {
462            cfg.max_stagnation_steps = Some(n.clamp(1, MAX_STAGNATION_STEPS_CEILING));
463        }
464        cfg.validated()
465    }
466
467    /// Validate all fields that could cause the agent loop to malfunction.
468    ///
469    /// Call this after constructing an `AgentConfig` from untrusted input.
470    /// The [`Default`] implementation is always valid; this acts as a safety
471    /// net for values assembled by HTTP DTOs or CLI argument parsing.
472    ///
473    /// # Errors
474    ///
475    /// Returns `Err(AgentConfigError)` if any field violates its invariant.
476    pub fn validated(self) -> Result<Self, AgentConfigError> {
477        if self.max_iterations < 1 {
478            return Err(AgentConfigError::MaxIterationsZero(self.max_iterations));
479        }
480        if self.max_parallel_tools < 1 {
481            return Err(AgentConfigError::MaxParallelToolsZero(
482                self.max_parallel_tools,
483            ));
484        }
485        if self.tool_timeout_ms < MIN_TOOL_TIMEOUT_MS {
486            return Err(AgentConfigError::ToolTimeoutTooLow(self.tool_timeout_ms));
487        }
488        if self.context_budget_chars < MIN_CONTEXT_BUDGET_CHARS {
489            return Err(AgentConfigError::ContextBudgetTooLow(
490                self.context_budget_chars,
491            ));
492        }
493        Ok(self)
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn defaults_match_frontend_constants() {
503        let cfg = AgentConfig::default();
504        assert_eq!(cfg.max_iterations, DEFAULT_MAX_ITERATIONS);
505        assert_eq!(cfg.max_parallel_tools, DEFAULT_MAX_PARALLEL_TOOLS);
506        assert_eq!(cfg.tool_timeout_ms, 30_000);
507        assert_eq!(cfg.context_budget_chars, 180_000);
508        assert_eq!(cfg.max_repeated_batch_steps, Some(2));
509        assert_eq!(
510            cfg.max_stagnation_steps,
511            Some(5),
512            "the documented default stagnation ceiling"
513        );
514        assert_eq!(cfg.prune_keep_tool_messages, 10);
515        assert_eq!(cfg.prune_keep_tail_messages, 12);
516        assert_eq!(
517            cfg.observation_tools,
518            vec![
519                "snapshot",
520                "screenshot",
521                "read_page",
522                "navigate",
523                "click",
524                "read_file",
525                "read_text_file",
526                "read_media_file",
527                "read_multiple_files",
528                "list_dir",
529                "list_files",
530                "list_allowed_directories",
531                "directory_tree",
532                "file_search",
533                "grep_search",
534                "search_files",
535                "semantic_search",
536                "codebase_search",
537                "test_search",
538                "get_file_info",
539                "get_errors",
540                "get_changed_files",
541                "get_terminal_output",
542                "list_code_usages",
543                "list_code_definition_names",
544                "fetch_webpage",
545            ],
546            "default exploratory patterns must cover both browser tools and the \
547             read-only tools coding agents repeat"
548        );
549        assert_eq!(
550            cfg.max_observation_steps,
551            Some(DEFAULT_MAX_OBSERVATION_STEPS),
552            "must match DEFAULT_MAX_OBSERVATION_STEPS"
553        );
554    }
555
556    #[test]
557    fn default_config_passes_validation() {
558        assert!(AgentConfig::default().validated().is_ok());
559    }
560
561    #[test]
562    fn zero_max_iterations_rejected() {
563        let cfg = AgentConfig {
564            max_iterations: 0,
565            ..Default::default()
566        };
567        assert_eq!(
568            cfg.validated().unwrap_err(),
569            AgentConfigError::MaxIterationsZero(0),
570        );
571    }
572
573    #[test]
574    fn zero_max_parallel_tools_rejected() {
575        let cfg = AgentConfig {
576            max_parallel_tools: 0,
577            ..Default::default()
578        };
579        assert_eq!(
580            cfg.validated().unwrap_err(),
581            AgentConfigError::MaxParallelToolsZero(0),
582        );
583    }
584
585    #[test]
586    fn tool_timeout_below_floor_rejected() {
587        let cfg = AgentConfig {
588            tool_timeout_ms: MIN_TOOL_TIMEOUT_MS - 1,
589            ..Default::default()
590        };
591        assert_eq!(
592            cfg.validated().unwrap_err(),
593            AgentConfigError::ToolTimeoutTooLow(MIN_TOOL_TIMEOUT_MS - 1),
594        );
595    }
596
597    #[test]
598    fn tool_timeout_at_floor_accepted() {
599        let cfg = AgentConfig {
600            tool_timeout_ms: MIN_TOOL_TIMEOUT_MS,
601            ..Default::default()
602        };
603        assert!(cfg.validated().is_ok());
604    }
605
606    #[test]
607    fn context_budget_below_floor_rejected() {
608        let cfg = AgentConfig {
609            context_budget_chars: MIN_CONTEXT_BUDGET_CHARS - 1,
610            ..Default::default()
611        };
612        assert_eq!(
613            cfg.validated().unwrap_err(),
614            AgentConfigError::ContextBudgetTooLow(MIN_CONTEXT_BUDGET_CHARS - 1),
615        );
616    }
617
618    #[test]
619    fn context_budget_at_floor_accepted() {
620        let cfg = AgentConfig {
621            context_budget_chars: MIN_CONTEXT_BUDGET_CHARS,
622            ..Default::default()
623        };
624        assert!(cfg.validated().is_ok());
625    }
626
627    #[test]
628    fn boundary_values_accepted() {
629        let cfg = AgentConfig {
630            max_iterations: 1,
631            max_parallel_tools: 1,
632            tool_timeout_ms: MIN_TOOL_TIMEOUT_MS,
633            context_budget_chars: MIN_CONTEXT_BUDGET_CHARS,
634            ..Default::default()
635        };
636        assert!(cfg.validated().is_ok());
637    }
638
639    #[test]
640    fn from_user_params_clamps_and_validates() {
641        // All values within range → accepted as-is.
642        let cfg = AgentConfig::from_user_params(Some(10), Some(3), Some(5_000), None, None, None)
643            .unwrap();
644        assert_eq!(cfg.max_iterations, 10);
645        assert_eq!(cfg.max_parallel_tools, 3);
646        assert_eq!(cfg.tool_timeout_ms, 5_000);
647    }
648
649    #[test]
650    fn from_user_params_clamps_extremes() {
651        // Zero iterations → clamped to 1.
652        let cfg =
653            AgentConfig::from_user_params(Some(0), Some(0), Some(0), None, None, None).unwrap();
654        assert_eq!(cfg.max_iterations, 1);
655        assert_eq!(cfg.max_parallel_tools, 1);
656        assert_eq!(cfg.tool_timeout_ms, MIN_TOOL_TIMEOUT_MS);
657    }
658
659    #[test]
660    fn from_user_params_clamps_above_ceiling() {
661        let cfg = AgentConfig::from_user_params(
662            Some(usize::MAX),
663            Some(usize::MAX),
664            Some(u64::MAX),
665            None,
666            None,
667            None,
668        )
669        .unwrap();
670        assert_eq!(cfg.max_iterations, MAX_ITERATIONS_CEILING);
671        assert_eq!(cfg.max_parallel_tools, MAX_PARALLEL_TOOLS_CEILING);
672        assert_eq!(cfg.tool_timeout_ms, MAX_TOOL_TIMEOUT_MS_CEILING);
673    }
674
675    #[test]
676    fn from_user_params_none_keeps_defaults() {
677        let cfg = AgentConfig::from_user_params(None, None, None, None, None, None).unwrap();
678        let def = AgentConfig::default();
679        assert_eq!(cfg.max_iterations, def.max_iterations);
680        assert_eq!(cfg.max_parallel_tools, def.max_parallel_tools);
681        assert_eq!(cfg.tool_timeout_ms, def.tool_timeout_ms);
682        assert_eq!(cfg.observation_tools, def.observation_tools);
683        assert_eq!(cfg.max_observation_steps, def.max_observation_steps);
684    }
685
686    #[test]
687    fn from_user_params_observation_tools_replaces_defaults() {
688        // A non-None observation_tools list replaces the built-in defaults.
689        let custom = vec!["get_dom".into(), "fetch_page".into()];
690        let cfg = AgentConfig::from_user_params(None, None, None, Some(custom.clone()), None, None)
691            .unwrap();
692        assert_eq!(cfg.observation_tools, custom);
693    }
694
695    #[test]
696    fn from_user_params_empty_observation_tools_disables_classification() {
697        // Some([]) disables observation classification — no tools ever match.
698        let cfg =
699            AgentConfig::from_user_params(None, None, None, Some(vec![]), None, None).unwrap();
700        assert!(cfg.observation_tools.is_empty());
701    }
702
703    #[test]
704    fn from_user_params_observation_steps_clamped_to_ceiling() {
705        let cfg =
706            AgentConfig::from_user_params(None, None, None, None, Some(usize::MAX), None).unwrap();
707        assert_eq!(
708            cfg.max_observation_steps,
709            Some(MAX_OBSERVATION_STEPS_CEILING),
710        );
711    }
712
713    #[test]
714    fn from_user_params_observation_steps_clamped_to_floor() {
715        // Zero would mean fire on the very first occurrence — clamp to 1.
716        let cfg = AgentConfig::from_user_params(None, None, None, None, Some(0), None).unwrap();
717        assert_eq!(cfg.max_observation_steps, Some(1));
718    }
719
720    #[test]
721    fn from_user_params_observation_steps_within_range_unchanged() {
722        let cfg = AgentConfig::from_user_params(None, None, None, None, Some(15), None).unwrap();
723        assert_eq!(cfg.max_observation_steps, Some(15));
724    }
725
726    #[test]
727    fn from_user_params_stagnation_steps_clamped_to_ceiling() {
728        let cfg =
729            AgentConfig::from_user_params(None, None, None, None, None, Some(usize::MAX)).unwrap();
730        assert_eq!(cfg.max_stagnation_steps, Some(MAX_STAGNATION_STEPS_CEILING));
731    }
732
733    #[test]
734    fn from_user_params_stagnation_steps_clamped_to_floor() {
735        // Zero would mean abort on the first occurrence of any text — clamp to 1.
736        let cfg = AgentConfig::from_user_params(None, None, None, None, None, Some(0)).unwrap();
737        assert_eq!(cfg.max_stagnation_steps, Some(1));
738    }
739
740    #[test]
741    fn from_user_params_stagnation_steps_none_keeps_default() {
742        let cfg = AgentConfig::from_user_params(None, None, None, None, None, None).unwrap();
743        assert_eq!(cfg.max_stagnation_steps, Some(DEFAULT_MAX_STAGNATION_STEPS),);
744    }
745}