Skip to main content

deepstrike_core/context/
task_state.rs

1use serde::{Deserialize, Serialize};
2
3/// One entry in the compression log — records what happened at each compression event.
4/// All tiers write here; the log is append-only and never overwritten.
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct CompressionEntry {
7    /// Compression tier label: snip_compact | micro_compact | context_collapse | auto_compact
8    pub action: String,
9    /// Human-readable summary (tool names, message counts, token counts).
10    /// Empty for Snip/Micro which only record truncation stats.
11    pub summary: String,
12}
13
14/// Persistent task state that lives in the working partition.
15/// Survives compression, renewal, and wake/resume cycles because the working
16/// partition is `compressible = false`.
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18pub struct TaskState {
19    /// Primary objective for this run. Set at `run_started`, immutable thereafter.
20    pub goal: String,
21    /// Acceptance criteria copied from `RunStarted`.
22    pub criteria: Vec<String>,
23    /// Ordered plan steps.
24    pub plan: Vec<PlanStep>,
25    /// Index of the step currently executing (0-based). None before planning.
26    pub current_step: Option<usize>,
27    /// Free-text progress note updated after each significant action.
28    pub progress: String,
29    /// Ephemeral scratch space for model use. Cleared on renewal. NOT used by the
30    /// compression pipeline (use compression_log instead).
31    pub scratchpad: String,
32    /// Reasons the current step cannot proceed.
33    pub blocked_on: Vec<String>,
34    /// Explicit durable user directives / standing constraints (e.g. "don't do X"). Unlike
35    /// runtime signals, these are intentionally persisted across compression and renewal.
36    /// Bounded + recency-ordered (oldest dropped past [`MAX_DIRECTIVES`]); newest last.
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub directives: Vec<String>,
39    /// Call IDs or artifact hashes that must be preserved from compression.
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub preserved_refs: Vec<String>,
42    /// Rolling log of recent *task* activity — one entry per turn, each a compact summary of that
43    /// turn's tool calls (e.g. "module_read, module_list"). Kernel-maintained from REAL tool
44    /// activity (not model-curated), so the State turn always shows forward motion even when the
45    /// model never maintains `plan`. Lives in the volatile State turn (out of the cacheable prefix),
46    /// so updating it never churns the prompt cache. Bounded + recency-ordered; newest last.
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub recent_actions: Vec<String>,
49    /// Rolling log of compression events, newest last. Bounded at [`MAX_COMPRESSION_LOG`]
50    /// (oldest dropped past the cap, counted in `compression_log_dropped`).
51    /// Rendered into systemVolatile so the model always sees compression history.
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub compression_log: Vec<CompressionEntry>,
54    /// Entries dropped from `compression_log` past its cap — kept so the render stays honest
55    /// about how much history is no longer visible.
56    #[serde(default, skip_serializing_if = "u64_is_zero")]
57    pub compression_log_dropped: u64,
58}
59
60fn u64_is_zero(value: &u64) -> bool {
61    *value == 0
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct PlanStep {
66    pub label: String,
67    pub done: bool,
68}
69
70impl PlanStep {
71    pub fn new(label: impl Into<String>) -> Self {
72        Self {
73            label: label.into(),
74            done: false,
75        }
76    }
77}
78
79/// Maximum durable directives retained; past this the oldest is dropped (recency window).
80pub const MAX_DIRECTIVES: usize = 8;
81
82/// Maximum recent action-turns retained for the recency footer (bounded ring).
83pub const MAX_RECENT_ACTIONS: usize = 6;
84
85/// Compression-log entries retained in state (rolling window; render shows the last 3).
86pub const MAX_COMPRESSION_LOG: usize = 64;
87
88/// Partial update applied by the SDK or via `update_plan` meta-tool.
89#[derive(Debug, Clone, Default, Serialize, Deserialize)]
90pub struct TaskUpdate {
91    pub plan: Option<Vec<String>>,
92    pub current_step: Option<usize>,
93    pub progress: Option<String>,
94    pub scratchpad: Option<String>,
95    pub blocked_on: Option<Vec<String>>,
96    pub preserved_refs: Option<Vec<String>>,
97    /// Replace the durable directive list wholesale (SDK/model curation).
98    pub directives: Option<Vec<String>>,
99}
100
101impl TaskState {
102    /// Compact text block for embedding in `system_text`.
103    /// Returns an empty string when the task has not been initialised.
104    pub fn format_compact(&self) -> String {
105        if self.goal.is_empty() && self.plan.is_empty() && self.progress.is_empty() {
106            return String::new();
107        }
108
109        let mut lines = Vec::new();
110        lines.push(format!("[TASK STATE] goal: {}", self.goal));
111
112        if !self.criteria.is_empty() {
113            lines.push(format!("criteria: {}", self.criteria.join(" | ")));
114        }
115
116        // Active directives render right after the goal — highest salience after the objective, so
117        // a recent user command keeps its imperative force across compaction/renewal.
118        if !self.directives.is_empty() {
119            lines.push("active_directives (most recent last):".to_string());
120            for d in &self.directives {
121                lines.push(format!("  - {d}"));
122            }
123        }
124
125        if !self.plan.is_empty() {
126            lines.push("plan:".to_string());
127            for (i, step) in self.plan.iter().enumerate() {
128                let marker = if step.done {
129                    "done"
130                } else if Some(i) == self.current_step {
131                    "active"
132                } else {
133                    "todo"
134                };
135                lines.push(format!("  [{}] {}. {}", marker, i + 1, step.label));
136            }
137        }
138
139        if !self.progress.is_empty() {
140            lines.push(format!("progress: {}", self.progress));
141        }
142
143        if !self.blocked_on.is_empty() {
144            lines.push(format!("blocked_on: {}", self.blocked_on.join(", ")));
145        }
146
147        if !self.scratchpad.is_empty() {
148            lines.push(format!("scratchpad: {}", self.scratchpad));
149        }
150
151        // Render only the most recent compression events (fixed cap of 3). A wider budgeted
152        // window was tried and withdrawn: live A/B at n≤4 (12-PR review @ 2048, DeepSeek)
153        // could not distinguish it from provider drift — a control run on the unmodified
154        // kernel showed the same failures — and a window full of old `tool X args` digest
155        // lines is plausible re-execution bait. Absent replay-lab evidence FOR widening, the
156        // proven shape stays. Older digests remain in `compression_log` (bounded at
157        // [`MAX_COMPRESSION_LOG`]); making their *content* durably useful to the model is
158        // semantic-summary (P2) territory, not a bigger raw window.
159        if !self.compression_log.is_empty() {
160            lines.push("compression_history:".to_string());
161            let start = self.compression_log.len().saturating_sub(3);
162            for entry in &self.compression_log[start..] {
163                if entry.summary.is_empty() {
164                    lines.push(format!("  [{}]", entry.action));
165                } else {
166                    lines.push(format!("  [{}] {}", entry.action, entry.summary));
167                }
168            }
169        }
170
171        lines.join("\n")
172    }
173
174    /// Record a durable user directive (deduped against the most recent, recency-capped at
175    /// [`MAX_DIRECTIVES`]). Newest is appended last; the oldest is dropped past the cap so the
176    /// channel stays bounded across a long session.
177    pub fn record_directive(&mut self, text: impl Into<String>) {
178        let text = text.into();
179        if text.trim().is_empty() {
180            return;
181        }
182        // Re-issuing the same directive moves it to most-recent rather than duplicating.
183        self.directives.retain(|d| d != &text);
184        self.directives.push(text);
185        if self.directives.len() > MAX_DIRECTIVES {
186            let overflow = self.directives.len() - MAX_DIRECTIVES;
187            self.directives.drain(0..overflow);
188        }
189    }
190
191    /// Record one turn's tool activity into the recency log (kernel-driven). `summary` is a compact
192    /// string of the turn's task tool names; blank input is ignored. Bounded at
193    /// [`MAX_RECENT_ACTIONS`] (oldest dropped past the cap).
194    pub fn note_actions(&mut self, summary: impl Into<String>) {
195        let summary = summary.into();
196        if summary.trim().is_empty() {
197            return;
198        }
199        self.recent_actions.push(summary);
200        if self.recent_actions.len() > MAX_RECENT_ACTIONS {
201            let overflow = self.recent_actions.len() - MAX_RECENT_ACTIONS;
202            self.recent_actions.drain(0..overflow);
203        }
204    }
205
206    /// Append a compression event to the log (bounded at [`MAX_COMPRESSION_LOG`]; the oldest
207    /// entries are dropped past the cap and counted so the render can report them).
208    pub fn log_compression(&mut self, action: &str, summary: String) {
209        self.compression_log.push(CompressionEntry {
210            action: action.to_string(),
211            summary,
212        });
213        if self.compression_log.len() > MAX_COMPRESSION_LOG {
214            let overflow = self.compression_log.len() - MAX_COMPRESSION_LOG;
215            self.compression_log.drain(0..overflow);
216            self.compression_log_dropped += overflow as u64;
217        }
218    }
219
220    pub fn apply(&mut self, update: TaskUpdate) {
221        if let Some(plan) = update.plan {
222            self.plan = plan.into_iter().map(PlanStep::new).collect();
223        }
224        if let Some(step) = update.current_step {
225            self.current_step = Some(step);
226        }
227        if let Some(p) = update.progress {
228            self.progress = p;
229        }
230        if let Some(s) = update.scratchpad {
231            self.scratchpad = s;
232        }
233        if let Some(b) = update.blocked_on {
234            self.blocked_on = b;
235        }
236        if let Some(r) = update.preserved_refs {
237            self.preserved_refs = r;
238        }
239        if let Some(d) = update.directives {
240            self.directives = d;
241            if self.directives.len() > MAX_DIRECTIVES {
242                let overflow = self.directives.len() - MAX_DIRECTIVES;
243                self.directives.drain(0..overflow);
244            }
245        }
246    }
247
248    /// Open steps (not yet done), for renewal handoff.
249    pub fn open_steps(&self) -> Vec<String> {
250        self.plan
251            .iter()
252            .filter(|s| !s.done)
253            .map(|s| s.label.clone())
254            .collect()
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn empty_state_compact_is_empty_string() {
264        assert_eq!(TaskState::default().format_compact(), "");
265    }
266
267    #[test]
268    fn goal_only_renders() {
269        let ts = TaskState {
270            goal: "Build it".to_string(),
271            ..Default::default()
272        };
273        let s = ts.format_compact();
274        assert!(s.contains("[TASK STATE] goal: Build it"));
275    }
276
277    #[test]
278    fn plan_markers_correct() {
279        let ts = TaskState {
280            goal: "g".to_string(),
281            plan: vec![
282                PlanStep {
283                    label: "step1".to_string(),
284                    done: true,
285                },
286                PlanStep {
287                    label: "step2".to_string(),
288                    done: false,
289                },
290                PlanStep {
291                    label: "step3".to_string(),
292                    done: false,
293                },
294            ],
295            current_step: Some(1),
296            ..Default::default()
297        };
298        let s = ts.format_compact();
299        assert!(s.contains("[done] 1. step1"));
300        assert!(s.contains("[active] 2. step2"));
301        assert!(s.contains("[todo] 3. step3"));
302    }
303
304    #[test]
305    fn open_steps_excludes_done() {
306        let ts = TaskState {
307            goal: "g".to_string(),
308            plan: vec![
309                PlanStep {
310                    label: "a".to_string(),
311                    done: true,
312                },
313                PlanStep {
314                    label: "b".to_string(),
315                    done: false,
316                },
317            ],
318            ..Default::default()
319        };
320        assert_eq!(ts.open_steps(), vec!["b"]);
321    }
322
323    #[test]
324    fn record_directive_dedups_caps_and_orders_by_recency() {
325        let mut ts = TaskState::default();
326        ts.record_directive("don't touch the db schema");
327        ts.record_directive("use 2-space indent");
328        // Re-issuing moves to most-recent, no duplicate.
329        ts.record_directive("don't touch the db schema");
330        assert_eq!(
331            ts.directives,
332            ["use 2-space indent", "don't touch the db schema"]
333        );
334
335        // Bounded at MAX_DIRECTIVES — oldest dropped.
336        let mut ts = TaskState::default();
337        for i in 0..(MAX_DIRECTIVES + 3) {
338            ts.record_directive(format!("rule {i}"));
339        }
340        assert_eq!(ts.directives.len(), MAX_DIRECTIVES);
341        assert_eq!(ts.directives.first().unwrap(), "rule 3"); // 0..2 dropped
342        assert_eq!(
343            ts.directives.last().unwrap(),
344            &format!("rule {}", MAX_DIRECTIVES + 2)
345        );
346
347        // Blank is ignored.
348        let mut ts = TaskState::default();
349        ts.record_directive("  ");
350        assert!(ts.directives.is_empty());
351    }
352
353    #[test]
354    fn directives_render_after_goal() {
355        let mut ts = TaskState {
356            goal: "ship it".to_string(),
357            ..Default::default()
358        };
359        ts.record_directive("don't break the public API");
360        let s = ts.format_compact();
361        assert!(s.contains("active_directives"));
362        assert!(s.contains("- don't break the public API"));
363        // Renders after the goal line.
364        assert!(s.find("goal: ship it").unwrap() < s.find("don't break the public API").unwrap());
365    }
366
367    #[test]
368    fn apply_replaces_directives_and_caps() {
369        let mut ts = TaskState::default();
370        ts.apply(TaskUpdate {
371            directives: Some((0..(MAX_DIRECTIVES + 2)).map(|i| format!("d{i}")).collect()),
372            ..Default::default()
373        });
374        assert_eq!(ts.directives.len(), MAX_DIRECTIVES);
375    }
376
377    #[test]
378    fn compression_render_shows_exactly_the_last_three_digests() {
379        // Pin the 3-entry window (see format_compact for why widening was withdrawn) so a
380        // future widening must re-justify itself with replay-lab evidence.
381        let mut ts = TaskState::default();
382        ts.goal = "review PRs".into();
383        for n in 1..=10 {
384            ts.log_compression("auto_compact", format!("digest {n}"));
385        }
386        let rendered = ts.format_compact();
387        assert!(!rendered.contains("digest 7\n"));
388        assert!(rendered.contains("digest 8"));
389        assert!(rendered.contains("digest 9"));
390        assert!(rendered.contains("digest 10"));
391    }
392
393    #[test]
394    fn compression_log_is_bounded_and_counts_drops() {
395        let mut ts = TaskState::default();
396        for n in 0..(MAX_COMPRESSION_LOG + 5) {
397            ts.log_compression("micro_compact", format!("d{n}"));
398        }
399        assert_eq!(ts.compression_log.len(), MAX_COMPRESSION_LOG);
400        assert_eq!(ts.compression_log_dropped, 5);
401        assert_eq!(ts.compression_log[0].summary, "d5");
402    }
403
404    #[test]
405    fn apply_updates_fields() {
406        let mut ts = TaskState::default();
407        ts.apply(TaskUpdate {
408            progress: Some("half done".to_string()),
409            blocked_on: Some(vec!["waiting for data".to_string()]),
410            ..Default::default()
411        });
412        assert_eq!(ts.progress, "half done");
413        assert_eq!(ts.blocked_on, ["waiting for data"]);
414    }
415}