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    /// Unkeyed immediate append — exactly `push_entry(None, msg, tokens, false)`.
79    pub fn push(&mut self, msg: Message, token_count: u32) {
80        self.push_entry(None, msg, token_count, false);
81    }
82
83    /// Keyed push: a fresh key (or `None`) appends immediately; an existing key stages a
84    /// boundary-deferred upsert (and clears any pending eviction — the entry is wanted again).
85    /// `pinned` takes effect immediately in both cases (it is bookkeeping, not rendered bytes).
86    pub fn push_entry(
87        &mut self,
88        key: Option<compact_str::CompactString>,
89        mut msg: Message,
90        tokens: u32,
91        pinned: bool,
92    ) {
93        msg.token_count = Some(tokens);
94        if let Some(ref k) = key {
95            if let Some(entry) = self.entries.iter_mut().find(|e| e.key.as_ref() == Some(k)) {
96                entry.pending = Some(Box::new((msg, tokens)));
97                entry.evict_at_boundary = false;
98                entry.pinned = pinned;
99                return;
100            }
101        }
102        self.token_count += tokens;
103        self.entries.push(KnowledgeEntry {
104            key,
105            message: msg,
106            tokens,
107            pinned,
108            evict_at_boundary: false,
109            pending: None,
110        });
111    }
112
113    /// Mark the keyed entry for removal at the next boundary. Errs-open: unknown key is a no-op.
114    /// Returns whether a matching entry was marked.
115    pub fn remove(&mut self, key: &str) -> bool {
116        match self.entries.iter_mut().find(|e| e.key.as_deref() == Some(key)) {
117            Some(entry) => {
118                entry.evict_at_boundary = true;
119                entry.pending = None;
120                true
121            }
122            None => false,
123        }
124    }
125
126    /// Apply pending upserts and drop marked entries. Call ONLY at compaction/renewal
127    /// boundaries — this is the one place existing system[1] bytes may be rewritten.
128    pub fn sweep_at_boundary(&mut self) -> KnowledgeSweep {
129        let mut sweep = KnowledgeSweep::default();
130        for entry in &mut self.entries {
131            if let Some(replacement) = entry.pending.take() {
132                let (msg, tokens) = *replacement;
133                self.token_count = self.token_count - entry.tokens + tokens;
134                entry.message = msg;
135                entry.tokens = tokens;
136                sweep.changed = true;
137            }
138        }
139        let before = self.entries.len();
140        self.entries.retain(|e| {
141            if e.evict_at_boundary {
142                if let Some(ref k) = e.key {
143                    sweep.removed_keys.push(k.to_string());
144                }
145                sweep.tokens_freed += e.tokens;
146                false
147            } else {
148                true
149            }
150        });
151        if self.entries.len() != before {
152            self.token_count = self.token_count.saturating_sub(sweep.tokens_freed);
153            sweep.changed = true;
154        }
155        sweep
156    }
157
158    /// The rendered messages, in entry order (renderer / snapshot surface).
159    pub fn messages(&self) -> impl Iterator<Item = &Message> {
160        self.entries.iter().map(|e| &e.message)
161    }
162
163    pub fn len(&self) -> usize { self.entries.len() }
164    pub fn is_empty(&self) -> bool { self.entries.is_empty() }
165}
166
167/// Four-slot context model aligned with LLM API slots (five fields — slot 3 spans
168/// `task_state` + `signals`):
169///
170///   Slot 1 — Identity  (system):    who the agent is; role, rules, constraints.
171///                                    Maps to: Anthropic system[0] cache_control, OpenAI system role.
172///                                    Never changes within a run.
173///
174///   Slot 2 — Knowledge (knowledge): what the agent knows; memory retrievals, skill
175///                                    definitions, artifacts. Low-frequency changes.
176///                                    Maps to: Anthropic system[1] cache_control.
177///
178///   Slot 3 — State     (task_state + signals): what the agent is doing right now.
179///                                    task_state = goal/plan/progress (structured).
180///                                    signals = runtime events (rollback notes, interrupts).
181///                                    Maps to: messages[0] user turn, rebuilt every call.
182///
183///   Slot 4 — History   (history):   what the agent has done; conversation turns,
184///                                    tool calls and results. Compression pipeline target.
185///                                    Maps to: messages[1..N].
186pub struct ContextPartitions {
187    pub system: Partition,
188    pub knowledge: KnowledgePartition,
189    pub task_state: TaskState,
190    /// Runtime signals injected into the current turn (rollback notes, interrupts).
191    /// Cleared after each render — signals are ephemeral per-turn events.
192    pub signals: Vec<String>,
193    pub history: Partition,
194}
195
196impl ContextPartitions {
197    pub fn new(_config: &ContextConfig) -> Self {
198        Self {
199            system: Partition::new(),
200            knowledge: KnowledgePartition::new(),
201            task_state: TaskState::default(),
202            signals: Vec::new(),
203            history: Partition::new(),
204        }
205    }
206
207    /// Total token count across all slots.
208    /// task_state tokens are measured from its rendered compact form.
209    pub fn total_tokens(&self, engine: &ContextTokenEngine) -> u32 {
210        self.system.token_count
211            + self.knowledge.token_count
212            + engine.count(&self.task_state.format_compact())
213            + self.history.token_count
214    }
215}
216
217impl Default for ContextPartitions {
218    fn default() -> Self {
219        Self::new(&ContextConfig::default())
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::context::config::ContextConfig;
227    use crate::context::token_engine::ContextTokenEngine;
228    use crate::types::message::Message;
229
230    fn engine() -> ContextTokenEngine { ContextTokenEngine::char_approx() }
231
232    #[test]
233    fn push_updates_token_count() {
234        let mut ctx = ContextPartitions::new(&ContextConfig::default());
235        let base = ctx.total_tokens(&engine());
236        ctx.system.push(Message::system("rules"), 10);
237        ctx.history.push(Message::user("hello"), 5);
238        assert_eq!(ctx.total_tokens(&engine()), base + 15);
239    }
240
241    #[test]
242    fn task_state_tokens_included_in_total() {
243        use crate::context::task_state::TaskState;
244        let mut ctx = ContextPartitions::new(&ContextConfig::default());
245        let before = ctx.total_tokens(&engine());
246        ctx.task_state = TaskState { goal: "do something important".to_string(), ..Default::default() };
247        let after = ctx.total_tokens(&engine());
248        assert!(after > before, "task_state should contribute to total_tokens");
249    }
250
251    #[test]
252    fn knowledge_tokens_included_in_total() {
253        let mut ctx = ContextPartitions::new(&ContextConfig::default());
254        let before = ctx.total_tokens(&engine());
255        ctx.knowledge.push(Message::system("skill: debug"), 20);
256        assert_eq!(ctx.total_tokens(&engine()), before + 20);
257    }
258
259    // ── K1: keyed knowledge entries ──────────────────────────────────────────
260
261    fn text_of(p: &KnowledgePartition) -> Vec<String> {
262        p.messages().filter_map(|m| m.content.as_text().map(str::to_string)).collect()
263    }
264
265    #[test]
266    fn keyed_upsert_defers_to_boundary() {
267        let mut p = KnowledgePartition::new();
268        p.push_entry(Some("ref".into()), Message::system("v1"), 10, false);
269        p.push_entry(Some("ref".into()), Message::system("v2"), 12, false);
270        // Mid-generation: still ONE entry rendering the ORIGINAL bytes (system[1] untouched).
271        assert_eq!(p.len(), 1);
272        assert_eq!(text_of(&p), vec!["v1"]);
273        assert_eq!(p.token_count, 10);
274
275        let sweep = p.sweep_at_boundary();
276        assert!(sweep.changed);
277        assert!(sweep.removed_keys.is_empty(), "upsert-only sweep removes nothing");
278        assert_eq!(text_of(&p), vec!["v2"]);
279        assert_eq!(p.token_count, 12);
280    }
281
282    #[test]
283    fn remove_marks_then_sweep_drops() {
284        let mut p = KnowledgePartition::new();
285        p.push_entry(Some("ref".into()), Message::system("pinned ref"), 8, false);
286        assert!(p.remove("ref"));
287        // Still rendered until the boundary (no mid-generation byte rewrite).
288        assert_eq!(p.len(), 1);
289        assert_eq!(text_of(&p), vec!["pinned ref"]);
290
291        let sweep = p.sweep_at_boundary();
292        assert!(sweep.changed);
293        assert_eq!(sweep.removed_keys, vec!["ref".to_string()]);
294        assert_eq!(sweep.tokens_freed, 8);
295        assert!(p.is_empty());
296        assert_eq!(p.token_count, 0);
297    }
298
299    #[test]
300    fn remove_unknown_key_errs_open() {
301        let mut p = KnowledgePartition::new();
302        p.push(Message::system("unkeyed"), 5);
303        assert!(!p.remove("missing"));
304        assert!(!p.sweep_at_boundary().changed);
305        assert_eq!(p.len(), 1);
306    }
307
308    #[test]
309    fn same_key_push_after_remove_revives_entry() {
310        let mut p = KnowledgePartition::new();
311        p.push_entry(Some("ref".into()), Message::system("v1"), 5, false);
312        p.remove("ref");
313        // Re-pushing the key means the entry is wanted again — the eviction mark clears and the
314        // fresh content lands as a deferred upsert.
315        p.push_entry(Some("ref".into()), Message::system("v2"), 6, false);
316        let sweep = p.sweep_at_boundary();
317        assert!(sweep.removed_keys.is_empty());
318        assert_eq!(text_of(&p), vec!["v2"]);
319    }
320
321    #[test]
322    fn fresh_keys_and_unkeyed_append_immediately() {
323        let mut p = KnowledgePartition::new();
324        p.push(Message::system("legacy"), 3);
325        p.push_entry(Some("a".into()), Message::system("fresh"), 4, true);
326        // Appends are visible right away (cache-cheap direction: prefix only extends).
327        assert_eq!(text_of(&p), vec!["legacy", "fresh"]);
328        assert_eq!(p.token_count, 7);
329        assert!(p.entries[1].pinned);
330    }
331}