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_observation_steps`] accepted from
75/// external callers.
76///
77/// Prevents an API or CLI caller from setting `max_observation_steps` to an
78/// arbitrarily large value, which would silently neutralise the observation
79/// guard and allow a confused agent to call observation tools indefinitely.
80/// 100 observation-only iterations is far more than any legitimate browsing
81/// task requires.
82pub const MAX_OBSERVATION_STEPS_CEILING: usize = 100;
83
84/// Default value for [`AgentConfig::max_observation_steps`].
85///
86/// An exploratory-tool-only batch (every call matches a pattern in
87/// [`AgentConfig::observation_tools`]) may repeat up to this many times
88/// before loop detection fires.  15 is generous for multi-page browsing,
89/// multi-directory walking, and paginated API tasks while still catching
90/// a genuinely confused agent within a reasonable token budget.
91pub const DEFAULT_MAX_OBSERVATION_STEPS: usize = 15;
92
93/// Configuration that governs a single agentic loop run.
94///
95/// All fields have sensible defaults via [`Default`] that match the historical
96/// TypeScript frontend constants (previously in `agentLoop.ts`, now reflected
97/// in `streamAgentChat.ts`).
98///
99/// # Serialisation
100///
101/// `AgentConfig` is intentionally **not** `Deserialize`.  External callers
102/// (HTTP, future config files) must go through a dedicated DTO that exposes
103/// only the safe subset of fields.  This prevents accidental exposure of
104/// internal tuning knobs (pruning parameters, strike limits, etc.) to
105/// untrusted callers.
106#[derive(Debug, Clone, Serialize)]
107#[non_exhaustive]
108pub struct AgentConfig {
109    /// Maximum number of LLM→tool→LLM iterations before the loop is aborted.
110    ///
111    /// Frontend constant: `DEFAULT_MAX_TOOL_ITERS = 25`.
112    pub max_iterations: usize,
113
114    /// Maximum number of tool calls that may be executed in parallel per iteration.
115    ///
116    /// **Dual-purpose:** this value is used both as the `Semaphore` concurrency
117    /// cap in `tool_execution` (limiting simultaneous in-flight calls) *and* as
118    /// an upper bound on the batch size the model may request in a single turn.
119    /// If the model emits more tool calls than this limit, the loop terminates
120    /// with [`AgentError::ParallelToolLimitExceeded`] rather than silently
121    /// serialising them.  Setting this to `1` means the model may only request
122    /// **one** tool call per turn; two calls in a single response will abort the
123    /// loop, not run them sequentially.
124    ///
125    /// Frontend constant: `MAX_PARALLEL_TOOLS = 5`.
126    pub max_parallel_tools: usize,
127
128    /// Per-tool execution timeout in milliseconds.
129    ///
130    /// Frontend constant: `TOOL_TIMEOUT_MS = 30_000`.
131    pub tool_timeout_ms: u64,
132
133    /// Maximum total character budget across all messages before context pruning
134    /// is applied.
135    ///
136    /// Frontend constant: `MAX_CONTEXT_CHARS = 180_000`.
137    pub context_budget_chars: usize,
138
139    /// Maximum number of times the same tool-call batch signature may repeat
140    /// before the loop is declared stuck and aborted with
141    /// [`crate::ports::AgentError::LoopDetected`].
142    ///
143    /// Frontend constant: `MAX_SAME_SIGNATURE_HITS = 2` in `streamAgentChat.ts`.
144    ///
145    /// Set to `None` to disable loop detection entirely (useful in tests that
146    /// deliberately repeat the same tool call).
147    pub max_repeated_batch_steps: Option<usize>,
148
149    /// Session-wide occurrence limit for identical assistant text before the
150    /// loop is considered stagnant and aborted with
151    /// [`crate::ports::AgentError::StagnationDetected`].
152    ///
153    /// **Semantics:** Each occurrence of the same response text increments a
154    /// session counter.  The error fires when the counter **after**
155    /// incrementing exceeds `max_stagnation_steps`.  With the default value
156    /// of `5`, stagnation triggers on the **sixth** identical occurrence.
157    /// With `max_stagnation_steps = 0`, the error fires on the **very first**
158    /// occurrence of any repeated text.
159    ///
160    /// Frontend constant: `MAX_STAGNATION_STEPS = 5` in `streamAgentChat.ts`.
161    ///
162    /// Set to `None` to disable stagnation detection entirely (useful in tests
163    /// that return a fixed LLM response across many iterations).
164    pub max_stagnation_steps: Option<usize>,
165
166    /// Number of most-recent tool-result messages preserved during the first
167    /// pass of context pruning.
168    ///
169    /// Not exposed as a user-facing option because the value is calibrated
170    /// to balance context retention against token budget; changing it
171    /// independently of `context_budget_chars` can produce incoherent
172    /// conversation histories.
173    #[serde(skip)]
174    pub prune_keep_tool_messages: usize,
175
176    /// Number of non-system messages retained during the emergency tail-prune
177    /// pass (second pass of context pruning).
178    ///
179    /// Same rationale as [`Self::prune_keep_tool_messages`].
180    #[serde(skip)]
181    pub prune_keep_tail_messages: usize,
182
183    // -------------------------------------------------------------------------
184    // Dual-threshold observation guard
185    // -------------------------------------------------------------------------
186    //
187    // Standard loop detection (max_repeated_batch_steps) hashes tool names and
188    // arguments to detect stuck cycles.  Observation-only tools (e.g. browser
189    // snapshots, screenshots) legitimately repeat with identical signatures
190    // because they take no meaningful arguments, yet return completely different
191    // page content on each call.  These two fields allow a separate, higher
192    // threshold to be applied when every tool in a batch is classified as an
193    // exploratory tool, preventing false-positive loop aborts during ReAct
194    // observation and navigation cycles while still eventually catching a
195    // genuinely confused agent.
196    /// Substring/suffix patterns used to classify tools as **exploratory**.
197    ///
198    /// "Exploratory" tools are those that drive progress by repeatedly
199    /// querying or traversing a stateful source — page snapshots, navigation,
200    /// clicks, file reads, directory listings, API pagination calls, etc.
201    /// Their repeated invocation with identical arguments is a legitimate
202    /// `ReAct` pattern, not a stuck loop.
203    ///
204    /// A tool call whose **lowercased** name satisfies
205    /// `name.ends_with(pattern) || name.contains(pattern)` for any pattern in
206    /// this list is classified as exploratory.  When **every** call in a batch
207    /// matches, [`Self::max_observation_steps`] is applied as the loop
208    /// detection threshold instead of [`Self::max_repeated_batch_steps`].
209    ///
210    /// **Matching semantics** — substring/suffix rather than exact string — are
211    /// intentional: MCP servers routinely prepend namespace prefixes to tool
212    /// names (e.g. `playwright_mcp_browser_snapshot`), so exact matching would
213    /// require users to enumerate every vendor variant.  The pattern `"navigate"`
214    /// matches `browser_navigate`, `db_navigate`, `fs_navigate`, etc.
215    ///
216    /// **BYO-MCP:** users connecting custom MCP servers should extend or replace
217    /// this list via [`AgentConfig::from_user_params`] to include their own
218    /// exploratory tool name fragments (e.g. `"get_dom"`, `"fetch_page"`,
219    /// `"list_dir"`).
220    ///
221    /// An empty list means no tools are ever classified as exploratory;
222    /// the standard [`Self::max_repeated_batch_steps`] threshold applies to all
223    /// batches.
224    ///
225    /// Default: `["snapshot", "screenshot", "read_page", "navigate", "click"]`.
226    pub observation_tools: Vec<String>,
227
228    /// Maximum number of times an exploratory-tool-only batch may repeat
229    /// before loop detection fires.
230    ///
231    /// Applied **instead of** [`Self::max_repeated_batch_steps`] when every
232    /// tool call in the current batch matches a pattern in
233    /// [`Self::observation_tools`].  A higher value (default: 15) gives the
234    /// agent room to browse multiple pages, walk directory trees, or paginate
235    /// through API results while still eventually aborting a genuinely confused
236    /// agent before it exhausts the token budget.
237    ///
238    /// **Mixed batches** (at least one non-exploratory tool alongside an
239    /// exploratory one) always fall back to [`Self::max_repeated_batch_steps`]
240    /// — the conservative choice.
241    ///
242    /// Clamped to [`MAX_OBSERVATION_STEPS_CEILING`] when supplied via
243    /// [`AgentConfig::from_user_params`] to prevent API callers from providing
244    /// a value large enough to neutralise the guard.
245    ///
246    /// Set to `None` to disable the elevated threshold entirely; exploratory
247    /// batches then use [`Self::max_repeated_batch_steps`] like any other batch.
248    ///
249    /// Default: `Some(15)`.
250    pub max_observation_steps: Option<usize>,
251}
252
253impl Default for AgentConfig {
254    fn default() -> Self {
255        Self {
256            max_iterations: DEFAULT_MAX_ITERATIONS,
257            max_parallel_tools: DEFAULT_MAX_PARALLEL_TOOLS,
258            tool_timeout_ms: 30_000,
259            context_budget_chars: 180_000,
260            max_repeated_batch_steps: Some(2),
261            max_stagnation_steps: Some(DEFAULT_MAX_STAGNATION_STEPS),
262            prune_keep_tool_messages: 10,
263            prune_keep_tail_messages: 12,
264            observation_tools: vec![
265                "snapshot".into(),
266                "screenshot".into(),
267                "read_page".into(),
268                "navigate".into(),
269                "click".into(),
270            ],
271            max_observation_steps: Some(DEFAULT_MAX_OBSERVATION_STEPS),
272        }
273    }
274}
275
276// =============================================================================
277// Validation
278// =============================================================================
279
280/// Error returned when [`AgentConfig::validated`] detects an invalid field.
281///
282/// Each variant names the exact invariant that was violated and carries the
283/// offending value so callers (HTTP handlers, CLI) can surface a precise
284/// diagnostic without re-inspecting the config.
285#[derive(Debug, Clone, PartialEq, Eq, Error)]
286pub enum AgentConfigError {
287    /// `max_iterations` must be ≥ 1 — zero would make the loop exit
288    /// immediately as `MaxIterationsReached(0)` without ever calling the LLM.
289    #[error("max_iterations must be >= 1, got {0}")]
290    MaxIterationsZero(usize),
291
292    /// `max_parallel_tools` must be ≥ 1 — zero would deadlock the
293    /// `Semaphore` used for tool-call concurrency (no permit can ever be
294    /// acquired).
295    #[error("max_parallel_tools must be >= 1, got {0} (0 would deadlock the semaphore)")]
296    MaxParallelToolsZero(usize),
297
298    /// `tool_timeout_ms` must be ≥ [`MIN_TOOL_TIMEOUT_MS`] — a value below
299    /// the floor would silently time out every tool call, making tool
300    /// calling unusable without a clear error.
301    #[error("tool_timeout_ms must be >= {MIN_TOOL_TIMEOUT_MS}, got {0}")]
302    ToolTimeoutTooLow(u64),
303    /// `context_budget_chars` must be >= [`MIN_CONTEXT_BUDGET_CHARS`] — a value
304    /// below the floor would cause the pruner to discard virtually all context.
305    #[error("context_budget_chars must be >= {MIN_CONTEXT_BUDGET_CHARS}, got {0}")]
306    ContextBudgetTooLow(usize),
307}
308
309impl AgentConfig {
310    /// Build an `AgentConfig` from user-supplied overrides.
311    ///
312    /// Each `Some` numeric value is clamped to the safe `[floor, ceiling]`
313    /// range before assignment; `None` fields retain their [`Default`] values.
314    /// The result is validated before returning.
315    ///
316    /// This is the **single entry-point** for HTTP, Tauri, and CLI callers,
317    /// eliminating duplicated clamping logic at every call site.
318    ///
319    /// # Observation-tool parameters
320    ///
321    /// - `observation_tools: Some(vec)` — **replaces** the default pattern list
322    ///   entirely.  Pass the complete list you want, including any defaults you
323    ///   wish to preserve.  `Some(vec![])` disables observation classification
324    ///   (standard threshold applies to all batches).  `None` keeps the
325    ///   built-in defaults (`["snapshot", "screenshot", "read_page"]`).
326    ///
327    /// - `max_observation_steps: Some(n)` — clamped to
328    ///   `[1, MAX_OBSERVATION_STEPS_CEILING]`.  `None` keeps the built-in
329    ///   default of `Some(10)`.
330    ///
331    /// # Errors
332    ///
333    /// Returns `Err(AgentConfigError)` if the clamped config violates any
334    /// invariant (defense-in-depth — should never happen given the clamping).
335    pub fn from_user_params(
336        max_iterations: Option<usize>,
337        max_parallel_tools: Option<usize>,
338        tool_timeout_ms: Option<u64>,
339        observation_tools: Option<Vec<String>>,
340        max_observation_steps: Option<usize>,
341    ) -> Result<Self, AgentConfigError> {
342        let mut cfg = Self::default();
343        if let Some(n) = max_iterations {
344            cfg.max_iterations = n.clamp(1, MAX_ITERATIONS_CEILING);
345        }
346        if let Some(n) = max_parallel_tools {
347            cfg.max_parallel_tools = n.clamp(1, MAX_PARALLEL_TOOLS_CEILING);
348        }
349        if let Some(ms) = tool_timeout_ms {
350            cfg.tool_timeout_ms = ms.clamp(MIN_TOOL_TIMEOUT_MS, MAX_TOOL_TIMEOUT_MS_CEILING);
351        }
352        if let Some(tools) = observation_tools {
353            cfg.observation_tools = tools;
354        }
355        if let Some(n) = max_observation_steps {
356            cfg.max_observation_steps = Some(n.clamp(1, MAX_OBSERVATION_STEPS_CEILING));
357        }
358        cfg.validated()
359    }
360
361    /// Validate all fields that could cause the agent loop to malfunction.
362    ///
363    /// Call this after constructing an `AgentConfig` from untrusted input.
364    /// The [`Default`] implementation is always valid; this acts as a safety
365    /// net for values assembled by HTTP DTOs or CLI argument parsing.
366    ///
367    /// # Errors
368    ///
369    /// Returns `Err(AgentConfigError)` if any field violates its invariant.
370    pub fn validated(self) -> Result<Self, AgentConfigError> {
371        if self.max_iterations < 1 {
372            return Err(AgentConfigError::MaxIterationsZero(self.max_iterations));
373        }
374        if self.max_parallel_tools < 1 {
375            return Err(AgentConfigError::MaxParallelToolsZero(
376                self.max_parallel_tools,
377            ));
378        }
379        if self.tool_timeout_ms < MIN_TOOL_TIMEOUT_MS {
380            return Err(AgentConfigError::ToolTimeoutTooLow(self.tool_timeout_ms));
381        }
382        if self.context_budget_chars < MIN_CONTEXT_BUDGET_CHARS {
383            return Err(AgentConfigError::ContextBudgetTooLow(
384                self.context_budget_chars,
385            ));
386        }
387        Ok(self)
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn defaults_match_frontend_constants() {
397        let cfg = AgentConfig::default();
398        assert_eq!(cfg.max_iterations, DEFAULT_MAX_ITERATIONS);
399        assert_eq!(cfg.max_parallel_tools, DEFAULT_MAX_PARALLEL_TOOLS);
400        assert_eq!(cfg.tool_timeout_ms, 30_000);
401        assert_eq!(cfg.context_budget_chars, 180_000);
402        assert_eq!(cfg.max_repeated_batch_steps, Some(2));
403        assert_eq!(
404            cfg.max_stagnation_steps,
405            Some(5),
406            "must mirror MAX_STAGNATION_STEPS from streamAgentChat.ts"
407        );
408        assert_eq!(cfg.prune_keep_tool_messages, 10);
409        assert_eq!(cfg.prune_keep_tail_messages, 12);
410        assert_eq!(
411            cfg.observation_tools,
412            vec!["snapshot", "screenshot", "read_page", "navigate", "click"],
413            "default exploratory patterns must cover common snapshot, navigation, and click tools"
414        );
415        assert_eq!(
416            cfg.max_observation_steps,
417            Some(DEFAULT_MAX_OBSERVATION_STEPS),
418            "must match DEFAULT_MAX_OBSERVATION_STEPS"
419        );
420    }
421
422    #[test]
423    fn default_config_passes_validation() {
424        assert!(AgentConfig::default().validated().is_ok());
425    }
426
427    #[test]
428    fn zero_max_iterations_rejected() {
429        let cfg = AgentConfig {
430            max_iterations: 0,
431            ..Default::default()
432        };
433        assert_eq!(
434            cfg.validated().unwrap_err(),
435            AgentConfigError::MaxIterationsZero(0),
436        );
437    }
438
439    #[test]
440    fn zero_max_parallel_tools_rejected() {
441        let cfg = AgentConfig {
442            max_parallel_tools: 0,
443            ..Default::default()
444        };
445        assert_eq!(
446            cfg.validated().unwrap_err(),
447            AgentConfigError::MaxParallelToolsZero(0),
448        );
449    }
450
451    #[test]
452    fn tool_timeout_below_floor_rejected() {
453        let cfg = AgentConfig {
454            tool_timeout_ms: MIN_TOOL_TIMEOUT_MS - 1,
455            ..Default::default()
456        };
457        assert_eq!(
458            cfg.validated().unwrap_err(),
459            AgentConfigError::ToolTimeoutTooLow(MIN_TOOL_TIMEOUT_MS - 1),
460        );
461    }
462
463    #[test]
464    fn tool_timeout_at_floor_accepted() {
465        let cfg = AgentConfig {
466            tool_timeout_ms: MIN_TOOL_TIMEOUT_MS,
467            ..Default::default()
468        };
469        assert!(cfg.validated().is_ok());
470    }
471
472    #[test]
473    fn context_budget_below_floor_rejected() {
474        let cfg = AgentConfig {
475            context_budget_chars: MIN_CONTEXT_BUDGET_CHARS - 1,
476            ..Default::default()
477        };
478        assert_eq!(
479            cfg.validated().unwrap_err(),
480            AgentConfigError::ContextBudgetTooLow(MIN_CONTEXT_BUDGET_CHARS - 1),
481        );
482    }
483
484    #[test]
485    fn context_budget_at_floor_accepted() {
486        let cfg = AgentConfig {
487            context_budget_chars: MIN_CONTEXT_BUDGET_CHARS,
488            ..Default::default()
489        };
490        assert!(cfg.validated().is_ok());
491    }
492
493    #[test]
494    fn boundary_values_accepted() {
495        let cfg = AgentConfig {
496            max_iterations: 1,
497            max_parallel_tools: 1,
498            tool_timeout_ms: MIN_TOOL_TIMEOUT_MS,
499            context_budget_chars: MIN_CONTEXT_BUDGET_CHARS,
500            ..Default::default()
501        };
502        assert!(cfg.validated().is_ok());
503    }
504
505    #[test]
506    fn from_user_params_clamps_and_validates() {
507        // All values within range → accepted as-is.
508        let cfg =
509            AgentConfig::from_user_params(Some(10), Some(3), Some(5_000), None, None).unwrap();
510        assert_eq!(cfg.max_iterations, 10);
511        assert_eq!(cfg.max_parallel_tools, 3);
512        assert_eq!(cfg.tool_timeout_ms, 5_000);
513    }
514
515    #[test]
516    fn from_user_params_clamps_extremes() {
517        // Zero iterations → clamped to 1.
518        let cfg = AgentConfig::from_user_params(Some(0), Some(0), Some(0), None, None).unwrap();
519        assert_eq!(cfg.max_iterations, 1);
520        assert_eq!(cfg.max_parallel_tools, 1);
521        assert_eq!(cfg.tool_timeout_ms, MIN_TOOL_TIMEOUT_MS);
522    }
523
524    #[test]
525    fn from_user_params_clamps_above_ceiling() {
526        let cfg = AgentConfig::from_user_params(
527            Some(usize::MAX),
528            Some(usize::MAX),
529            Some(u64::MAX),
530            None,
531            None,
532        )
533        .unwrap();
534        assert_eq!(cfg.max_iterations, MAX_ITERATIONS_CEILING);
535        assert_eq!(cfg.max_parallel_tools, MAX_PARALLEL_TOOLS_CEILING);
536        assert_eq!(cfg.tool_timeout_ms, MAX_TOOL_TIMEOUT_MS_CEILING);
537    }
538
539    #[test]
540    fn from_user_params_none_keeps_defaults() {
541        let cfg = AgentConfig::from_user_params(None, None, None, None, None).unwrap();
542        let def = AgentConfig::default();
543        assert_eq!(cfg.max_iterations, def.max_iterations);
544        assert_eq!(cfg.max_parallel_tools, def.max_parallel_tools);
545        assert_eq!(cfg.tool_timeout_ms, def.tool_timeout_ms);
546        assert_eq!(cfg.observation_tools, def.observation_tools);
547        assert_eq!(cfg.max_observation_steps, def.max_observation_steps);
548    }
549
550    #[test]
551    fn from_user_params_observation_tools_replaces_defaults() {
552        // A non-None observation_tools list replaces the built-in defaults.
553        let custom = vec!["get_dom".into(), "fetch_page".into()];
554        let cfg =
555            AgentConfig::from_user_params(None, None, None, Some(custom.clone()), None).unwrap();
556        assert_eq!(cfg.observation_tools, custom);
557    }
558
559    #[test]
560    fn from_user_params_empty_observation_tools_disables_classification() {
561        // Some([]) disables observation classification — no tools ever match.
562        let cfg = AgentConfig::from_user_params(None, None, None, Some(vec![]), None).unwrap();
563        assert!(cfg.observation_tools.is_empty());
564    }
565
566    #[test]
567    fn from_user_params_observation_steps_clamped_to_ceiling() {
568        let cfg = AgentConfig::from_user_params(None, None, None, None, Some(usize::MAX)).unwrap();
569        assert_eq!(
570            cfg.max_observation_steps,
571            Some(MAX_OBSERVATION_STEPS_CEILING),
572        );
573    }
574
575    #[test]
576    fn from_user_params_observation_steps_clamped_to_floor() {
577        // Zero would mean fire on the very first occurrence — clamp to 1.
578        let cfg = AgentConfig::from_user_params(None, None, None, None, Some(0)).unwrap();
579        assert_eq!(cfg.max_observation_steps, Some(1));
580    }
581
582    #[test]
583    fn from_user_params_observation_steps_within_range_unchanged() {
584        let cfg = AgentConfig::from_user_params(None, None, None, None, Some(15)).unwrap();
585        assert_eq!(cfg.max_observation_steps, Some(15));
586    }
587}