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}