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