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            // 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}