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}