agent_works/rotation_policy/config.rs
1//! Configuration and message builders for the token-budget window strategy.
2//!
3//! Pure data + pure functions: no I/O, no runtime. The window lifecycle
4//! state machine lives in [`super::core`].
5
6use agent_base::engine::estimate_messages_tokens;
7use agent_base::types::ChatMessage;
8
9/// Configuration for the token-budget window strategy.
10///
11/// **Work-room semantics**: `work_budget` is the conversation space ABOVE
12/// the window's fixed base (system prompt + per-window boilerplate), not the
13/// total window size. The reset point is `base + work_budget + buffer`, so
14/// any positive budget produces a window that fits — the "budget below
15/// system-prompt overhead" failure class is structurally impossible. Use
16/// [`TokenBudgetConfig::with_work_budget`] to keep the reminder/buffer
17/// thresholds proportional when overriding the default.
18#[derive(Clone, Debug)]
19pub struct TokenBudgetConfig {
20 /// Work-room budget: tokens of conversation space above the fixed
21 /// window base. Corresponds to "how much room the model has to work
22 /// with" — the base is added by the framework.
23 pub work_budget: usize,
24 /// Reminder threshold: inject the reminder when remaining work room
25 /// ≤ this value.
26 pub reminder_threshold: usize,
27 /// Reminder message template. `{n_remaining}` is replaced with estimated
28 /// remaining work-room tokens.
29 pub reminder_template: String,
30 /// Fallback prompt injected when the work budget is exhausted (window
31 /// about to reset).
32 pub fallback_prompt: String,
33 /// Extra work-room overshoot tolerated past `work_budget` before the
34 /// reset fires. Allows the model one more turn to save state after
35 /// the fallback.
36 pub fallback_buffer: usize,
37 /// Guidance message injected once per window start (explains the
38 /// private context-management tools).
39 pub guidance_message: String,
40 /// User seed message injected as the LAST message of every new window.
41 /// Satisfies the engine's compaction-output contract (at least one
42 /// non-System message — System maps to the top-level `system` parameter,
43 /// and an all-System window is an HTTP 400) and gives the model a turn
44 /// to reconstruct state.
45 pub seed_message: String,
46 /// Minimum viable work budget as a multiple of the base overhead.
47 /// Below this no single turn of work fits in the room (one tool result
48 /// overflows it) and the session thrashes. Construction clamps the
49 /// work budget up to `max(min_work_multiple × base,
50 /// min_absolute_work_room)` and scales the reminder/buffer bands to
51 /// match.
52 pub min_work_multiple: f64,
53 /// Absolute floor for the work room, independent of base size. Tool
54 /// results don't shrink with the system prompt — an app with a tiny
55 /// prompt still needs room for real tool output, so the viability floor
56 /// is whichever is larger: the proportional bound or this constant.
57 pub min_absolute_work_room: usize,
58 /// Token cap for the mechanical user-message trail carried into a fresh
59 /// window. User messages are the task trail — facts, not model output —
60 /// so they are re-injected verbatim (newest-first under this cap, oldest
61 /// truncated) instead of asking the model to recover them from history
62 /// (cf. codex `build_compacted_history`).
63 pub user_trail_max_tokens: usize,
64}
65
66impl Default for TokenBudgetConfig {
67 fn default() -> Self {
68 Self {
69 work_budget: 96_000,
70 reminder_threshold: 19_200,
71 reminder_template: "You have approximately {n_remaining} tokens left in this context window. \
72 If you have important state, decisions, or progress to record, save them to your notes now \
73 using the notes tools. This window may close soon."
74 .to_string(),
75 fallback_prompt: "This context window is closing. Before it resets, write a handoff \
76 summary to your notes — a note file named `thread_hint.md` is injected \
77 automatically into the next window, so put the current task, key decisions, \
78 and progress there. Your previous conversation history also remains \
79 available via the history tools."
80 .to_string(),
81 fallback_buffer: 9_600,
82 guidance_message: "You have access to two private tools for context management:\n\
83 - `history`: read-only access to previous context windows (list, search, read)\n\
84 - `notes`: read-write persistent scratchpad that survives window transitions\n\
85 Use these silently to maintain continuity. Never disclose them to the user."
86 .to_string(),
87 seed_message: DEFAULT_SEED_MESSAGE.to_string(),
88 min_work_multiple: 2.0,
89 min_absolute_work_room: 4_096,
90 user_trail_max_tokens: 2_000,
91 }
92 }
93}
94
95/// Default user seed message for new windows (see
96/// [`TokenBudgetConfig::seed_message`]).
97pub const DEFAULT_SEED_MESSAGE: &str = "Your previous context window was archived. The user \
98 messages above are your task trail — the last one is your current task. \
99 Check notes/thread_hint.md for any handoff from the previous window; \
100 if missing, write one after you understand the task. \
101 Previous conversation history is available via the history tools — consult them \
102 only if you need context the trail doesn't provide. \
103 Do NOT re-read files you already covered. Continue the task directly.";
104
105impl TokenBudgetConfig {
106 /// Config with proportional reminder/buffer thresholds for a custom
107 /// work budget (reminder = 20%, buffer = 10% of `work_budget`).
108 pub fn with_work_budget(work_budget: usize) -> Self {
109 Self {
110 work_budget,
111 reminder_threshold: work_budget / 5,
112 fallback_buffer: work_budget / 10,
113 ..Default::default()
114 }
115 }
116
117 /// Hard reset threshold in absolute estimated tokens:
118 /// `base_overhead + work_budget + fallback_buffer`.
119 pub fn hard_limit(&self, base_overhead: usize) -> usize {
120 base_overhead + self.work_budget + self.fallback_buffer
121 }
122}
123
124/// The window-info system message injected at the start of each window.
125pub fn build_context_window_info(
126 previous_window_id: usize,
127 current_window_id: usize,
128) -> ChatMessage {
129 ChatMessage::system(format!(
130 "<context_window>\n\
131 Previous context window id: {:03}\n\
132 Current context window id: {:03}\n\
133 </context_window>",
134 previous_window_id, current_window_id
135 ))
136}
137
138/// Build the reminder developer message for a given remaining work-room
139/// token count.
140///
141/// Uses an ephemeral system message so it's auto-cleaned after the turn and
142/// does not pollute persisted conversation history.
143pub fn build_reminder_message(config: &TokenBudgetConfig, remaining: usize) -> ChatMessage {
144 let content = config
145 .reminder_template
146 .replace("{n_remaining}", &remaining.to_string());
147 ChatMessage::system_ephemeral(content)
148}
149
150/// Build the fallback developer message (work budget exhausted, window
151/// about to reset).
152///
153/// This is NOT ephemeral — it persists so the model can see it in the new
154/// window's initial context if needed.
155pub fn build_fallback_message(config: &TokenBudgetConfig) -> ChatMessage {
156 ChatMessage::system(config.fallback_prompt.clone())
157}
158
159/// Token estimate of the fixed content every window starts with:
160/// system prompt + window info + guidance + seed (no thread hint — the
161/// best case; app-provided hints add on top).
162///
163/// Call ONCE at build time — the system prompt is process-constant, so the
164/// result never changes for the lifetime of the agent. The estimate is a
165/// char count (~4 chars/token Latin, ~1.5 CJK), not a tokenizer call.
166pub fn token_budget_base_overhead(
167 config: &TokenBudgetConfig,
168 system_prompt: Option<&str>,
169) -> usize {
170 let mut base = Vec::new();
171 if let Some(sp) = system_prompt {
172 base.push(ChatMessage::system(sp.to_string()));
173 }
174 base.push(build_context_window_info(1, 2));
175 base.push(ChatMessage::system(config.guidance_message.clone()));
176 base.push(ChatMessage::user(config.seed_message.clone()));
177 estimate_messages_tokens(&base)
178}
179
180/// State tracker for the token budget across compaction calls within a
181/// single window.
182///
183/// Uses atomics for interior mutability (the
184/// [`ContextCompaction::compact`](agent_base::ContextCompaction::compact)
185/// method takes `&self`, not `&mut self`).
186pub struct TokenBudgetState {
187 /// Current window ID (1-indexed, incremented after each reset).
188 pub window_id: std::sync::atomic::AtomicUsize,
189 /// Whether a reminder has been sent in the current window.
190 pub reminder_sent: std::sync::atomic::AtomicBool,
191 /// Consecutive resets of windows that never had a quiet turn — feeds the
192 /// futility brake (see `TokenBudgetCore::evaluate`).
193 breathless_resets: std::sync::atomic::AtomicUsize,
194 /// Whether the current window ever evaluated to `None` (i.e. completed
195 /// real work instead of dying immediately).
196 had_breath: std::sync::atomic::AtomicBool,
197 /// Futility brake engaged: window rotation permanently paused (the room
198 /// provably cannot hold one turn of work).
199 braked: std::sync::atomic::AtomicBool,
200}
201
202impl TokenBudgetState {
203 pub fn new() -> Self {
204 Self {
205 window_id: std::sync::atomic::AtomicUsize::new(1),
206 reminder_sent: std::sync::atomic::AtomicBool::new(false),
207 breathless_resets: std::sync::atomic::AtomicUsize::new(0),
208 had_breath: std::sync::atomic::AtomicBool::new(false),
209 braked: std::sync::atomic::AtomicBool::new(false),
210 }
211 }
212
213 /// Advance to the next window and reset per-window state. Returns the
214 /// new window ID.
215 pub fn advance_window(&self) -> usize {
216 self.reminder_sent
217 .store(false, std::sync::atomic::Ordering::Relaxed);
218 self.had_breath
219 .store(false, std::sync::atomic::Ordering::Relaxed);
220 self.window_id
221 .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
222 + 1
223 }
224
225 pub fn current_window_id(&self) -> usize {
226 self.window_id.load(std::sync::atomic::Ordering::Relaxed)
227 }
228
229 pub fn has_sent_reminder(&self) -> bool {
230 self.reminder_sent
231 .load(std::sync::atomic::Ordering::Relaxed)
232 }
233
234 pub fn mark_reminder_sent(&self) {
235 self.reminder_sent
236 .store(true, std::sync::atomic::Ordering::Relaxed);
237 }
238
239 /// Record that the current window had a quiet evaluation — real work
240 /// fits in the room.
241 pub fn mark_breath(&self) {
242 self.had_breath
243 .store(true, std::sync::atomic::Ordering::Relaxed);
244 }
245
246 pub fn had_breath(&self) -> bool {
247 self.had_breath.load(std::sync::atomic::Ordering::Relaxed)
248 }
249
250 /// Count a reset of a window that never had a quiet turn. Returns the
251 /// consecutive breathless-reset count (1-indexed).
252 pub fn record_breathless_reset(&self) -> usize {
253 self.breathless_resets
254 .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
255 + 1
256 }
257
258 /// A window that did real work was archived — the room is viable; clear
259 /// the consecutive-breathless counter.
260 pub fn clear_breathless(&self) {
261 self.breathless_resets
262 .store(0, std::sync::atomic::Ordering::Relaxed);
263 }
264
265 pub fn engage_brake(&self) {
266 self.braked
267 .store(true, std::sync::atomic::Ordering::Relaxed);
268 }
269
270 pub fn braked(&self) -> bool {
271 self.braked.load(std::sync::atomic::Ordering::Relaxed)
272 }
273}
274
275impl Default for TokenBudgetState {
276 fn default() -> Self {
277 Self::new()
278 }
279}