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}