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;