Skip to main content

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}