leviath_cli/config/limits.rs
1//! `[limits]` and `[webhook]`: the ceilings a run is bounded by.
2//!
3//! Concurrency caps, timeouts, retention, the stall and wedge watchdogs, and the
4//! webhook retry schedule. The `default_*` functions are serde defaults for the
5//! fields below them and are kept beside those fields deliberately: a default
6//! that drifts from the field it fills is invisible until someone reads a
7//! config that omits it.
8
9use serde::{Deserialize, Serialize};
10
11fn default_max_concurrent_inferences() -> Option<usize> {
12 Some(8)
13}
14
15fn default_default_max_iterations() -> Option<usize> {
16 Some(50)
17}
18
19fn default_max_concurrent_tools() -> usize {
20 8
21}
22
23fn default_script_shell_timeout_secs() -> u64 {
24 60
25}
26
27fn default_stall_timeout_secs() -> u64 {
28 leviath_runtime::pipeline::DEFAULT_STALL_TIMEOUT_SECS
29}
30
31fn default_dead_cycles_before_relief() -> u32 {
32 leviath_runtime::host::DEFAULT_DEAD_CYCLES_BEFORE_RELIEF
33}
34
35pub(crate) fn default_mcp_idle_disconnect_secs() -> u64 {
36 crate::daemon::mcp_pool::DEFAULT_MCP_IDLE_DISCONNECT_SECS
37}
38
39fn default_finished_retention_secs() -> u64 {
40 leviath_runtime::host::DEFAULT_FINISHED_RETENTION_SECS
41}
42
43fn default_wedge_timeout_secs() -> u64 {
44 leviath_runtime::pipeline::DEFAULT_WEDGE_TIMEOUT_SECS
45}
46
47fn default_provider_failures_before_open() -> u32 {
48 leviath_runtime::pipeline::DEFAULT_FAILURES_BEFORE_OPEN
49}
50
51fn default_provider_circuit_cooldown_secs() -> u64 {
52 leviath_runtime::pipeline::DEFAULT_CIRCUIT_COOLDOWN_SECS
53}
54
55fn default_interaction_timeout_secs() -> u64 {
56 leviath_runtime::interaction_hub::DEFAULT_INTERACTION_TIMEOUT_SECS
57}
58
59fn default_inference_retry_attempts() -> u32 {
60 leviath_runtime::DEFAULT_RETRY_ATTEMPTS
61}
62
63fn default_inference_retry_base_ms() -> u64 {
64 leviath_runtime::DEFAULT_RETRY_BASE_DELAY_MS
65}
66
67/// Runtime resource limits with safe defaults baked in.
68///
69/// Both fields default to a bounded value so a fresh install can't accidentally
70/// run unbounded inference concurrency or an unbounded agent loop. Set a field
71/// explicitly in `[limits]` to raise or lower it.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct LimitsConfig {
74 /// Global fallback cap on concurrent inference requests for any model
75 /// without its own per-model pool entry. Defaults to `Some(8)`; omit or set
76 /// a large number to effectively unbound it.
77 ///
78 /// One physical bound sits behind this for *script* providers: each of
79 /// their in-flight calls occupies a blocking-pool thread, and the daemon's
80 /// runtime provisions 2048 of those. Pools above 2048 only run that wide
81 /// for HTTP providers, whose calls are fully async.
82 #[serde(default = "default_max_concurrent_inferences")]
83 pub max_concurrent_inferences: Option<usize>,
84
85 /// Size of the shared tool-execution worker pool - the number of agents whose
86 /// tool batches may run concurrently across the whole daemon (the tool-lane
87 /// counterpart of `max_concurrent_inferences`). Defaults to `8`. Clamped to at
88 /// least 1.
89 #[serde(default = "default_max_concurrent_tools")]
90 pub max_concurrent_tools: usize,
91
92 /// Fallback `max_iterations` applied to a stage that does not set its own,
93 /// so an agent can't loop forever with no completion signal. Defaults to
94 /// `Some(50)`. A stage's explicit `max_iterations` always wins.
95 #[serde(default = "default_default_max_iterations")]
96 pub default_max_iterations: Option<usize>,
97
98 /// Opt-in exact pre-inference token budgeting. When `true`, each agent
99 /// inference is preceded by an exact token count of the assembled request
100 /// (via the provider's `count_tokens`, which uses a remote endpoint for
101 /// Anthropic/Gemini and a local heuristic otherwise) and is rejected before
102 /// sending if it would exceed the model's context window. Off by default:
103 /// normal budgeting uses cheap local estimates, and this adds a network
104 /// round-trip per inference for providers with a remote count endpoint.
105 #[serde(default)]
106 pub exact_token_counting: bool,
107
108 /// Wall-clock timeout (seconds) for a Rhai script tool's `shell()` host call,
109 /// mirroring the built-in shell tool's own 60-second cap so a script can't
110 /// hang an agent on a runaway command. Defaults to `60`.
111 #[serde(default = "default_script_shell_timeout_secs")]
112 pub script_shell_timeout_secs: u64,
113
114 /// How long (seconds) a run may sit ready to work but unable to dispatch
115 /// before it is failed instead of left running.
116 ///
117 /// This only ever fires for something the runtime cannot resolve on its own -
118 /// today, a stage whose provider is not configured. Waiting for a busy
119 /// model's inference pool is ordinary backpressure and is never failed, no
120 /// matter how long it takes. Defaults to `60`; `0` disables the watchdog and
121 /// restores the old behaviour of waiting indefinitely.
122 ///
123 /// Read once at daemon start, so a change needs a daemon restart.
124 #[serde(default = "default_stall_timeout_secs")]
125 pub stall_timeout_secs: u64,
126
127 /// How many consecutive safety re-drives may find the tool lane full and no
128 /// run moving before the daemon widens the lane to break the jam.
129 ///
130 /// The daemon re-drives itself every 30 seconds, so the default of `10` is
131 /// five minutes of a full lane going nowhere. Relief only ever *adds*
132 /// capacity, never cancels anything, and is capped at one extra lane's worth
133 /// over the daemon's life, so it cannot run away.
134 ///
135 /// `0` turns relief off. Detection and reporting stay on either way, so
136 /// `lev ps` and the metrics still show the streak.
137 ///
138 /// Read once at daemon start, so a change needs a daemon restart.
139 #[serde(default = "default_dead_cycles_before_relief")]
140 pub dead_cycles_before_relief: u32,
141
142 /// How long (seconds) a run keeps its place in `lev ps` after the daemon
143 /// unloads it from memory.
144 ///
145 /// A terminal run used to leave the listing the moment it was unloaded,
146 /// which made a run that died on its first inference look exactly like a run
147 /// that had never been spawned. A scheduler polling the listing could only
148 /// tell the two apart with a stopwatch, and issue #205 is what that cost:
149 /// forty minutes of spawning work, timing out, and spawning it again.
150 ///
151 /// Defaults to `300`. `0` drops a run as soon as it finishes, which is the
152 /// old behaviour. The record lives in memory, so a restart clears it
153 /// whatever this is set to.
154 ///
155 /// Read once at daemon start, so a change needs a daemon restart.
156 #[serde(default = "default_finished_retention_secs")]
157 pub finished_retention_secs: u64,
158 /// How long (seconds) a per-agent MCP server may sit with zero live runs
159 /// leasing it before the daemon disconnects it (ending a stdio server's
160 /// child process). Long enough that back-to-back runs of a blueprint reuse
161 /// the warm connection; the next run that declares the server reconnects
162 /// lazily. `0` keeps every server connected for the daemon's life, which
163 /// was the old behaviour. Global `[[mcp_servers]]` from config.toml are
164 /// never disconnected regardless.
165 ///
166 /// Read once at daemon start, so a change needs a daemon restart.
167 #[serde(default = "default_mcp_idle_disconnect_secs")]
168 pub mcp_idle_disconnect_secs: u64,
169 /// How long (seconds) a run may sit in a state no part of the engine can
170 /// reach before it is failed instead of left reported as running.
171 ///
172 /// Not a general "this run looks slow" timeout, and never fires on one. An
173 /// agent waiting on the model, on a tool, on its sub-agents, or on a person
174 /// is holding the marker that says so, and is exempt however long it takes.
175 /// This only catches an agent holding *no* marker at all, which the engine's
176 /// own invariants say cannot happen and which nothing will ever look at
177 /// again. Such a run stays `running` in `meta.json` for the life of the
178 /// daemon and keeps whatever capacity an external scheduler assigned it,
179 /// which is issue #202.
180 ///
181 /// Defaults to `0`, which is off: this fails runs, and an upgrade that
182 /// starts killing work nobody asked it to kill is worse than the leak. `300`
183 /// is a reasonable value to set. Turning it on is also a way to find out
184 /// whether it is happening to you, since it says so in the log and in the
185 /// run's error.
186 ///
187 /// Read once at daemon start, so a change needs a daemon restart.
188 #[serde(default = "default_wedge_timeout_secs")]
189 pub wedge_timeout_secs: u64,
190 /// How many consecutive provider-fatal failures (out of credits, rejected
191 /// key) take a provider out of service for every run.
192 ///
193 /// Defaults to `3`. One 402 can just be a request asking for more output
194 /// tokens than the balance covers; three in a row is the account. While a
195 /// provider is out, runs move to their next candidate, and runs with none
196 /// left are failed by the stall watchdog rather than left "running".
197 ///
198 /// `0` disables the breaker, leaving per-run failover on its own.
199 ///
200 /// Read once at daemon start, so a change needs a daemon restart.
201 #[serde(default = "default_provider_failures_before_open")]
202 pub provider_failures_before_open: u32,
203
204 /// How long a provider stays out of service before one request is let
205 /// through to see whether it recovered.
206 ///
207 /// Defaults to `300` (five minutes). That probe either succeeds, which puts
208 /// the provider straight back into service, or fails and restarts the wait,
209 /// so topping up an account brings the factory back with no restart.
210 ///
211 /// Read once at daemon start, so a change needs a daemon restart.
212 #[serde(default = "default_provider_circuit_cooldown_secs")]
213 pub provider_circuit_cooldown_secs: u64,
214 /// How long (seconds) a prompt may go unanswered before the daemon resolves
215 /// it itself and lets the run carry on.
216 ///
217 /// Covers every prompt that waits on a person: an agent's `ask_user_*` /
218 /// `present_for_review` call, a tool-approval prompt, a taint gate, and a
219 /// blueprint interaction point. Before this existed, a run whose operator
220 /// had walked away sat in `WaitingInput` holding its slot until the daemon
221 /// restarted - hours, in the report that prompted it (issue #204).
222 ///
223 /// Expiry resolves the prompt exactly as cancelling it would: a tool
224 /// approval and a taint gate **deny**, an `ask_user_*` call is told nobody
225 /// answered, and an interaction point proceeds with no user text. Nothing is
226 /// approved on the strength of a timeout.
227 ///
228 /// Defaults to `3600` (one hour); `0` waits indefinitely.
229 ///
230 /// Read once at daemon start, so a change needs a daemon restart.
231 #[serde(default = "default_interaction_timeout_secs")]
232 pub interaction_timeout_secs: u64,
233
234 /// How many times an inference is attempted, the first try included,
235 /// before the agent is failed with whatever the provider last said.
236 ///
237 /// Only a transient failure is retried at all - a reset connection, a
238 /// timeout, a 429, a 5xx. An authentication error or an over-long request
239 /// fails on the first answer, since the second would be identical.
240 ///
241 /// Defaults to `4`, which is one try and three retries. `1` turns retrying
242 /// off. The wait between retries is `inference_retry_base_ms`, doubling each
243 /// time, so the default schedule is 1s, 2s, 4s.
244 ///
245 /// Raising it lengthens how long a run rides out a provider **overload**
246 /// (an Anthropic 529, or a 429), which is retried on its own much slower
247 /// schedule of 15s, 30s, then 60s per further attempt - that case is why the
248 /// key exists (issue #417). Whatever this is set to, the retries of one
249 /// request may sleep at most five minutes in total.
250 ///
251 /// Read once at daemon start, so a change needs a daemon restart.
252 #[serde(default = "default_inference_retry_attempts")]
253 pub inference_retry_attempts: u32,
254
255 /// The wait before the first inference retry, in milliseconds, doubling for
256 /// each retry after it. Defaults to `1000`, so the schedule is 1s, 2s, 4s.
257 ///
258 /// This is the *blip* schedule and is meant to stay short: a reset
259 /// connection or a 500 is usually gone by the next attempt. A provider
260 /// overload does not use it - see `inference_retry_attempts` - so raising
261 /// this to wait out an outage is the wrong lever and only delays ordinary
262 /// failures.
263 ///
264 /// Read once at daemon start, so a change needs a daemon restart.
265 #[serde(default = "default_inference_retry_base_ms")]
266 pub inference_retry_base_ms: u64,
267
268 /// Most bytes one tool call may write to disk. Unset is unlimited.
269 ///
270 /// **Unset in code, set by `lev setup`.** How much an agent should write is
271 /// a judgement about what you are doing with it, so nothing is imposed on a
272 /// user who never opened this file - but a fresh install gets a concrete
273 /// number written here, where it is visible and can be deleted outright.
274 ///
275 /// The incident behind it (issue #252) was a single shell call appending in
276 /// a loop until the 60-second timeout: about 14 GB, from one call that
277 /// looked ordinary.
278 ///
279 /// A shell redirect is measured *after* the call, since the bytes go from
280 /// the shell to the file without passing through Leviath. So this stops the
281 /// call after the one that overran, not the one that did. `write_file` is
282 /// measured before, and is stopped outright.
283 ///
284 /// Running out of disk is checked separately and is never configurable: see
285 /// [`leviath_core::write_limits::MIN_FREE_BYTES`].
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub max_tool_call_write_bytes: Option<u64>,
288
289 /// Most bytes a whole run may write to disk. Unset is unlimited.
290 ///
291 /// The companion to `max_tool_call_write_bytes`, and the one that catches
292 /// what a per-call ceiling cannot: three calls of 12-14 GB each are
293 /// individually plausible and collectively a full disk. Same defaulting -
294 /// unset in code, written by `lev setup`.
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub max_run_write_bytes: Option<u64>,
297}
298
299impl LimitsConfig {
300 /// The write ceilings in effect, for the engine.
301 pub fn write_limits(&self) -> leviath_core::write_limits::WriteLimits {
302 leviath_core::write_limits::WriteLimits {
303 per_call: self.max_tool_call_write_bytes,
304 per_run: self.max_run_write_bytes,
305 }
306 }
307}
308
309impl Default for LimitsConfig {
310 fn default() -> Self {
311 Self {
312 max_concurrent_inferences: default_max_concurrent_inferences(),
313 max_concurrent_tools: default_max_concurrent_tools(),
314 default_max_iterations: default_default_max_iterations(),
315 exact_token_counting: false,
316 script_shell_timeout_secs: default_script_shell_timeout_secs(),
317 stall_timeout_secs: default_stall_timeout_secs(),
318 dead_cycles_before_relief: default_dead_cycles_before_relief(),
319 finished_retention_secs: default_finished_retention_secs(),
320 mcp_idle_disconnect_secs: default_mcp_idle_disconnect_secs(),
321 wedge_timeout_secs: default_wedge_timeout_secs(),
322 provider_failures_before_open: default_provider_failures_before_open(),
323 provider_circuit_cooldown_secs: default_provider_circuit_cooldown_secs(),
324 interaction_timeout_secs: default_interaction_timeout_secs(),
325 inference_retry_attempts: default_inference_retry_attempts(),
326 inference_retry_base_ms: default_inference_retry_base_ms(),
327 // Deliberately `None` here and concrete in `lev setup`: the code
328 // imposes no ceiling on a user who never opened the config, and a
329 // fresh install gets a number written where it can be seen and
330 // deleted. See the field docs.
331 max_tool_call_write_bytes: None,
332 max_run_write_bytes: None,
333 }
334 }
335}
336
337fn default_webhook_max_retries() -> u32 {
338 3
339}
340
341fn default_webhook_base_delay_ms() -> u64 {
342 500
343}
344
345fn default_webhook_max_delay_ms() -> u64 {
346 30_000
347}
348
349fn default_webhook_timeout_secs() -> u64 {
350 10
351}
352
353/// Completion-webhook delivery tuning.
354///
355/// A completion webhook is POSTed when a run reaches a terminal status. Delivery
356/// retries on transient failures (network errors, timeouts, 5xx, 429, 408) with
357/// exponential backoff. Each field has a safe default so `[webhook]` can be
358/// omitted entirely.
359#[derive(Debug, Clone, Serialize, Deserialize)]
360pub struct WebhookConfig {
361 /// Number of retries **after** the first attempt (so total sends is
362 /// `max_retries + 1`). Defaults to `3`. Set `0` to disable retries.
363 #[serde(default = "default_webhook_max_retries")]
364 pub max_retries: u32,
365
366 /// Base backoff before the first retry, in milliseconds. Subsequent retries
367 /// double it (capped at `max_delay_ms`). Defaults to `500`.
368 #[serde(default = "default_webhook_base_delay_ms")]
369 pub base_delay_ms: u64,
370
371 /// Upper bound on any single backoff delay, in milliseconds. Defaults to
372 /// `30_000` (30s).
373 #[serde(default = "default_webhook_max_delay_ms")]
374 pub max_delay_ms: u64,
375
376 /// Per-attempt request timeout, in seconds. Defaults to `10`.
377 #[serde(default = "default_webhook_timeout_secs")]
378 pub timeout_secs: u64,
379}
380
381impl Default for WebhookConfig {
382 fn default() -> Self {
383 Self {
384 max_retries: default_webhook_max_retries(),
385 base_delay_ms: default_webhook_base_delay_ms(),
386 max_delay_ms: default_webhook_max_delay_ms(),
387 timeout_secs: default_webhook_timeout_secs(),
388 }
389 }
390}