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    /// Durable user directives / standing constraints (e.g. mid-task corrections, "don't do X").
35    /// Promoted here from the *ephemeral* signal channel so they survive compression AND renewal
36    /// like the goal does — without this, the most recent user command loses salience exactly at
37    /// the compaction/renewal boundaries between consecutive contexts (the "goal drift" failure).
38    /// Bounded + recency-ordered (oldest dropped past [`MAX_DIRECTIVES`]); newest last.
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub directives: Vec<String>,
41    /// Call IDs or artifact hashes that must be preserved from compression.
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub preserved_refs: Vec<String>,
44    /// Rolling log of recent *task* activity — one entry per turn, each a compact summary of that
45    /// turn's tool calls (e.g. "module_read, module_list"). Kernel-maintained from REAL tool
46    /// activity (not model-curated), so the State turn always shows forward motion even when the
47    /// model never maintains `plan`. Lives in the volatile State turn (out of the cacheable prefix),
48    /// so updating it never churns the prompt cache. Bounded + recency-ordered; newest last.
49    #[serde(default, skip_serializing_if = "Vec::is_empty")]
50    pub recent_actions: Vec<String>,
51    /// Append-only log of all compression events. Never overwritten.
52    /// Rendered into systemVolatile so the model always sees compression history.
53    #[serde(default, skip_serializing_if = "Vec::is_empty")]
54    pub compression_log: Vec<CompressionEntry>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct PlanStep {
59    pub label: String,
60    pub done: bool,
61}
62
63impl PlanStep {
64    pub fn new(label: impl Into<String>) -> Self {
65        Self {
66            label: label.into(),
67            done: false,
68        }
69    }
70}
71
72/// Maximum durable directives retained; past this the oldest is dropped (recency window).
73pub const MAX_DIRECTIVES: usize = 8;
74
75/// Maximum recent action-turns retained for the recency footer (bounded ring).
76pub const MAX_RECENT_ACTIONS: usize = 6;
77
78/// Partial update applied by the SDK or via `update_plan` meta-tool.
79#[derive(Debug, Clone, Default, Serialize, Deserialize)]
80pub struct TaskUpdate {
81    pub plan: Option<Vec<String>>,
82    pub current_step: Option<usize>,
83    pub progress: Option<String>,
84    pub scratchpad: Option<String>,
85    pub blocked_on: Option<Vec<String>>,
86    pub preserved_refs: Option<Vec<String>>,
87    /// Replace the durable directive list wholesale (SDK/model curation).
88    pub directives: Option<Vec<String>>,
89}
90
91impl TaskState {
92    /// Compact text block for embedding in `system_text`.
93    /// Returns an empty string when the task has not been initialised.
94    pub fn format_compact(&self) -> String {
95        if self.goal.is_empty() && self.plan.is_empty() && self.progress.is_empty() {
96            return String::new();
97        }
98
99        let mut lines = Vec::new();
100        lines.push(format!("[TASK STATE] goal: {}", self.goal));
101
102        if !self.criteria.is_empty() {
103            lines.push(format!("criteria: {}", self.criteria.join(" | ")));
104        }
105
106        // Active directives render right after the goal — highest salience after the objective, so
107        // a recent user command keeps its imperative force across compaction/renewal.
108        if !self.directives.is_empty() {
109            lines.push("active_directives (most recent last):".to_string());
110            for d in &self.directives {
111                lines.push(format!("  - {d}"));
112            }
113        }
114
115        if !self.plan.is_empty() {
116            lines.push("plan:".to_string());
117            for (i, step) in self.plan.iter().enumerate() {
118                let marker = if step.done {
119                    "done"
120                } else if Some(i) == self.current_step {
121                    "active"
122                } else {
123                    "todo"
124                };
125                lines.push(format!("  [{}] {}. {}", marker, i + 1, step.label));
126            }
127        }
128
129        if !self.progress.is_empty() {
130            lines.push(format!("progress: {}", self.progress));
131        }
132
133        if !self.blocked_on.is_empty() {
134            lines.push(format!("blocked_on: {}", self.blocked_on.join(", ")));
135        }
136
137        if !self.scratchpad.is_empty() {
138            lines.push(format!("scratchpad: {}", self.scratchpad));
139        }
140
141        // Render the most recent compression events (cap at 3 to limit token cost).
142        if !self.compression_log.is_empty() {
143            lines.push("compression_history:".to_string());
144            let start = self.compression_log.len().saturating_sub(3);
145            for entry in &self.compression_log[start..] {
146                if entry.summary.is_empty() {
147                    lines.push(format!("  [{}]", entry.action));
148                } else {
149                    lines.push(format!("  [{}] {}", entry.action, entry.summary));
150                }
151            }
152        }
153
154        lines.join("\n")
155    }
156
157    /// Record a durable user directive (deduped against the most recent, recency-capped at
158    /// [`MAX_DIRECTIVES`]). Newest is appended last; the oldest is dropped past the cap so the
159    /// channel stays bounded across a long session.
160    pub fn record_directive(&mut self, text: impl Into<String>) {
161        let text = text.into();
162        if text.trim().is_empty() {
163            return;
164        }
165        // Re-issuing the same directive moves it to most-recent rather than duplicating.
166        self.directives.retain(|d| d != &text);
167        self.directives.push(text);
168        if self.directives.len() > MAX_DIRECTIVES {
169            let overflow = self.directives.len() - MAX_DIRECTIVES;
170            self.directives.drain(0..overflow);
171        }
172    }
173
174    /// Record one turn's tool activity into the recency log (kernel-driven). `summary` is a compact
175    /// string of the turn's task tool names; blank input is ignored. Bounded at
176    /// [`MAX_RECENT_ACTIONS`] (oldest dropped past the cap).
177    pub fn note_actions(&mut self, summary: impl Into<String>) {
178        let summary = summary.into();
179        if summary.trim().is_empty() {
180            return;
181        }
182        self.recent_actions.push(summary);
183        if self.recent_actions.len() > MAX_RECENT_ACTIONS {
184            let overflow = self.recent_actions.len() - MAX_RECENT_ACTIONS;
185            self.recent_actions.drain(0..overflow);
186        }
187    }
188
189    /// Append a compression event to the log. Never overwrites existing entries.
190    pub fn log_compression(&mut self, action: &str, summary: String) {
191        self.compression_log.push(CompressionEntry {
192            action: action.to_string(),
193            summary,
194        });
195    }
196
197    pub fn apply(&mut self, update: TaskUpdate) {
198        if let Some(plan) = update.plan {
199            self.plan = plan.into_iter().map(PlanStep::new).collect();
200        }
201        if let Some(step) = update.current_step {
202            self.current_step = Some(step);
203        }
204        if let Some(p) = update.progress {
205            self.progress = p;
206        }
207        if let Some(s) = update.scratchpad {
208            self.scratchpad = s;
209        }
210        if let Some(b) = update.blocked_on {
211            self.blocked_on = b;
212        }
213        if let Some(r) = update.preserved_refs {
214            self.preserved_refs = r;
215        }
216        if let Some(d) = update.directives {
217            self.directives = d;
218            if self.directives.len() > MAX_DIRECTIVES {
219                let overflow = self.directives.len() - MAX_DIRECTIVES;
220                self.directives.drain(0..overflow);
221            }
222        }
223    }
224
225    /// Open steps (not yet done), for renewal handoff.
226    pub fn open_steps(&self) -> Vec<String> {
227        self.plan
228            .iter()
229            .filter(|s| !s.done)
230            .map(|s| s.label.clone())
231            .collect()
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn empty_state_compact_is_empty_string() {
241        assert_eq!(TaskState::default().format_compact(), "");
242    }
243
244    #[test]
245    fn goal_only_renders() {
246        let ts = TaskState {
247            goal: "Build it".to_string(),
248            ..Default::default()
249        };
250        let s = ts.format_compact();
251        assert!(s.contains("[TASK STATE] goal: Build it"));
252    }
253
254    #[test]
255    fn plan_markers_correct() {
256        let ts = TaskState {
257            goal: "g".to_string(),
258            plan: vec![
259                PlanStep {
260                    label: "step1".to_string(),
261                    done: true,
262                },
263                PlanStep {
264                    label: "step2".to_string(),
265                    done: false,
266                },
267                PlanStep {
268                    label: "step3".to_string(),
269                    done: false,
270                },
271            ],
272            current_step: Some(1),
273            ..Default::default()
274        };
275        let s = ts.format_compact();
276        assert!(s.contains("[done] 1. step1"));
277        assert!(s.contains("[active] 2. step2"));
278        assert!(s.contains("[todo] 3. step3"));
279    }
280
281    #[test]
282    fn open_steps_excludes_done() {
283        let ts = TaskState {
284            goal: "g".to_string(),
285            plan: vec![
286                PlanStep {
287                    label: "a".to_string(),
288                    done: true,
289                },
290                PlanStep {
291                    label: "b".to_string(),
292                    done: false,
293                },
294            ],
295            ..Default::default()
296        };
297        assert_eq!(ts.open_steps(), vec!["b"]);
298    }
299
300    #[test]
301    fn record_directive_dedups_caps_and_orders_by_recency() {
302        let mut ts = TaskState::default();
303        ts.record_directive("don't touch the db schema");
304        ts.record_directive("use 2-space indent");
305        // Re-issuing moves to most-recent, no duplicate.
306        ts.record_directive("don't touch the db schema");
307        assert_eq!(ts.directives, ["use 2-space indent", "don't touch the db schema"]);
308
309        // Bounded at MAX_DIRECTIVES — oldest dropped.
310        let mut ts = TaskState::default();
311        for i in 0..(MAX_DIRECTIVES + 3) {
312            ts.record_directive(format!("rule {i}"));
313        }
314        assert_eq!(ts.directives.len(), MAX_DIRECTIVES);
315        assert_eq!(ts.directives.first().unwrap(), "rule 3"); // 0..2 dropped
316        assert_eq!(ts.directives.last().unwrap(), &format!("rule {}", MAX_DIRECTIVES + 2));
317
318        // Blank is ignored.
319        let mut ts = TaskState::default();
320        ts.record_directive("  ");
321        assert!(ts.directives.is_empty());
322    }
323
324    #[test]
325    fn directives_render_after_goal() {
326        let mut ts = TaskState { goal: "ship it".to_string(), ..Default::default() };
327        ts.record_directive("don't break the public API");
328        let s = ts.format_compact();
329        assert!(s.contains("active_directives"));
330        assert!(s.contains("- don't break the public API"));
331        // Renders after the goal line.
332        assert!(s.find("goal: ship it").unwrap() < s.find("don't break the public API").unwrap());
333    }
334
335    #[test]
336    fn apply_replaces_directives_and_caps() {
337        let mut ts = TaskState::default();
338        ts.apply(TaskUpdate {
339            directives: Some((0..(MAX_DIRECTIVES + 2)).map(|i| format!("d{i}")).collect()),
340            ..Default::default()
341        });
342        assert_eq!(ts.directives.len(), MAX_DIRECTIVES);
343    }
344
345    #[test]
346    fn apply_updates_fields() {
347        let mut ts = TaskState::default();
348        ts.apply(TaskUpdate {
349            progress: Some("half done".to_string()),
350            blocked_on: Some(vec!["waiting for data".to_string()]),
351            ..Default::default()
352        });
353        assert_eq!(ts.progress, "half done");
354        assert_eq!(ts.blocked_on, ["waiting for data"]);
355    }
356}