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