Skip to main content

leviath_core/
write_limits.rs

1//! Deciding whether an agent may write, and how much.
2//!
3//! Three questions, deliberately answered separately because they are not the
4//! same kind of thing (issue #252):
5//!
6//! 1. **Will this fill the disk?** Always asked, not configurable away. A run
7//!    that filled `C:` took the machine down with it, and every other process
8//!    on it. Nobody wants that outcome, so nothing offers it.
9//! 2. **Is one call writing an absurd amount?** Off unless configured. The
10//!    reported incident was a single shell call appending in a loop until the
11//!    60-second timeout - about 14 GB - and a per-call ceiling is what would
12//!    have caught it.
13//! 3. **Is the whole run writing an absurd amount?** Also off unless
14//!    configured. Three calls of 12-14 GB each is the shape 2 alone misses.
15//!
16//! **2 and 3 default to off in code and on in a fresh config.** How much an
17//! agent should be allowed to write is a judgement about what the user is doing
18//! with it, not something this crate can know - so the code imposes nothing.
19//! `lev setup` writes concrete values into `config.toml`, where they are
20//! visible and can be deleted outright by anyone who wants no ceiling.
21
22use serde::{Deserialize, Serialize};
23
24/// How much free space must remain before a write is refused.
25///
26/// Chosen to leave a machine usable rather than merely alive: below a gigabyte,
27/// a desktop OS starts failing at things a person notices - swap, browser
28/// caches, save dialogs - well before the disk is literally full. Refusing the
29/// agent's write at that point costs one tool call; not refusing it costs the
30/// session.
31pub const MIN_FREE_BYTES: u64 = 1024 * 1024 * 1024;
32
33/// The ceilings in effect for one run.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
35pub struct WriteLimits {
36    /// Most one tool call may write. `None` is unlimited.
37    pub per_call: Option<u64>,
38    /// Most the whole run may write. `None` is unlimited.
39    pub per_run: Option<u64>,
40}
41
42/// Why a write was refused, or that it was not.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum WriteVerdict {
45    /// Nothing objected.
46    Allow,
47    /// The filesystem is nearly full.
48    OutOfSpace {
49        /// Bytes still writable.
50        available: u64,
51        /// Bytes that must remain.
52        required: u64,
53    },
54    /// This one call is over the per-call ceiling.
55    CallTooLarge {
56        /// Bytes this call would write.
57        bytes: u64,
58        /// The ceiling.
59        limit: u64,
60    },
61    /// The run has spent its budget.
62    RunTooLarge {
63        /// Bytes written so far, including this call.
64        written: u64,
65        /// The ceiling.
66        limit: u64,
67    },
68}
69
70impl WriteVerdict {
71    /// The message an agent reads, or `None` when it was allowed.
72    ///
73    /// Each names the number that was exceeded, because "write refused" with no
74    /// figure leaves a model guessing whether to retry smaller or stop - and
75    /// the retry is what turns one refusal into a loop.
76    pub fn refusal(&self) -> Option<String> {
77        match self {
78            Self::Allow => None,
79            Self::OutOfSpace {
80                available,
81                required,
82            } => Some(format!(
83                "[denied] Refusing to write: only {available} bytes are free on this filesystem \
84                 and {required} must remain. This is not a limit you can raise - the machine is \
85                 nearly out of disk. Free some space, or write somewhere with room."
86            )),
87            Self::CallTooLarge { bytes, limit } => Some(format!(
88                "[denied] This call would write {bytes} bytes, over the {limit}-byte per-call \
89                 limit. Write less in one go, or raise `[limits] max_tool_call_write_bytes` in \
90                 the Leviath config (deleting the line removes the limit)."
91            )),
92            Self::RunTooLarge { written, limit } => Some(format!(
93                "[denied] This run has written {written} bytes, over its {limit}-byte budget. \
94                 Raise `[limits] max_run_write_bytes` in the Leviath config, or delete the line \
95                 to remove the limit."
96            )),
97        }
98    }
99}
100
101/// Whether a write of `bytes` may proceed.
102///
103/// `available` is what the filesystem reports, or `None` when it could not be
104/// measured. An unmeasurable filesystem **allows** the write: a guard that
105/// cannot see has nothing to say, and refusing on it would block every write on
106/// any filesystem the probe cannot read. The other two ceilings still apply.
107///
108/// `already_written` counts the run so far, *excluding* this call.
109///
110/// Checked in that order on purpose. Running out of disk is the only one that
111/// harms anything outside this run, so it is reported first when more than one
112/// applies - a user reading "over the per-call limit" would go raise the limit,
113/// which is exactly wrong when the real problem is a full disk.
114pub fn check_write(
115    limits: WriteLimits,
116    already_written: u64,
117    bytes: u64,
118    available: Option<u64>,
119) -> WriteVerdict {
120    if let Some(available) = available
121        && available.saturating_sub(bytes) < MIN_FREE_BYTES
122    {
123        return WriteVerdict::OutOfSpace {
124            available,
125            required: MIN_FREE_BYTES,
126        };
127    }
128    if let Some(limit) = limits.per_call
129        && bytes > limit
130    {
131        return WriteVerdict::CallTooLarge { bytes, limit };
132    }
133    let total = already_written.saturating_add(bytes);
134    if let Some(limit) = limits.per_run
135        && total > limit
136    {
137        return WriteVerdict::RunTooLarge {
138            written: total,
139            limit,
140        };
141    }
142    WriteVerdict::Allow
143}
144
145#[cfg(test)]
146mod tests;