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/// Configuration that governs a single agentic loop run.
75///
76/// All fields have sensible defaults via [`Default`] that match the historical
77/// TypeScript frontend constants (previously in `agentLoop.ts`, now reflected
78/// in `streamAgentChat.ts`).
79///
80/// # Serialisation
81///
82/// `AgentConfig` is intentionally **not** `Deserialize`.  External callers
83/// (HTTP, future config files) must go through a dedicated DTO that exposes
84/// only the safe subset of fields.  This prevents accidental exposure of
85/// internal tuning knobs (pruning parameters, strike limits, etc.) to
86/// untrusted callers.
87#[derive(Debug, Clone, Serialize)]
88#[non_exhaustive]
89pub struct AgentConfig {
90    /// Maximum number of LLM→tool→LLM iterations before the loop is aborted.
91    ///
92    /// Frontend constant: `DEFAULT_MAX_TOOL_ITERS = 25`.
93    pub max_iterations: usize,
94
95    /// Maximum number of tool calls that may be executed in parallel per iteration.
96    ///
97    /// **Dual-purpose:** this value is used both as the `Semaphore` concurrency
98    /// cap in `tool_execution` (limiting simultaneous in-flight calls) *and* as
99    /// an upper bound on the batch size the model may request in a single turn.
100    /// If the model emits more tool calls than this limit, the loop terminates
101    /// with [`AgentError::ParallelToolLimitExceeded`] rather than silently
102    /// serialising them.  Setting this to `1` means the model may only request
103    /// **one** tool call per turn; two calls in a single response will abort the
104    /// loop, not run them sequentially.
105    ///
106    /// Frontend constant: `MAX_PARALLEL_TOOLS = 5`.
107    pub max_parallel_tools: usize,
108
109    /// Per-tool execution timeout in milliseconds.
110    ///
111    /// Frontend constant: `TOOL_TIMEOUT_MS = 30_000`.
112    pub tool_timeout_ms: u64,
113
114    /// Maximum total character budget across all messages before context pruning
115    /// is applied.
116    ///
117    /// Frontend constant: `MAX_CONTEXT_CHARS = 180_000`.
118    pub context_budget_chars: usize,
119
120    /// Maximum number of times the same tool-call batch signature may repeat
121    /// before the loop is declared stuck and aborted with
122    /// [`crate::ports::AgentError::LoopDetected`].
123    ///
124    /// Frontend constant: `MAX_SAME_SIGNATURE_HITS = 2` in `streamAgentChat.ts`.
125    ///
126    /// Set to `None` to disable loop detection entirely (useful in tests that
127    /// deliberately repeat the same tool call).
128    pub max_repeated_batch_steps: Option<usize>,
129
130    /// Session-wide occurrence limit for identical assistant text before the
131    /// loop is considered stagnant and aborted with
132    /// [`crate::ports::AgentError::StagnationDetected`].
133    ///
134    /// **Semantics:** Each occurrence of the same response text increments a
135    /// session counter.  The error fires when the counter **after**
136    /// incrementing exceeds `max_stagnation_steps`.  With the default value
137    /// of `5`, stagnation triggers on the **sixth** identical occurrence.
138    /// With `max_stagnation_steps = 0`, the error fires on the **very first**
139    /// occurrence of any repeated text.
140    ///
141    /// Frontend constant: `MAX_STAGNATION_STEPS = 5` in `streamAgentChat.ts`.
142    ///
143    /// Set to `None` to disable stagnation detection entirely (useful in tests
144    /// that return a fixed LLM response across many iterations).
145    pub max_stagnation_steps: Option<usize>,
146
147    /// Number of most-recent tool-result messages preserved during the first
148    /// pass of context pruning.
149    ///
150    /// Not exposed as a user-facing option because the value is calibrated
151    /// to balance context retention against token budget; changing it
152    /// independently of `context_budget_chars` can produce incoherent
153    /// conversation histories.
154    #[serde(skip)]
155    pub prune_keep_tool_messages: usize,
156
157    /// Number of non-system messages retained during the emergency tail-prune
158    /// pass (second pass of context pruning).
159    ///
160    /// Same rationale as [`Self::prune_keep_tool_messages`].
161    #[serde(skip)]
162    pub prune_keep_tail_messages: usize,
163}
164
165impl Default for AgentConfig {
166    fn default() -> Self {
167        Self {
168            max_iterations: DEFAULT_MAX_ITERATIONS,
169            max_parallel_tools: DEFAULT_MAX_PARALLEL_TOOLS,
170            tool_timeout_ms: 30_000,
171            context_budget_chars: 180_000,
172            max_repeated_batch_steps: Some(2),
173            max_stagnation_steps: Some(DEFAULT_MAX_STAGNATION_STEPS),
174            prune_keep_tool_messages: 10,
175            prune_keep_tail_messages: 12,
176        }
177    }
178}
179
180// =============================================================================
181// Validation
182// =============================================================================
183
184/// Error returned when [`AgentConfig::validated`] detects an invalid field.
185///
186/// Each variant names the exact invariant that was violated and carries the
187/// offending value so callers (HTTP handlers, CLI) can surface a precise
188/// diagnostic without re-inspecting the config.
189#[derive(Debug, Clone, PartialEq, Eq, Error)]
190pub enum AgentConfigError {
191    /// `max_iterations` must be ≥ 1 — zero would make the loop exit
192    /// immediately as `MaxIterationsReached(0)` without ever calling the LLM.
193    #[error("max_iterations must be >= 1, got {0}")]
194    MaxIterationsZero(usize),
195
196    /// `max_parallel_tools` must be ≥ 1 — zero would deadlock the
197    /// `Semaphore` used for tool-call concurrency (no permit can ever be
198    /// acquired).
199    #[error("max_parallel_tools must be >= 1, got {0} (0 would deadlock the semaphore)")]
200    MaxParallelToolsZero(usize),
201
202    /// `tool_timeout_ms` must be ≥ [`MIN_TOOL_TIMEOUT_MS`] — a value below
203    /// the floor would silently time out every tool call, making tool
204    /// calling unusable without a clear error.
205    #[error("tool_timeout_ms must be >= {MIN_TOOL_TIMEOUT_MS}, got {0}")]
206    ToolTimeoutTooLow(u64),
207    /// `context_budget_chars` must be >= [`MIN_CONTEXT_BUDGET_CHARS`] — a value
208    /// below the floor would cause the pruner to discard virtually all context.
209    #[error("context_budget_chars must be >= {MIN_CONTEXT_BUDGET_CHARS}, got {0}")]
210    ContextBudgetTooLow(usize),
211}
212
213impl AgentConfig {
214    /// Build an `AgentConfig` from user-supplied overrides.
215    ///
216    /// Each `Some` value is clamped to the safe `[floor, ceiling]` range
217    /// before assignment; `None` fields retain their [`Default`] values.
218    /// The result is validated before returning.
219    ///
220    /// This is the **single entry-point** for both HTTP and CLI callers,
221    /// eliminating duplicated clamping logic at every call site.
222    ///
223    /// # Errors
224    ///
225    /// Returns `Err(AgentConfigError)` if the clamped config violates any
226    /// invariant (defense-in-depth — should never happen given the clamping).
227    pub fn from_user_params(
228        max_iterations: Option<usize>,
229        max_parallel_tools: Option<usize>,
230        tool_timeout_ms: Option<u64>,
231    ) -> Result<Self, AgentConfigError> {
232        let mut cfg = Self::default();
233        if let Some(n) = max_iterations {
234            cfg.max_iterations = n.clamp(1, MAX_ITERATIONS_CEILING);
235        }
236        if let Some(n) = max_parallel_tools {
237            cfg.max_parallel_tools = n.clamp(1, MAX_PARALLEL_TOOLS_CEILING);
238        }
239        if let Some(ms) = tool_timeout_ms {
240            cfg.tool_timeout_ms = ms.clamp(MIN_TOOL_TIMEOUT_MS, MAX_TOOL_TIMEOUT_MS_CEILING);
241        }
242        cfg.validated()
243    }
244
245    /// Validate all fields that could cause the agent loop to malfunction.
246    ///
247    /// Call this after constructing an `AgentConfig` from untrusted input.
248    /// The [`Default`] implementation is always valid; this acts as a safety
249    /// net for values assembled by HTTP DTOs or CLI argument parsing.
250    ///
251    /// # Errors
252    ///
253    /// Returns `Err(AgentConfigError)` if any field violates its invariant.
254    pub const fn validated(self) -> Result<Self, AgentConfigError> {
255        if self.max_iterations < 1 {
256            return Err(AgentConfigError::MaxIterationsZero(self.max_iterations));
257        }
258        if self.max_parallel_tools < 1 {
259            return Err(AgentConfigError::MaxParallelToolsZero(
260                self.max_parallel_tools,
261            ));
262        }
263        if self.tool_timeout_ms < MIN_TOOL_TIMEOUT_MS {
264            return Err(AgentConfigError::ToolTimeoutTooLow(self.tool_timeout_ms));
265        }
266        if self.context_budget_chars < MIN_CONTEXT_BUDGET_CHARS {
267            return Err(AgentConfigError::ContextBudgetTooLow(
268                self.context_budget_chars,
269            ));
270        }
271        Ok(self)
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn defaults_match_frontend_constants() {
281        let cfg = AgentConfig::default();
282        assert_eq!(cfg.max_iterations, DEFAULT_MAX_ITERATIONS);
283        assert_eq!(cfg.max_parallel_tools, DEFAULT_MAX_PARALLEL_TOOLS);
284        assert_eq!(cfg.tool_timeout_ms, 30_000);
285        assert_eq!(cfg.context_budget_chars, 180_000);
286        assert_eq!(cfg.max_repeated_batch_steps, Some(2));
287        assert_eq!(
288            cfg.max_stagnation_steps,
289            Some(5),
290            "must mirror MAX_STAGNATION_STEPS from streamAgentChat.ts"
291        );
292        assert_eq!(cfg.prune_keep_tool_messages, 10);
293        assert_eq!(cfg.prune_keep_tail_messages, 12);
294    }
295
296    #[test]
297    fn default_config_passes_validation() {
298        assert!(AgentConfig::default().validated().is_ok());
299    }
300
301    #[test]
302    fn zero_max_iterations_rejected() {
303        let cfg = AgentConfig {
304            max_iterations: 0,
305            ..Default::default()
306        };
307        assert_eq!(
308            cfg.validated().unwrap_err(),
309            AgentConfigError::MaxIterationsZero(0),
310        );
311    }
312
313    #[test]
314    fn zero_max_parallel_tools_rejected() {
315        let cfg = AgentConfig {
316            max_parallel_tools: 0,
317            ..Default::default()
318        };
319        assert_eq!(
320            cfg.validated().unwrap_err(),
321            AgentConfigError::MaxParallelToolsZero(0),
322        );
323    }
324
325    #[test]
326    fn tool_timeout_below_floor_rejected() {
327        let cfg = AgentConfig {
328            tool_timeout_ms: MIN_TOOL_TIMEOUT_MS - 1,
329            ..Default::default()
330        };
331        assert_eq!(
332            cfg.validated().unwrap_err(),
333            AgentConfigError::ToolTimeoutTooLow(MIN_TOOL_TIMEOUT_MS - 1),
334        );
335    }
336
337    #[test]
338    fn tool_timeout_at_floor_accepted() {
339        let cfg = AgentConfig {
340            tool_timeout_ms: MIN_TOOL_TIMEOUT_MS,
341            ..Default::default()
342        };
343        assert!(cfg.validated().is_ok());
344    }
345
346    #[test]
347    fn context_budget_below_floor_rejected() {
348        let cfg = AgentConfig {
349            context_budget_chars: MIN_CONTEXT_BUDGET_CHARS - 1,
350            ..Default::default()
351        };
352        assert_eq!(
353            cfg.validated().unwrap_err(),
354            AgentConfigError::ContextBudgetTooLow(MIN_CONTEXT_BUDGET_CHARS - 1),
355        );
356    }
357
358    #[test]
359    fn context_budget_at_floor_accepted() {
360        let cfg = AgentConfig {
361            context_budget_chars: MIN_CONTEXT_BUDGET_CHARS,
362            ..Default::default()
363        };
364        assert!(cfg.validated().is_ok());
365    }
366
367    #[test]
368    fn boundary_values_accepted() {
369        let cfg = AgentConfig {
370            max_iterations: 1,
371            max_parallel_tools: 1,
372            tool_timeout_ms: MIN_TOOL_TIMEOUT_MS,
373            context_budget_chars: MIN_CONTEXT_BUDGET_CHARS,
374            ..Default::default()
375        };
376        assert!(cfg.validated().is_ok());
377    }
378
379    #[test]
380    fn from_user_params_clamps_and_validates() {
381        // All values within range → accepted as-is.
382        let cfg = AgentConfig::from_user_params(Some(10), Some(3), Some(5_000)).unwrap();
383        assert_eq!(cfg.max_iterations, 10);
384        assert_eq!(cfg.max_parallel_tools, 3);
385        assert_eq!(cfg.tool_timeout_ms, 5_000);
386    }
387
388    #[test]
389    fn from_user_params_clamps_extremes() {
390        // Zero iterations → clamped to 1.
391        let cfg = AgentConfig::from_user_params(Some(0), Some(0), Some(0)).unwrap();
392        assert_eq!(cfg.max_iterations, 1);
393        assert_eq!(cfg.max_parallel_tools, 1);
394        assert_eq!(cfg.tool_timeout_ms, MIN_TOOL_TIMEOUT_MS);
395    }
396
397    #[test]
398    fn from_user_params_clamps_above_ceiling() {
399        let cfg = AgentConfig::from_user_params(Some(usize::MAX), Some(usize::MAX), Some(u64::MAX))
400            .unwrap();
401        assert_eq!(cfg.max_iterations, MAX_ITERATIONS_CEILING);
402        assert_eq!(cfg.max_parallel_tools, MAX_PARALLEL_TOOLS_CEILING);
403        assert_eq!(cfg.tool_timeout_ms, MAX_TOOL_TIMEOUT_MS_CEILING);
404    }
405
406    #[test]
407    fn from_user_params_none_keeps_defaults() {
408        let cfg = AgentConfig::from_user_params(None, None, None).unwrap();
409        let def = AgentConfig::default();
410        assert_eq!(cfg.max_iterations, def.max_iterations);
411        assert_eq!(cfg.max_parallel_tools, def.max_parallel_tools);
412        assert_eq!(cfg.tool_timeout_ms, def.tool_timeout_ms);
413    }
414}