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