Skip to main content

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}