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