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}