Skip to main content

deepstrike_core/context/
partitions.rs

1use super::config::ContextConfig;
2use super::task_state::TaskState;
3use super::token_engine::ContextTokenEngine;
4use crate::types::message::Message;
5
6/// A single context partition — a named bucket of messages with a token counter.
7#[derive(Debug, Clone)]
8pub struct Partition {
9    pub messages: Vec<Message>,
10    pub token_count: u32,
11}
12
13impl Partition {
14    pub fn new() -> Self {
15        Self { messages: Vec::new(), token_count: 0 }
16    }
17
18    pub fn push(&mut self, mut msg: Message, token_count: u32) {
19        msg.token_count = Some(token_count);
20        self.token_count += token_count;
21        self.messages.push(msg);
22    }
23
24    pub fn clear(&mut self) {
25        self.messages.clear();
26        self.token_count = 0;
27    }
28
29    pub fn len(&self) -> usize { self.messages.len() }
30    pub fn is_empty(&self) -> bool { self.messages.is_empty() }
31}
32
33impl Default for Partition {
34    fn default() -> Self { Self::new() }
35}
36
37/// One durable knowledge entry. Unlike history messages, knowledge entries have IDENTITY —
38/// a host-assigned key enabling upsert (refresh a pinned reference) and targeted removal —
39/// plus lifecycle flags driving the boundary sweep (K1/K2 of the dynamic-control spec).
40#[derive(Debug, Clone)]
41pub struct KnowledgeEntry {
42    /// `None` ⇒ legacy unkeyed append (initialMemory, old snapshots). Keyed entries upsert.
43    pub key: Option<compact_str::CompactString>,
44    pub message: Message,
45    pub tokens: u32,
46    /// Host-pinned ⇒ never budget-evicted (K2). Skill pins are NOT host-pinned (K3 governs them).
47    pub pinned: bool,
48    /// Marked for removal at the next compaction/renewal boundary. Knowledge renders into the
49    /// cached system[1] block, so existing bytes are only rewritten where the prompt-cache prefix
50    /// is being rebuilt anyway — the same principle as `reset_collapse_generation`.
51    pub evict_at_boundary: bool,
52    /// Deferred upsert: a same-key push mid-generation stages its replacement here instead of
53    /// rewriting rendered bytes; applied by [`KnowledgePartition::sweep_at_boundary`].
54    pub pending: Option<Box<(Message, u32)>>,
55}
56
57/// Outcome of one boundary sweep, for the `KnowledgeSwept` kernel observation.
58#[derive(Debug, Clone, Default)]
59pub struct KnowledgeSweep {
60    pub removed_keys: Vec<String>,
61    pub tokens_freed: u32,
62    /// True when the sweep changed anything (removal OR applied upsert).
63    pub changed: bool,
64}
65
66/// The knowledge partition: durable, identity-bearing entries rendered into system[1].
67/// Appends are immediate (they extend the cached prefix — the cheap direction); mutation and
68/// removal of existing entries are boundary-deferred (see [`KnowledgeEntry::evict_at_boundary`]).
69#[derive(Debug, Clone, Default)]
70pub struct KnowledgePartition {
71    pub entries: Vec<KnowledgeEntry>,
72    pub token_count: u32,
73}
74
75impl KnowledgePartition {
76    pub fn new() -> Self { Self::default() }
77
78    /// Legacy unkeyed append (initialMemory, snapshot restore) — immediate, never upserts.
79    pub fn push(&mut self, mut msg: Message, token_count: u32) {
80        msg.token_count = Some(token_count);
81        self.token_count += token_count;
82        self.entries.push(KnowledgeEntry {
83            key: None,
84            message: msg,
85            tokens: token_count,
86            pinned: false,
87            evict_at_boundary: false,
88            pending: None,
89        });
90    }
91
92    /// Keyed push: a fresh key (or `None`) appends immediately; an existing key stages a
93    /// boundary-deferred upsert (and clears any pending eviction — the entry is wanted again).
94    /// `pinned` takes effect immediately in both cases (it is bookkeeping, not rendered bytes).
95    pub fn push_entry(
96        &mut self,
97        key: Option<compact_str::CompactString>,
98        mut msg: Message,
99        tokens: u32,
100        pinned: bool,
101    ) {
102        msg.token_count = Some(tokens);
103        if let Some(ref k) = key {
104            if let Some(entry) = self.entries.iter_mut().find(|e| e.key.as_ref() == Some(k)) {
105                entry.pending = Some(Box::new((msg, tokens)));
106                entry.evict_at_boundary = false;
107                entry.pinned = pinned;
108                return;
109            }
110        }
111        self.token_count += tokens;
112        self.entries.push(KnowledgeEntry {
113            key,
114            message: msg,
115            tokens,
116            pinned,
117            evict_at_boundary: false,
118            pending: None,
119        });
120    }
121
122    /// Mark the keyed entry for removal at the next boundary. Errs-open: unknown key is a no-op.
123    /// Returns whether a matching entry was marked.
124    pub fn remove(&mut self, key: &str) -> bool {
125        match self.entries.iter_mut().find(|e| e.key.as_deref() == Some(key)) {
126            Some(entry) => {
127                entry.evict_at_boundary = true;
128                entry.pending = None;
129                true
130            }
131            None => false,
132        }
133    }
134
135    /// Apply pending upserts and drop marked entries. Call ONLY at compaction/renewal
136    /// boundaries — this is the one place existing system[1] bytes may be rewritten.
137    pub fn sweep_at_boundary(&mut self) -> KnowledgeSweep {
138        let mut sweep = KnowledgeSweep::default();
139        for entry in &mut self.entries {
140            if let Some(replacement) = entry.pending.take() {
141                let (msg, tokens) = *replacement;
142                self.token_count = self.token_count - entry.tokens + tokens;
143                entry.message = msg;
144                entry.tokens = tokens;
145                sweep.changed = true;
146            }
147        }
148        let before = self.entries.len();
149        self.entries.retain(|e| {
150            if e.evict_at_boundary {
151                if let Some(ref k) = e.key {
152                    sweep.removed_keys.push(k.to_string());
153                }
154                sweep.tokens_freed += e.tokens;
155                false
156            } else {
157                true
158            }
159        });
160        if self.entries.len() != before {
161            self.token_count = self.token_count.saturating_sub(sweep.tokens_freed);
162            sweep.changed = true;
163        }
164        sweep
165    }
166
167    /// The rendered messages, in entry order (renderer / snapshot surface).
168    pub fn messages(&self) -> impl Iterator<Item = &Message> {
169        self.entries.iter().map(|e| &e.message)
170    }
171
172    pub fn len(&self) -> usize { self.entries.len() }
173    pub fn is_empty(&self) -> bool { self.entries.is_empty() }
174}
175
176/// Three-partition context model aligned with LLM API slots:
177///
178///   Slot 1 — Identity  (system):    who the agent is; role, rules, constraints.
179///                                    Maps to: Anthropic system[0] cache_control, OpenAI system role.
180///                                    Never changes within a run.
181///
182///   Slot 2 — Knowledge (knowledge): what the agent knows; memory retrievals, skill
183///                                    definitions, artifacts. Low-frequency changes.
184///                                    Maps to: Anthropic system[1] cache_control.
185///
186///   Slot 3 — State     (task_state + signals): what the agent is doing right now.
187///                                    task_state = goal/plan/progress (structured).
188///                                    signals = runtime events (rollback notes, interrupts).
189///                                    Maps to: messages[0] user turn, rebuilt every call.
190///
191///   Slot 4 — History   (history):   what the agent has done; conversation turns,
192///                                    tool calls and results. Compression pipeline target.
193///                                    Maps to: messages[1..N].
194pub struct ContextPartitions {
195    pub system: Partition,
196    pub knowledge: KnowledgePartition,
197    pub task_state: TaskState,
198    /// Runtime signals injected into the current turn (rollback notes, interrupts).
199    /// Cleared after each render — signals are ephemeral per-turn events.
200    pub signals: Vec<String>,
201    pub history: Partition,
202}
203
204impl ContextPartitions {
205    pub fn new(_config: &ContextConfig) -> Self {
206        Self {
207            system: Partition::new(),
208            knowledge: KnowledgePartition::new(),
209            task_state: TaskState::default(),
210            signals: Vec::new(),
211            history: Partition::new(),
212        }
213    }
214
215    /// Total token count across all slots.
216    /// task_state tokens are measured from its rendered compact form.
217    pub fn total_tokens(&self, engine: &ContextTokenEngine) -> u32 {
218        self.system.token_count
219            + self.knowledge.token_count
220            + engine.count(&self.task_state.format_compact())
221            + self.history.token_count
222    }
223}
224
225impl Default for ContextPartitions {
226    fn default() -> Self {
227        Self::new(&ContextConfig::default())
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::context::config::ContextConfig;
235    use crate::context::token_engine::ContextTokenEngine;
236    use crate::types::message::Message;
237
238    fn engine() -> ContextTokenEngine { ContextTokenEngine::char_approx() }
239
240    #[test]
241    fn push_updates_token_count() {
242        let mut ctx = ContextPartitions::new(&ContextConfig::default());
243        let base = ctx.total_tokens(&engine());
244        ctx.system.push(Message::system("rules"), 10);
245        ctx.history.push(Message::user("hello"), 5);
246        assert_eq!(ctx.total_tokens(&engine()), base + 15);
247    }
248
249    #[test]
250    fn task_state_tokens_included_in_total() {
251        use crate::context::task_state::TaskState;
252        let mut ctx = ContextPartitions::new(&ContextConfig::default());
253        let before = ctx.total_tokens(&engine());
254        ctx.task_state = TaskState { goal: "do something important".to_string(), ..Default::default() };
255        let after = ctx.total_tokens(&engine());
256        assert!(after > before, "task_state should contribute to total_tokens");
257    }
258
259    #[test]
260    fn knowledge_tokens_included_in_total() {
261        let mut ctx = ContextPartitions::new(&ContextConfig::default());
262        let before = ctx.total_tokens(&engine());
263        ctx.knowledge.push(Message::system("skill: debug"), 20);
264        assert_eq!(ctx.total_tokens(&engine()), before + 20);
265    }
266
267    // ── K1: keyed knowledge entries ──────────────────────────────────────────
268
269    fn text_of(p: &KnowledgePartition) -> Vec<String> {
270        p.messages().filter_map(|m| m.content.as_text().map(str::to_string)).collect()
271    }
272
273    #[test]
274    fn keyed_upsert_defers_to_boundary() {
275        let mut p = KnowledgePartition::new();
276        p.push_entry(Some("ref".into()), Message::system("v1"), 10, false);
277        p.push_entry(Some("ref".into()), Message::system("v2"), 12, false);
278        // Mid-generation: still ONE entry rendering the ORIGINAL bytes (system[1] untouched).
279        assert_eq!(p.len(), 1);
280        assert_eq!(text_of(&p), vec!["v1"]);
281        assert_eq!(p.token_count, 10);
282
283        let sweep = p.sweep_at_boundary();
284        assert!(sweep.changed);
285        assert!(sweep.removed_keys.is_empty(), "upsert-only sweep removes nothing");
286        assert_eq!(text_of(&p), vec!["v2"]);
287        assert_eq!(p.token_count, 12);
288    }
289
290    #[test]
291    fn remove_marks_then_sweep_drops() {
292        let mut p = KnowledgePartition::new();
293        p.push_entry(Some("ref".into()), Message::system("pinned ref"), 8, false);
294        assert!(p.remove("ref"));
295        // Still rendered until the boundary (no mid-generation byte rewrite).
296        assert_eq!(p.len(), 1);
297        assert_eq!(text_of(&p), vec!["pinned ref"]);
298
299        let sweep = p.sweep_at_boundary();
300        assert!(sweep.changed);
301        assert_eq!(sweep.removed_keys, vec!["ref".to_string()]);
302        assert_eq!(sweep.tokens_freed, 8);
303        assert!(p.is_empty());
304        assert_eq!(p.token_count, 0);
305    }
306
307    #[test]
308    fn remove_unknown_key_errs_open() {
309        let mut p = KnowledgePartition::new();
310        p.push(Message::system("unkeyed"), 5);
311        assert!(!p.remove("missing"));
312        assert!(!p.sweep_at_boundary().changed);
313        assert_eq!(p.len(), 1);
314    }
315
316    #[test]
317    fn same_key_push_after_remove_revives_entry() {
318        let mut p = KnowledgePartition::new();
319        p.push_entry(Some("ref".into()), Message::system("v1"), 5, false);
320        p.remove("ref");
321        // Re-pushing the key means the entry is wanted again — the eviction mark clears and the
322        // fresh content lands as a deferred upsert.
323        p.push_entry(Some("ref".into()), Message::system("v2"), 6, false);
324        let sweep = p.sweep_at_boundary();
325        assert!(sweep.removed_keys.is_empty());
326        assert_eq!(text_of(&p), vec!["v2"]);
327    }
328
329    #[test]
330    fn fresh_keys_and_unkeyed_append_immediately() {
331        let mut p = KnowledgePartition::new();
332        p.push(Message::system("legacy"), 3);
333        p.push_entry(Some("a".into()), Message::system("fresh"), 4, true);
334        // Appends are visible right away (cache-cheap direction: prefix only extends).
335        assert_eq!(text_of(&p), vec!["legacy", "fresh"]);
336        assert_eq!(p.token_count, 7);
337        assert!(p.entries[1].pinned);
338    }
339}