Skip to main content

deepstrike_core/context/
renewal.rs

1use serde::{Deserialize, Serialize};
2
3use super::config::ContextConfig;
4use super::partitions::ContextPartitions;
5use super::pressure::PressureMonitor;
6use super::token_engine::ContextTokenEngine;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ContractCheckResult {
10    pub criterion_id: String,
11    pub passed: bool,
12    pub evidence: Option<String>,
13}
14
15/// Structured state passed between sprints.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct HandoffArtifact {
18    pub goal: String,
19    pub sprint: u32,
20    pub progress_summary: String,
21    pub open_tasks: Vec<String>,
22    pub context_snapshot: serde_json::Value,
23    #[serde(default)]
24    pub contract_status: Vec<ContractCheckResult>,
25    #[serde(default)]
26    pub drift_rate_24h: f64,
27    #[serde(default)]
28    pub blocked_on: Vec<String>,
29}
30
31pub struct RenewalPolicy {
32    pub renewal_threshold: f64,
33    pub carryover_ratio: f64,
34}
35
36impl RenewalPolicy {
37    pub fn from_config(config: &ContextConfig) -> Self {
38        Self {
39            renewal_threshold: config.renewal_threshold,
40            carryover_ratio: config.carryover_ratio,
41        }
42    }
43
44    pub fn should_renew(
45        &self,
46        monitor: &PressureMonitor,
47        partitions: &ContextPartitions,
48        engine: &ContextTokenEngine,
49    ) -> bool {
50        monitor.pressure(partitions, engine, None) > self.renewal_threshold
51    }
52
53    /// Perform renewal: carry system + knowledge + task_state into new sprint.
54    /// History is reset; only the last `carryover_tokens` worth of turns are kept.
55    /// Signals are cleared (they are per-turn ephemeral).
56    pub fn renew(
57        &self,
58        partitions: &ContextPartitions,
59        goal: &str,
60        sprint: u32,
61        max_tokens: u32,
62    ) -> (ContextPartitions, HandoffArtifact) {
63        let config = ContextConfig {
64            carryover_ratio: self.carryover_ratio,
65            renewal_threshold: self.renewal_threshold,
66            ..Default::default()
67        };
68        let mut renewed = ContextPartitions::new(&config);
69
70        // Identity and Knowledge slots carry over unchanged.
71        for msg in &partitions.system.messages {
72            renewed.system.push(msg.clone(), msg.token_count.unwrap_or(0));
73        }
74        for msg in &partitions.knowledge.messages {
75            renewed.knowledge.push(msg.clone(), msg.token_count.unwrap_or(0));
76        }
77
78        // State: carry task_state (goal/plan/progress), clear scratchpad.
79        renewed.task_state = partitions.task_state.clone();
80        renewed.task_state.scratchpad.clear();
81        // Signals are ephemeral — not carried over.
82
83        // History: carry recent turns up to carryover budget.
84        let carryover_budget = config.carryover_tokens(max_tokens);
85        let mut remaining = carryover_budget;
86        let mut carried: Vec<_> = partitions.history.messages.iter().rev()
87            .take_while(|msg| {
88                let t = msg.token_count.unwrap_or(0);
89                if t <= remaining { remaining = remaining.saturating_sub(t); true } else { false }
90            })
91            .cloned()
92            .collect();
93        carried.reverse();
94        for msg in carried {
95            let t = msg.token_count.unwrap_or(0);
96            renewed.history.push(msg, t);
97        }
98
99        let artifact = HandoffArtifact {
100            goal: goal.to_string(),
101            sprint,
102            progress_summary: partitions.task_state.progress.clone(),
103            open_tasks: partitions.task_state.open_steps(),
104            context_snapshot: serde_json::json!({
105                "history_len": partitions.history.messages.len(),
106                "knowledge_len": partitions.knowledge.messages.len(),
107            }),
108            contract_status: Vec::new(),
109            drift_rate_24h: 0.0,
110            blocked_on: partitions.task_state.blocked_on.clone(),
111        };
112
113        (renewed, artifact)
114    }
115}
116
117impl Default for RenewalPolicy {
118    fn default() -> Self { Self::from_config(&ContextConfig::default()) }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::context::config::ContextConfig;
125    use crate::context::partitions::ContextPartitions;
126    use crate::context::task_state::TaskState;
127    use crate::types::message::Message;
128
129    fn make_policy(carryover_ratio: f64) -> RenewalPolicy {
130        RenewalPolicy::from_config(&ContextConfig { carryover_ratio, ..Default::default() })
131    }
132
133    #[test]
134    fn renewal_preserves_system_and_knowledge() {
135        let cfg = ContextConfig::default();
136        let mut ctx = ContextPartitions::new(&cfg);
137        ctx.system.push(Message::system("rules"), 10);
138        ctx.knowledge.push(Message::system("skill: debug"), 20);
139        let (renewed, _) = make_policy(0.05).renew(&ctx, "goal", 0, 1_000);
140        assert_eq!(renewed.system.len(), 1);
141        assert_eq!(renewed.knowledge.len(), 1);
142    }
143
144    #[test]
145    fn renewal_clears_signals() {
146        let cfg = ContextConfig::default();
147        let mut ctx = ContextPartitions::new(&cfg);
148        ctx.signals.push("[ROLLBACK] failed".to_string());
149        let (renewed, _) = make_policy(0.05).renew(&ctx, "goal", 0, 1_000);
150        assert!(renewed.signals.is_empty());
151    }
152
153    #[test]
154    fn carryover_respects_token_budget() {
155        let cfg = ContextConfig::default();
156        let mut ctx = ContextPartitions::new(&cfg);
157        for i in 0..10 {
158            ctx.history.push(Message::user(format!("msg {i}")), 100);
159        }
160        let (renewed, _) = make_policy(0.05).renew(&ctx, "goal", 0, 1_000);
161        assert!(renewed.history.token_count <= 100);
162    }
163
164    #[test]
165    fn renewal_clears_task_scratchpad_keeps_goal() {
166        let cfg = ContextConfig::default();
167        let mut ctx = ContextPartitions::new(&cfg);
168        ctx.task_state = TaskState {
169            goal: "build".to_string(),
170            scratchpad: "temp data".to_string(),
171            ..Default::default()
172        };
173        let (renewed, artifact) = make_policy(0.05).renew(&ctx, "build", 0, 1_000);
174        assert_eq!(renewed.task_state.goal, "build");
175        assert!(renewed.task_state.scratchpad.is_empty());
176        assert_eq!(artifact.goal, "build");
177    }
178}