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;