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