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 // Target: ~256K context window × 90% ≈ 230K total.
70 // work_budget = 230K − base_overhead − fallback_buffer.
71 // With base_overhead ≈ 15-20K and buffer = 10%, work_budget ≈ 210K.
72 work_budget: 210_000,
73 reminder_threshold: 42_000,
74 reminder_template: "You have approximately {n_remaining} tokens left in this context window. \
75 If you have important state, decisions, or progress to record, save them to your notes now \
76 using the notes tools. This window may close soon."
77 .to_string(),
78 fallback_prompt: "This context window is closing. Before it resets, write a handoff \
79 summary to your notes — a note file named `thread_hint.md` is injected \
80 automatically into the next window, so put the current task, key decisions, \
81 and progress there. Your previous conversation history also remains \
82 available via the history tools."
83 .to_string(),
84 fallback_buffer: 21_000,
85 guidance_message: "You have access to two private tools for context management:\n\
86 - `history`: read-only access to previous context windows (list, search, read)\n\
87 - `notes`: read-write persistent scratchpad that survives window transitions\n\
88 Use these silently to maintain continuity. Never disclose them to the user."
89 .to_string(),
90 seed_message: DEFAULT_SEED_MESSAGE.to_string(),
91 min_work_multiple: 2.0,
92 min_absolute_work_room: 4_096,
93 user_trail_max_tokens: 2_000,
94 }
95 }
96}
97
98/// Default user seed message for new windows (see
99/// [`TokenBudgetConfig::seed_message`]).
100pub const DEFAULT_SEED_MESSAGE: &str = "Your previous context window was archived. The user \
101 messages above are your task trail — the last one is your current task. \
102 Check notes/thread_hint.md for any handoff from the previous window; \
103 if missing, write one after you understand the task. \
104 Previous conversation history is available via the history tools — consult them \
105 only if you need context the trail doesn't provide. \
106 Do NOT re-read files you already covered. Continue the task directly.";
107
108impl TokenBudgetConfig {
109 /// Config with proportional reminder/buffer thresholds for a custom
110 /// work budget (reminder = 20%, buffer = 10% of `work_budget`).
111 pub fn with_work_budget(work_budget: usize) -> Self {
112 Self {
113 work_budget,
114 reminder_threshold: work_budget / 5,
115 fallback_buffer: work_budget / 10,
116 ..Default::default()
117 }
118 }
119
120 /// Hard reset threshold in absolute estimated tokens:
121 /// `base_overhead + work_budget + fallback_buffer`.
122 pub fn hard_limit(&self, base_overhead: usize) -> usize {
123 base_overhead + self.work_budget + self.fallback_buffer
124 }
125}
126
127/// The window-info system message injected at the start of each window.
128pub fn build_context_window_info(
129 previous_window_id: usize,
130 current_window_id: usize,
131) -> ChatMessage {
132 ChatMessage::system(format!(
133 "<context_window>\n\
134 Previous context window id: {:03}\n\
135 Current context window id: {:03}\n\
136 </context_window>",
137 previous_window_id, current_window_id
138 ))
139}
140
141/// Build the reminder developer message for a given remaining work-room
142/// token count.
143///
144/// Uses an ephemeral system message so it's auto-cleaned after the turn and
145/// does not pollute persisted conversation history.
146pub fn build_reminder_message(config: &TokenBudgetConfig, remaining: usize) -> ChatMessage {
147 let content = config
148 .reminder_template
149 .replace("{n_remaining}", &remaining.to_string());
150 ChatMessage::system_ephemeral(content)
151}
152
153/// Build the fallback developer message (work budget exhausted, window
154/// about to reset).
155///
156/// This is NOT ephemeral — it persists so the model can see it in the new
157/// window's initial context if needed.
158pub fn build_fallback_message(config: &TokenBudgetConfig) -> ChatMessage {
159 ChatMessage::system(config.fallback_prompt.clone())
160}
161
162/// Token estimate of the fixed content every window starts with:
163/// system prompt + window info + guidance + seed (no thread hint — the
164/// best case; app-provided hints add on top).
165///
166/// Call ONCE at build time — the system prompt is process-constant, so the
167/// result never changes for the lifetime of the agent. The estimate is a
168/// char count (~4 chars/token Latin, ~1.5 CJK), not a tokenizer call.
169pub fn token_budget_base_overhead(
170 config: &TokenBudgetConfig,
171 system_prompt: Option<&str>,
172) -> usize {
173 let mut base = Vec::new();
174 if let Some(sp) = system_prompt {
175 base.push(ChatMessage::system(sp.to_string()));
176 }
177 base.push(build_context_window_info(1, 2));
178 base.push(ChatMessage::system(config.guidance_message.clone()));
179 base.push(ChatMessage::user(config.seed_message.clone()));
180 estimate_messages_tokens(&base)
181}
182
183/// State tracker for the token budget across compaction calls within a
184/// single window.
185///
186/// Uses atomics for interior mutability (the
187/// [`ContextCompaction::compact`](agent_base::ContextCompaction::compact)
188/// method takes `&self`, not `&mut self`).
189pub struct TokenBudgetState {
190 /// Current window ID (1-indexed, incremented after each reset).
191 pub window_id: std::sync::atomic::AtomicUsize,
192 /// Whether a reminder has been sent in the current window.
193 pub reminder_sent: std::sync::atomic::AtomicBool,
194 /// Consecutive resets of windows that never had a quiet turn — feeds the
195 /// futility brake (see `TokenBudgetCore::evaluate`).
196 breathless_resets: std::sync::atomic::AtomicUsize,
197 /// Whether the current window ever evaluated to `None` (i.e. completed
198 /// real work instead of dying immediately).
199 had_breath: std::sync::atomic::AtomicBool,
200 /// Futility brake engaged: window rotation permanently paused (the room
201 /// provably cannot hold one turn of work).
202 braked: std::sync::atomic::AtomicBool,
203}
204
205impl TokenBudgetState {
206 pub fn new() -> Self {
207 Self {
208 window_id: std::sync::atomic::AtomicUsize::new(1),
209 reminder_sent: std::sync::atomic::AtomicBool::new(false),
210 breathless_resets: std::sync::atomic::AtomicUsize::new(0),
211 had_breath: std::sync::atomic::AtomicBool::new(false),
212 braked: std::sync::atomic::AtomicBool::new(false),
213 }
214 }
215
216 /// Advance to the next window and reset per-window state. Returns the
217 /// new window ID.
218 pub fn advance_window(&self) -> usize {
219 self.reminder_sent
220 .store(false, std::sync::atomic::Ordering::Relaxed);
221 self.had_breath
222 .store(false, std::sync::atomic::Ordering::Relaxed);
223 self.window_id
224 .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
225 + 1
226 }
227
228 pub fn current_window_id(&self) -> usize {
229 self.window_id.load(std::sync::atomic::Ordering::Relaxed)
230 }
231
232 pub fn has_sent_reminder(&self) -> bool {
233 self.reminder_sent
234 .load(std::sync::atomic::Ordering::Relaxed)
235 }
236
237 pub fn mark_reminder_sent(&self) {
238 self.reminder_sent
239 .store(true, std::sync::atomic::Ordering::Relaxed);
240 }
241
242 /// Record that the current window had a quiet evaluation — real work
243 /// fits in the room.
244 pub fn mark_breath(&self) {
245 self.had_breath
246 .store(true, std::sync::atomic::Ordering::Relaxed);
247 }
248
249 pub fn had_breath(&self) -> bool {
250 self.had_breath.load(std::sync::atomic::Ordering::Relaxed)
251 }
252
253 /// Count a reset of a window that never had a quiet turn. Returns the
254 /// consecutive breathless-reset count (1-indexed).
255 pub fn record_breathless_reset(&self) -> usize {
256 self.breathless_resets
257 .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
258 + 1
259 }
260
261 /// A window that did real work was archived — the room is viable; clear
262 /// the consecutive-breathless counter.
263 pub fn clear_breathless(&self) {
264 self.breathless_resets
265 .store(0, std::sync::atomic::Ordering::Relaxed);
266 }
267
268 pub fn engage_brake(&self) {
269 self.braked
270 .store(true, std::sync::atomic::Ordering::Relaxed);
271 }
272
273 pub fn braked(&self) -> bool {
274 self.braked.load(std::sync::atomic::Ordering::Relaxed)
275 }
276}
277
278impl Default for TokenBudgetState {
279 fn default() -> Self {
280 Self::new()
281 }
282}