Skip to main content

agent_core/core/
context_policy.rs

1//! Pure, task-aware context pressure policy. No I/O, clocks, prompts, or tools.
2//!
3//! Pressure is preparation, not a percentage-based stop. Existing execution
4//! can continue through the warning band, but a new task or execution after a
5//! plan should start in fresh context. At rollover only a bounded Plan/WrapUp
6//! extension is allowed; provider capacity always takes precedence.
7//!
8//! The host owns token accounting and safe session replacement. Assess before
9//! each model round, then persist `assessment.next_state` at that admission
10//! checkpoint (not for UI-only polling). It charges one round for each bounded
11//! extension. Keep that state across phase reports and mode changes; reset it
12//! only after successful context replacement. Neither phase reports nor these
13//! decisions authorize tools, disclosures, or mutations of session history.
14
15use crate::core::config::{ContextManagementConfig, ContextManagementMode};
16
17/// Advisory task phase. A report is not evidence of authorization or capacity.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum WorkPhase {
20    #[default]
21    Unknown,
22    /// Execution already in progress may continue through the pressure band.
23    Execute,
24    /// Finish a bounded piece of existing work; do not begin another task.
25    WrapUp,
26    /// Produce a plan/specification, not execute the resulting large plan.
27    Plan,
28    NewTask,
29}
30
31impl WorkPhase {
32    pub fn parse(value: &str) -> Option<Self> {
33        match value.trim().to_ascii_lowercase().as_str() {
34            "unknown" => Some(Self::Unknown),
35            "execute" => Some(Self::Execute),
36            "wrap_up" | "wrapup" | "wrap-up" => Some(Self::WrapUp),
37            "plan" => Some(Self::Plan),
38            "new_task" | "newtask" | "new-task" => Some(Self::NewTask),
39            _ => None,
40        }
41    }
42
43    pub fn as_str(self) -> &'static str {
44        match self {
45            Self::Unknown => "unknown",
46            Self::Execute => "execute",
47            Self::WrapUp => "wrap_up",
48            Self::Plan => "plan",
49            Self::NewTask => "new_task",
50        }
51    }
52}
53
54/// Per-context state. Private counters prevent phase reports from replenishing
55/// the allowance. The host must preserve this state across model rounds.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub struct ContextState {
58    phase: WorkPhase,
59    plan_pending_execution: bool,
60    boundary_pending: bool,
61    rollover_due: bool,
62    rollover_required: bool,
63    finish_rounds_used: u32,
64    /// Host-recorded failed-to-shrink attempt: (input footprint, skipped rounds).
65    /// Advisory phases never clear this; hard admission is checked first.
66    unproductive_rollover: Option<(u64, u8)>,
67}
68
69impl ContextState {
70    pub fn phase(&self) -> WorkPhase {
71        self.phase
72    }
73
74    pub fn finish_rounds_used(&self) -> u32 {
75        self.finish_rounds_used
76    }
77
78    /// Record advisory progress. Intermediate Unknown/WrapUp reports cannot
79    /// hide a Plan -> Execute transition, nor can reports reset an extension.
80    pub fn report_phase(&mut self, phase: WorkPhase) {
81        match phase {
82            WorkPhase::Plan => self.plan_pending_execution = true,
83            WorkPhase::Execute if self.plan_pending_execution => {
84                self.boundary_pending = true;
85                self.plan_pending_execution = false;
86            }
87            WorkPhase::NewTask => self.boundary_pending = true,
88            _ => {}
89        }
90        self.phase = phase;
91    }
92
93    /// Host-only: candidate cannot meaningfully shrink, but the current full
94    /// request still passes hard admission. Keep task/finish state intact and
95    /// avoid a no-op archive/rollover loop. Reassess after bounded progress.
96    pub fn defer_unproductive_rollover(&mut self, used_tokens: u64) {
97        self.unproductive_rollover = Some((used_tokens, 0));
98    }
99
100    /// Host-only lifecycle operation after successful context replacement.
101    pub fn reset(&mut self) {
102        *self = Self::default();
103    }
104}
105
106/// Host-supplied, conservative accounting for the next request.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub struct ContextBudget {
109    pub context_window_tokens: u64,
110    /// Current full input footprint, including system/tool/summary overhead.
111    pub used_tokens: u64,
112    /// Remaining usable capacity according to the host/provider. May be less
113    /// than window - used; a larger value never enlarges the physical window.
114    pub hard_remaining_tokens: u64,
115    /// Required headroom for the next round, including output/thinking/tool
116    /// growth. Compared with (not added to) the configured minimum reserve.
117    pub required_next_round_tokens: u64,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct ContextThresholds {
122    pub pressure_tokens: u64,
123    pub rollover_tokens: u64,
124}
125
126impl ContextThresholds {
127    /// Anchored absolute soft bands: 140k/180k at 200k, 250k/400k at 1m.
128    /// Interpolate between anchors and cap above 1m. Smaller models scale
129    /// down to fit their window. A lone override preserves that explicit
130    /// value and moves the other default only if ordering requires it.
131    pub fn resolve(
132        config: &ContextManagementConfig,
133        context_window_tokens: u64,
134    ) -> Result<Self, &'static str> {
135        config.validate_for_window(context_window_tokens)?;
136        let (pressure_tokens, rollover_tokens) = Self::bands(config, context_window_tokens);
137        Ok(Self {
138            pressure_tokens,
139            rollover_tokens,
140        })
141    }
142
143    /// Unvalidated (pressure, rollover) after applying overrides. Callers
144    /// outside validation must use [`Self::resolve`].
145    pub(crate) fn bands(config: &ContextManagementConfig, window: u64) -> (u64, u64) {
146        let (pressure, rollover) = if window <= 200_000 {
147            ((window * 7 / 10).max(1), (window * 9 / 10).max(2))
148        } else if window < 1_000_000 {
149            let extra = window - 200_000;
150            (
151                140_000 + extra * 110_000 / 800_000,
152                180_000 + extra * 220_000 / 800_000,
153            )
154        } else {
155            (250_000, 400_000)
156        };
157        match (config.pressure_tokens, config.rollover_tokens) {
158            (Some(p), Some(r)) => (p, r),
159            (Some(p), None) => (p, rollover.max(p + 1)),
160            (None, Some(r)) => (pressure.min(r.saturating_sub(1)).max(1), r),
161            (None, None) => (pressure, rollover),
162        }
163    }
164
165    /// Footprint a rollover successor should aim for: the explicit
166    /// `target_tokens` (validated below pressure), otherwise
167    /// min(100,000, 2/3 of pressure) so small windows scale down and a fresh
168    /// successor never starts inside the pressure band.
169    pub fn target_tokens(&self, config: &ContextManagementConfig) -> u64 {
170        config
171            .target_tokens
172            .unwrap_or_else(|| DEFAULT_TARGET_TOKENS.min(self.pressure_tokens * 2 / 3))
173            .clamp(1, self.pressure_tokens.saturating_sub(1).max(1))
174    }
175}
176
177/// Default successor footprint: a rolled-over context starts at about 100k.
178pub const DEFAULT_TARGET_TOKENS: u64 = 100_000;
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum ContextBand {
182    Normal,
183    Pressure,
184    Rollover,
185    HardLimit,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum ContextAction {
190    Disabled,
191    Continue,
192    /// Preparation only; the current task is not forcibly interrupted.
193    Advisory,
194    /// Admit one Plan/WrapUp round. Count is the allowance AFTER this round.
195    FinishBounded {
196        rounds_remaining: u32,
197    },
198    /// Request host-controlled rollover at a safe boundary, before more work.
199    Rollover,
200    /// No further model round, even to finish a plan or generate a summary.
201    /// The host must recover without exceeding the supplied hard budget.
202    HardStop,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum ContextReason {
207    ModeOff,
208    BelowPressure,
209    PrepareForRollover,
210    TaskBoundary,
211    RolloverThreshold,
212    BoundedFinish,
213    FinishAllowanceExhausted,
214    RolloverPending,
215    UnproductiveRollover,
216    HardCapacity,
217    InvalidConfig,
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub struct ContextAssessment {
222    pub action: ContextAction,
223    pub band: ContextBand,
224    pub reason: ContextReason,
225    /// None when disabled, invalid, or already stopped by the hard budget.
226    pub thresholds: Option<ContextThresholds>,
227    /// Persist at the host's admission checkpoint, even if the action stops
228    /// work. Repeated assessments alone have no side effects.
229    pub next_state: ContextState,
230}
231
232/// Pure assessment, independent of UI, model prose, and tool authority.
233/// Hard capacity wins even in Off mode. Invalid programmatic configuration
234/// fails closed; config-file validation normally catches it before this call.
235pub fn assess_context(
236    config: &ContextManagementConfig,
237    state: &ContextState,
238    budget: ContextBudget,
239) -> ContextAssessment {
240    let mut assessment = ContextAssessment {
241        action: ContextAction::HardStop,
242        band: ContextBand::HardLimit,
243        reason: ContextReason::HardCapacity,
244        thresholds: None,
245        next_state: *state,
246    };
247    let remaining = budget.hard_remaining_tokens.min(
248        budget
249            .context_window_tokens
250            .saturating_sub(budget.used_tokens),
251    );
252    let required = budget.required_next_round_tokens.max(config.reserve_tokens);
253    if remaining == 0 || remaining < required {
254        return assessment;
255    }
256
257    // Consume boundary reports at the admission checkpoint, including Off
258    // mode, so an old low-pressure transition does not interrupt later work.
259    assessment.next_state.boundary_pending = false;
260    if config.mode == ContextManagementMode::Off {
261        assessment.action = ContextAction::Disabled;
262        assessment.band = ContextBand::Normal;
263        assessment.reason = ContextReason::ModeOff;
264        return assessment;
265    }
266    let Ok(thresholds) = ContextThresholds::resolve(config, budget.context_window_tokens) else {
267        assessment.reason = ContextReason::InvalidConfig;
268        return assessment;
269    };
270    assessment.thresholds = Some(thresholds);
271    assessment.band = if budget.used_tokens >= thresholds.rollover_tokens {
272        ContextBand::Rollover
273    } else if budget.used_tokens >= thresholds.pressure_tokens {
274        ContextBand::Pressure
275    } else {
276        ContextBand::Normal
277    };
278
279    // Hard admission and configuration validation above always win. A soft
280    // no-shrink result may admit work, never renew finish/time/cost allowances.
281    // Retry on changed footprint (growth OR reduction), otherwise after four
282    // provider admissions so a newly archivable tail is eventually reconsidered.
283    if let Some((used, skipped)) = state.unproductive_rollover {
284        if budget.used_tokens.abs_diff(used) < 8_192 && skipped < 4 {
285            assessment.next_state.unproductive_rollover = Some((used, skipped + 1));
286            assessment.action = ContextAction::Advisory;
287            assessment.reason = ContextReason::UnproductiveRollover;
288            return assessment;
289        }
290        assessment.next_state.unproductive_rollover = None;
291    }
292
293    if state.rollover_required {
294        assessment.action = ContextAction::Rollover;
295        assessment.reason = ContextReason::RolloverPending;
296        return assessment;
297    }
298    if budget.used_tokens >= thresholds.pressure_tokens
299        && (state.boundary_pending || state.phase == WorkPhase::NewTask)
300    {
301        assessment.next_state.rollover_required = true;
302        assessment.action = ContextAction::Rollover;
303        assessment.reason = ContextReason::TaskBoundary;
304        return assessment;
305    }
306    if assessment.band == ContextBand::Rollover || state.rollover_due {
307        // Token-estimate decreases, repeated reports, or mode toggles cannot
308        // renew an allowance once this context has entered rollover.
309        assessment.next_state.rollover_due = true;
310        if matches!(state.phase, WorkPhase::Plan | WorkPhase::WrapUp)
311            && state.finish_rounds_used < config.finish_rounds
312        {
313            assessment.next_state.finish_rounds_used += 1;
314            assessment.action = ContextAction::FinishBounded {
315                rounds_remaining: config.finish_rounds - assessment.next_state.finish_rounds_used,
316            };
317            assessment.reason = ContextReason::BoundedFinish;
318        } else {
319            assessment.next_state.rollover_required = true;
320            assessment.action = ContextAction::Rollover;
321            assessment.reason = if state.finish_rounds_used >= config.finish_rounds {
322                ContextReason::FinishAllowanceExhausted
323            } else {
324                ContextReason::RolloverThreshold
325            };
326        }
327    } else if assessment.band == ContextBand::Pressure {
328        assessment.action = ContextAction::Advisory;
329        assessment.reason = ContextReason::PrepareForRollover;
330    } else {
331        assessment.action = ContextAction::Continue;
332        assessment.reason = ContextReason::BelowPressure;
333    }
334    assessment
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    fn auto_config() -> ContextManagementConfig {
342        ContextManagementConfig {
343            mode: ContextManagementMode::Auto,
344            ..Default::default()
345        }
346    }
347
348    fn budget(window: u64, used: u64) -> ContextBudget {
349        ContextBudget {
350            context_window_tokens: window,
351            used_tokens: used,
352            hard_remaining_tokens: window.saturating_sub(used),
353            required_next_round_tokens: 8_000,
354        }
355    }
356
357    #[test]
358    fn unproductive_rollover_defers_only_soft_admission_and_retries_after_progress() {
359        let config = auto_config();
360        let mut state = ContextState::default();
361        state.report_phase(WorkPhase::NewTask);
362        state = assess_context(&config, &state, budget(1_000_000, 339_058)).next_state;
363        state.defer_unproductive_rollover(339_058);
364        for _ in 0..4 {
365            state.report_phase(WorkPhase::NewTask); // repeated model reports cannot clear cooldown
366            let next = assess_context(&config, &state, budget(1_000_000, 339_100));
367            assert_eq!(next.action, ContextAction::Advisory);
368            assert_eq!(next.reason, ContextReason::UnproductiveRollover);
369            state = next.next_state;
370        }
371        assert_eq!(
372            assess_context(&config, &state, budget(1_000_000, 339_100)).action,
373            ContextAction::Rollover
374        );
375        for changed in [329_000, 350_000] {
376            state.defer_unproductive_rollover(339_058);
377            assert_eq!(
378                assess_context(&config, &state, budget(1_000_000, changed)).action,
379                ContextAction::Rollover
380            );
381        }
382        state.reset();
383        assert_eq!(state.unproductive_rollover, None);
384        assert_eq!(state.phase(), WorkPhase::Unknown);
385    }
386
387    #[test]
388    fn unproductive_cooldown_cannot_bypass_hard_budget_or_renew_finish_allowance() {
389        let config = auto_config();
390        let mut state = ContextState::default();
391        state.report_phase(WorkPhase::Plan);
392        for _ in 0..config.finish_rounds {
393            state = assess_context(&config, &state, budget(1_000_000, 400_000)).next_state;
394        }
395        state = assess_context(&config, &state, budget(1_000_000, 400_000)).next_state;
396        state.defer_unproductive_rollover(400_000);
397        state.report_phase(WorkPhase::WrapUp);
398        let off = ContextManagementConfig {
399            mode: ContextManagementMode::Off,
400            ..config
401        };
402        state = assess_context(&off, &state, budget(1_000_000, 400_000)).next_state;
403        assert_eq!(state.unproductive_rollover, Some((400_000, 0)));
404        assert_eq!(state.finish_rounds_used(), config.finish_rounds);
405        let denied = assess_context(
406            &config,
407            &state,
408            ContextBudget {
409                hard_remaining_tokens: 0,
410                ..budget(1_000_000, 400_000)
411            },
412        );
413        assert_eq!(denied.action, ContextAction::HardStop);
414        assert_eq!(denied.next_state, state);
415        assert_eq!(
416            assess_context(&config, &state, budget(410_000, 400_000)).action,
417            ContextAction::HardStop
418        );
419        let allowed = assess_context(&config, &state, budget(1_000_000, 400_000));
420        assert_eq!(allowed.reason, ContextReason::UnproductiveRollover);
421        assert_eq!(
422            allowed.next_state.finish_rounds_used(),
423            config.finish_rounds
424        );
425    }
426
427    #[test]
428    fn successor_target_defaults_to_100k_and_scales_below_small_pressure() {
429        let config = auto_config();
430        for (window, target) in [
431            (1_000_000, 100_000),
432            (2_000_000, 100_000),
433            (200_000, 93_333),
434            (32_000, 14_933),
435        ] {
436            let t = ContextThresholds::resolve(&config, window).unwrap();
437            assert_eq!(t.target_tokens(&config), target, "window {window}");
438            assert!(t.target_tokens(&config) < t.pressure_tokens);
439        }
440        let explicit = ContextManagementConfig {
441            target_tokens: Some(120_000),
442            ..config
443        };
444        let t = ContextThresholds::resolve(&explicit, 1_000_000).unwrap();
445        assert_eq!(t.target_tokens(&explicit), 120_000);
446        for bad in [Some(0), Some(250_000), Some(300_000)] {
447            let invalid = ContextManagementConfig {
448                target_tokens: bad,
449                ..config
450            };
451            assert!(
452                ContextThresholds::resolve(&invalid, 1_000_000).is_err(),
453                "{bad:?}"
454            );
455        }
456        // Explicit target must clear the effective (scaled) pressure too.
457        let small = ContextManagementConfig {
458            target_tokens: Some(100_000),
459            ..config
460        };
461        assert!(ContextThresholds::resolve(&small, 128_000).is_err());
462    }
463
464    #[test]
465    fn context_threshold_anchors_and_pressure_are_advisory() {
466        let config = auto_config();
467        for (window, pressure, rollover) in [
468            (200_000, 140_000, 180_000),
469            (1_000_000, 250_000, 400_000),
470            (2_000_000, 250_000, 400_000),
471        ] {
472            assert_eq!(
473                ContextThresholds::resolve(&config, window).unwrap(),
474                ContextThresholds {
475                    pressure_tokens: pressure,
476                    rollover_tokens: rollover
477                }
478            );
479            let mut state = ContextState::default();
480            state.report_phase(WorkPhase::Execute);
481            assert_eq!(
482                assess_context(&config, &state, budget(window, pressure - 1)).action,
483                ContextAction::Continue
484            );
485            let at_pressure = assess_context(&config, &state, budget(window, pressure));
486            assert_eq!(at_pressure.action, ContextAction::Advisory);
487            assert_eq!(at_pressure.band, ContextBand::Pressure);
488            assert_eq!(
489                assess_context(&config, &state, budget(window, rollover - 1)).action,
490                ContextAction::Advisory
491            );
492            assert_eq!(
493                assess_context(&config, &state, budget(window, rollover)).action,
494                ContextAction::Rollover
495            );
496        }
497    }
498
499    #[test]
500    fn context_350k_plan_finishes_before_execute_requests_rollover() {
501        let config = auto_config();
502        let current_budget = budget(1_000_000, 350_000);
503        for phase in [WorkPhase::Execute, WorkPhase::WrapUp, WorkPhase::Plan] {
504            let mut state = ContextState::default();
505            state.report_phase(phase);
506            assert_eq!(
507                assess_context(&config, &state, current_budget).action,
508                ContextAction::Advisory
509            );
510        }
511        let mut state = ContextState::default();
512        state.report_phase(WorkPhase::Plan);
513        state = assess_context(&config, &state, current_budget).next_state;
514        state.report_phase(WorkPhase::Unknown);
515        state.report_phase(WorkPhase::WrapUp);
516        state.report_phase(WorkPhase::Execute);
517        state.report_phase(WorkPhase::Plan); // cannot hide the boundary
518        let assessment = assess_context(&config, &state, current_budget);
519        assert_eq!(assessment.action, ContextAction::Rollover);
520        assert_eq!(assessment.reason, ContextReason::TaskBoundary);
521        state = assessment.next_state;
522        state.report_phase(WorkPhase::WrapUp);
523        assert_eq!(
524            assess_context(&config, &state, current_budget).action,
525            ContextAction::Rollover
526        );
527        state.reset();
528        assert_eq!(
529            assess_context(&config, &state, budget(1_000_000, 0)).action,
530            ContextAction::Continue
531        );
532    }
533
534    #[test]
535    fn context_new_task_rolls_over_at_pressure_not_before() {
536        let config = auto_config();
537        let mut state = ContextState::default();
538        state.report_phase(WorkPhase::NewTask);
539        assert_eq!(
540            assess_context(&config, &state, budget(1_000_000, 249_999)).action,
541            ContextAction::Continue
542        );
543        assert_eq!(
544            assess_context(&config, &state, budget(1_000_000, 250_000)).action,
545            ContextAction::Rollover
546        );
547    }
548
549    #[test]
550    fn context_low_pressure_boundary_is_consumed_at_admission() {
551        let config = auto_config();
552        let mut state = ContextState::default();
553        state.report_phase(WorkPhase::Plan);
554        state.report_phase(WorkPhase::Execute);
555        state = assess_context(&config, &state, budget(1_000_000, 100_000)).next_state;
556        assert_eq!(
557            assess_context(&config, &state, budget(1_000_000, 350_000)).action,
558            ContextAction::Advisory
559        );
560    }
561
562    #[test]
563    fn context_400k_finish_is_bounded_across_reports_and_estimate_decreases() {
564        let config = auto_config();
565        for phase in [WorkPhase::Plan, WorkPhase::WrapUp] {
566            let mut state = ContextState::default();
567            state.report_phase(phase);
568            let first = assess_context(&config, &state, budget(1_000_000, 400_000));
569            assert_eq!(
570                first.action,
571                ContextAction::FinishBounded {
572                    rounds_remaining: 1
573                }
574            );
575            assert_eq!(state.finish_rounds_used(), 0); // assessment itself is pure
576            assert_eq!(
577                first,
578                assess_context(&config, &state, budget(1_000_000, 400_000))
579            );
580            state = first.next_state;
581            state.report_phase(WorkPhase::Unknown);
582            state.report_phase(phase);
583            let second = assess_context(&config, &state, budget(1_000_000, 350_000));
584            assert_eq!(
585                second.action,
586                ContextAction::FinishBounded {
587                    rounds_remaining: 0
588                }
589            );
590            state = second.next_state;
591            assert_eq!(state.finish_rounds_used(), 2);
592            state.report_phase(phase);
593            let exhausted = assess_context(&config, &state, budget(1_000_000, 100_000));
594            assert_eq!(exhausted.action, ContextAction::Rollover);
595            assert_eq!(exhausted.reason, ContextReason::FinishAllowanceExhausted);
596            state = exhausted.next_state;
597            state.report_phase(WorkPhase::Plan);
598            assert_eq!(
599                assess_context(&config, &state, budget(1_000_000, 100_000)).action,
600                ContextAction::Rollover
601            );
602            state.reset();
603            assert_eq!(state.finish_rounds_used(), 0);
604            assert_eq!(state.phase(), WorkPhase::Unknown);
605        }
606    }
607
608    #[test]
609    fn context_finish_override_is_bounded_including_zero() {
610        for limit in [0, 1, ContextManagementConfig::MAX_FINISH_ROUNDS] {
611            let config = ContextManagementConfig {
612                finish_rounds: limit,
613                ..auto_config()
614            };
615            let mut state = ContextState::default();
616            state.report_phase(WorkPhase::WrapUp);
617            for used in 0..limit {
618                let assessment = assess_context(&config, &state, budget(1_000_000, 400_000));
619                assert_eq!(
620                    assessment.action,
621                    ContextAction::FinishBounded {
622                        rounds_remaining: limit - used - 1
623                    }
624                );
625                state = assessment.next_state;
626            }
627            assert_eq!(
628                assess_context(&config, &state, budget(1_000_000, 400_000)).action,
629                ContextAction::Rollover
630            );
631        }
632    }
633
634    #[test]
635    fn context_off_is_default_and_mode_toggles_do_not_replenish_finish() {
636        let off = ContextManagementConfig::default();
637        let mut state = ContextState::default();
638        state.report_phase(WorkPhase::WrapUp);
639        assert_eq!(
640            assess_context(&off, &state, budget(1_000_000, 400_000)).action,
641            ContextAction::Disabled
642        );
643        state = assess_context(&auto_config(), &state, budget(1_000_000, 400_000)).next_state;
644        state = assess_context(&off, &state, budget(1_000_000, 410_000)).next_state;
645        assert_eq!(state.finish_rounds_used(), 1);
646        let second = assess_context(&auto_config(), &state, budget(1_000_000, 420_000));
647        assert_eq!(
648            second.action,
649            ContextAction::FinishBounded {
650                rounds_remaining: 0
651            }
652        );
653        assert_eq!(
654            assess_context(
655                &auto_config(),
656                &second.next_state,
657                budget(1_000_000, 420_000)
658            )
659            .action,
660            ContextAction::Rollover
661        );
662    }
663
664    #[test]
665    fn context_hard_capacity_wins_for_every_phase_and_mode() {
666        for mode in [ContextManagementMode::Off, ContextManagementMode::Auto] {
667            let config = ContextManagementConfig {
668                mode,
669                ..Default::default()
670            };
671            for phase in [
672                WorkPhase::Unknown,
673                WorkPhase::Execute,
674                WorkPhase::WrapUp,
675                WorkPhase::Plan,
676                WorkPhase::NewTask,
677            ] {
678                let mut state = ContextState::default();
679                state.report_phase(phase);
680                for hard_budget in [
681                    ContextBudget {
682                        hard_remaining_tokens: 15_999,
683                        ..budget(1_000_000, 350_000)
684                    },
685                    ContextBudget {
686                        required_next_round_tokens: 650_001,
687                        ..budget(1_000_000, 350_000)
688                    },
689                    ContextBudget {
690                        hard_remaining_tokens: u64::MAX,
691                        ..budget(200_000, 190_000)
692                    },
693                    budget(200_000, 200_001),
694                    budget(0, 0),
695                ] {
696                    let assessment = assess_context(&config, &state, hard_budget);
697                    assert_eq!(
698                        assessment.action,
699                        ContextAction::HardStop,
700                        "{mode:?} {phase:?} {hard_budget:?}"
701                    );
702                    assert_eq!(assessment.band, ContextBand::HardLimit);
703                    assert_eq!(assessment.reason, ContextReason::HardCapacity);
704                    assert_eq!(assessment.next_state, state);
705                }
706            }
707        }
708    }
709
710    #[test]
711    fn context_exact_reserve_is_allowed_and_next_round_can_require_more() {
712        let config = auto_config();
713        let state = ContextState::default();
714        let exact = ContextBudget {
715            hard_remaining_tokens: 16_000,
716            ..budget(200_000, 140_000)
717        };
718        assert_eq!(
719            assess_context(&config, &state, exact).action,
720            ContextAction::Advisory
721        );
722        let larger = ContextBudget {
723            required_next_round_tokens: 16_001,
724            ..exact
725        };
726        assert_eq!(
727            assess_context(&config, &state, larger).action,
728            ContextAction::HardStop
729        );
730    }
731
732    #[test]
733    fn context_threshold_overrides_preserve_explicit_values() {
734        for (pressure, rollover, expected) in [
735            (Some(350_000), Some(500_000), (350_000, 500_000)),
736            (Some(450_000), None, (450_000, 450_001)),
737            (None, Some(200_000), (199_999, 200_000)),
738        ] {
739            let config = ContextManagementConfig {
740                pressure_tokens: pressure,
741                rollover_tokens: rollover,
742                ..auto_config()
743            };
744            let thresholds = ContextThresholds::resolve(&config, 1_000_000).unwrap();
745            assert_eq!(
746                (thresholds.pressure_tokens, thresholds.rollover_tokens),
747                expected
748            );
749        }
750        assert!(ContextThresholds::resolve(&auto_config(), u64::MAX).is_ok());
751        assert_eq!(
752            ContextThresholds::resolve(&auto_config(), 100_000)
753                .unwrap()
754                .pressure_tokens,
755            70_000
756        );
757        assert_eq!(
758            ContextThresholds::resolve(&auto_config(), 600_000)
759                .unwrap()
760                .rollover_tokens,
761            290_000
762        );
763    }
764
765    #[test]
766    fn context_invalid_programmatic_config_fails_closed() {
767        for config in [
768            ContextManagementConfig {
769                pressure_tokens: Some(0),
770                ..auto_config()
771            },
772            ContextManagementConfig {
773                pressure_tokens: Some(u64::MAX),
774                ..auto_config()
775            },
776            ContextManagementConfig {
777                rollover_tokens: Some(1_000_001),
778                ..auto_config()
779            },
780            ContextManagementConfig {
781                finish_rounds: 9,
782                ..auto_config()
783            },
784            ContextManagementConfig {
785                reserve_tokens: 0,
786                ..auto_config()
787            },
788        ] {
789            let assessment = assess_context(
790                &config,
791                &ContextState::default(),
792                budget(1_000_000, 350_000),
793            );
794            assert_eq!(assessment.action, ContextAction::HardStop);
795            assert_eq!(assessment.reason, ContextReason::InvalidConfig);
796        }
797    }
798
799    #[test]
800    fn context_work_phase_roundtrips_and_rejects_unknown_reports() {
801        for phase in [
802            WorkPhase::Unknown,
803            WorkPhase::Execute,
804            WorkPhase::WrapUp,
805            WorkPhase::Plan,
806            WorkPhase::NewTask,
807        ] {
808            assert_eq!(WorkPhase::parse(phase.as_str()), Some(phase));
809        }
810        assert_eq!(WorkPhase::parse(" PLAN "), Some(WorkPhase::Plan));
811        assert_eq!(WorkPhase::parse("wrap-up"), Some(WorkPhase::WrapUp));
812        assert_eq!(WorkPhase::parse("new-task"), Some(WorkPhase::NewTask));
813        assert_eq!(WorkPhase::parse("finish_forever"), None);
814    }
815}