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