deepstrike_core/mm/handle.rs
1//! Primitive P3: the resource handle table + paging (context as address space).
2//!
3//! M0 scaffold (see `.local-docs/specs/agent-os-three-primitives.md`): types + a pure
4//! eviction-plan stub only — **no wiring, no behavior change**. A later milestone (M3, which is the
5//! compression optimization) builds a [`HandleTable`] over the context manager and replaces the
6//! scattered compactors in [`crate::context::compression`] with a single pure [`plan_eviction`].
7//!
8//! Concept overlap this primitive collapses: the 5-layer compression pyramid (5 compactors each
9//! deciding its own trigger) becomes one [`EvictionPlan`] of uniform [`EvictionOp`]s; page-out (④)
10//! and long-term memory residency (⑦) ride on [`Residency`].
11
12use compact_str::CompactString;
13use serde::{Deserialize, Serialize};
14
15use crate::context::pressure::PressureAction;
16
17/// Opaque handle id. M3 assigns these as tool results / knowledge / memory pages enter context.
18pub type HandleId = u32;
19
20/// What a handle refers to.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum HandleKind {
24 /// A tool result occupying working context.
25 ToolResult,
26 /// A working-memory page (compressible / pageable).
27 MemoryPage,
28 /// A knowledge entry paged in from long-term storage.
29 KnowledgeEntry,
30 /// A sub-agent join result occupying context.
31 SubAgentJoin,
32}
33
34/// Where a handle's content currently lives. Page-in/page-out are transitions on this.
35///
36/// [`Self::External`] and [`Self::PagedOut`] are deliberately distinct (§7.10, cluster-b B19): the
37/// first is "generated over the inline threshold and never was resident", the second is "was
38/// resident, archived under context pressure".
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum Residency {
42 /// Full content present in working context.
43 Resident,
44 /// §7.10 · the body was over the inline threshold when it was generated: the host persisted it
45 /// before the kernel ever saw it, and only `preview` was ever resident.
46 External {
47 /// Opaque host locator. Never a path, never joined, never opened by the kernel.
48 payload_ref: String,
49 digest: String,
50 original_size: u64,
51 },
52 /// §7.10 · the body *was* resident and left under context pressure (page-out archive).
53 PagedOut { payload_ref: String, digest: String },
54 /// Original kept locally but projected out of the rendered view (Layer 4 read-time projection).
55 Collapsed,
56}
57
58impl Residency {
59 pub fn label(&self) -> &'static str {
60 match self {
61 Self::Resident => "resident",
62 Self::External { .. } => "external",
63 Self::PagedOut { .. } => "paged_out",
64 Self::Collapsed => "collapsed",
65 }
66 }
67
68 /// Whether the handle's full content currently counts against the token budget.
69 pub fn occupies_context(&self) -> bool {
70 matches!(self, Self::Resident)
71 }
72
73 /// The opaque locator a page-in must hand back to the host, when one exists.
74 ///
75 /// `None` for every residency the kernel can satisfy on its own — `Resident` and `Collapsed`
76 /// still hold the body locally.
77 pub fn payload_ref(&self) -> Option<&str> {
78 match self {
79 Self::External { payload_ref, .. } | Self::PagedOut { payload_ref, .. } => {
80 Some(payload_ref.as_str())
81 }
82 Self::Resident | Self::Collapsed => None,
83 }
84 }
85
86 /// The digest a paged-in body must reproduce. Paired with [`Self::payload_ref`]: a residency
87 /// that can be loaded is exactly one that can be verified.
88 pub fn digest(&self) -> Option<&str> {
89 match self {
90 Self::External { digest, .. } | Self::PagedOut { digest, .. } => Some(digest.as_str()),
91 Self::Resident | Self::Collapsed => None,
92 }
93 }
94}
95
96/// One addressable resource the agent holds.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct Handle {
99 pub id: HandleId,
100 pub kind: HandleKind,
101 pub residency: Residency,
102 /// Token cost of the resident form (used by the eviction planner).
103 pub tokens: u32,
104 /// Link back to the source object in working context — for [`HandleKind::ToolResult`] this is
105 /// the tool `call_id`, letting the renderer project a handle's residency onto its message
106 /// (read-time projection) without mutating the stored message. `None` for handles with no
107 /// in-context anchor.
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub source: Option<CompactString>,
110}
111
112impl Handle {
113 pub fn resident(id: HandleId, kind: HandleKind, tokens: u32) -> Self {
114 Self {
115 id,
116 kind,
117 residency: Residency::Resident,
118 tokens,
119 source: None,
120 }
121 }
122
123 /// A resident handle anchored to a source object (e.g. a tool `call_id`).
124 pub fn resident_for(
125 id: HandleId,
126 kind: HandleKind,
127 tokens: u32,
128 source: impl Into<CompactString>,
129 ) -> Self {
130 Self {
131 id,
132 kind,
133 residency: Residency::Resident,
134 tokens,
135 source: Some(source.into()),
136 }
137 }
138}
139
140/// Per-task handle table. M3 makes the context manager's partitions a view over this.
141#[derive(Debug, Clone, Default, Serialize, Deserialize)]
142pub struct HandleTable {
143 handles: Vec<Handle>,
144}
145
146impl HandleTable {
147 pub fn new() -> Self {
148 Self::default()
149 }
150
151 pub fn insert(&mut self, handle: Handle) {
152 if let Some(existing) = self.handles.iter_mut().find(|h| h.id == handle.id) {
153 *existing = handle;
154 } else {
155 self.handles.push(handle);
156 }
157 }
158
159 pub fn get(&self, id: HandleId) -> Option<&Handle> {
160 self.handles.iter().find(|h| h.id == id)
161 }
162
163 pub fn get_mut(&mut self, id: HandleId) -> Option<&mut Handle> {
164 self.handles.iter_mut().find(|h| h.id == id)
165 }
166
167 pub fn all(&self) -> &[Handle] {
168 &self.handles
169 }
170
171 pub fn all_mut(&mut self) -> &mut [Handle] {
172 &mut self.handles
173 }
174
175 /// Retain only the handles for which `keep` returns true; drop the rest. The GC primitive the
176 /// context manager uses to evict handles whose backing message has left working context
177 /// (archived by compression / dropped on renewal) — bounding the table to the working set
178 /// instead of growing with total session length.
179 pub fn retain(&mut self, keep: impl FnMut(&Handle) -> bool) {
180 self.handles.retain(keep);
181 }
182
183 /// Residency of the handle anchored to `source` (e.g. a tool `call_id`), if any.
184 /// The renderer uses this to project a tool result without touching the stored message.
185 pub fn residency_for_source(&self, source: &str) -> Option<&Residency> {
186 self.handles
187 .iter()
188 .find(|h| h.source.as_deref() == Some(source))
189 .map(|h| &h.residency)
190 }
191
192 /// Tool-result handles in insertion (recency) order — oldest first. Used by the residency
193 /// planner to decide which older results to project out under context pressure.
194 pub fn tool_result_handles_mut(&mut self) -> impl Iterator<Item = &mut Handle> {
195 self.handles
196 .iter_mut()
197 .filter(|h| matches!(h.kind, HandleKind::ToolResult))
198 }
199
200 /// Sum of tokens for handles still occupying working context.
201 pub fn resident_tokens(&self) -> u32 {
202 self.handles
203 .iter()
204 .filter(|h| h.residency.occupies_context())
205 .map(|h| h.tokens)
206 .sum()
207 }
208
209 /// Sum of tokens for handles that have left working context (`Collapsed` / `External` /
210 /// `PagedOut`). Their anchored messages still sit in `partitions` at full weight (collapse is
211 /// non-destructive), so this is exactly the over-count that the *estimate* rho path must
212 /// discount to become paging-aware — see [`crate::context::manager::ContextManager::effective_rho`].
213 pub fn non_resident_tokens(&self) -> u32 {
214 self.handles
215 .iter()
216 .filter(|h| !h.residency.occupies_context())
217 .map(|h| h.tokens)
218 .sum()
219 }
220}
221
222/// One ordered eviction action in an [`EvictionPlan`]. Maps the pressure pyramid onto explicit
223/// ops the planner emits directly (the old `Pressure(PressureAction)` umbrella is deleted), each
224/// annotated with cache-aware metadata via [`EvictionOp::invalidates_prefix_at`].
225///
226/// P1-6 (async LLM semantic summary) is **not** a distinct op here: every archiving op already
227/// emits the drained messages as `archived` on the `Compressed` observation, and the SDK upgrades
228/// that summary out-of-band (LLM call = SDK I/O, a kernel non-goal), writing back a second
229/// `compressed` event. A separate in-kernel `Summarize` op would be a never-produced dead variant.
230///
231/// **Layer boundary vs [`crate::context::pressure::PressureAction`] (do not collapse the two):**
232/// `EvictionOp` is the *planner-op* vocabulary — what `plan_eviction` decides to do, carrying the
233/// per-op payload (`target_tokens` / `per_msg_ratio` / `preserve_turns`). `PressureAction` is the
234/// *pressure-level* vocabulary owned by the pressure subsystem: it is what `PressureMonitor::recommend`
235/// and `ContextManager::should_compress` return, the `Ord`-keyed cascade selector inside the
236/// compression pipeline, and the canonical wire label. They map ~1:1 by layer but are not redundant —
237/// `TimeDecayMicro` doesn't sit on the linear pressure cascade, and `PressureAction` carries no
238/// per-op data. The one bridge is `execute_eviction_op`, which is the intended seam, not duplication.
239#[derive(Debug, Clone)]
240pub enum EvictionOp {
241 /// Layer 2: cap oversized messages at a per-message token limit (in-place rewrite).
242 Snip { per_msg_ratio: f64 },
243 /// Layer 3: idle/time-decay micro-compact — excerpt large tool results to placeholders.
244 /// Independent of rho; stamps `last_compact_ms` and uses the non-time compress path.
245 TimeDecayMicro,
246 /// Layer 4: collapse (read-time projection) — drop oldest messages until within target.
247 /// Now a distinct op (no longer bundled under `Pressure`), so the planner can annotate it
248 /// with cache-aware metadata and order it explicitly.
249 Collapse { target_tokens: u32 },
250 /// Layer 5: auto-compact — collapse history entirely except last K turns. Distinct from Collapse
251 /// for the same reason: the planner needs to control ordering and metadata.
252 AutoCompact { preserve_turns: usize },
253}
254
255impl EvictionOp {
256 pub fn label(&self) -> &'static str {
257 match self {
258 Self::Snip { .. } => "snip",
259 Self::TimeDecayMicro => "time_decay_micro",
260 Self::Collapse { .. } => "collapse",
261 Self::AutoCompact { .. } => "auto_compact",
262 }
263 }
264
265 /// Cache-aware metadata: the message index at which this op invalidates the prompt cache
266 /// prefix, if any. `None` = prefix-safe (op only affects late content).
267 /// Earlier index = higher cache cost (Anthropic cache keys off the first N messages).
268 pub fn invalidates_prefix_at(&self) -> Option<usize> {
269 match self {
270 // Snip: in-place rewrite of oversized messages anywhere in history. May hit early
271 // messages if an early turn was oversized → conservative: assume prefix invalidation.
272 Self::Snip { .. } => Some(0), // Conservative: may affect any message including early ones.
273 // TimeDecayMicro: excerpts large tool results to placeholders. Tool results are always
274 // interleaved (after their call), so they're typically mid/late history. Assuming the
275 // system prompt + first few user messages are untouched → prefix-safe for most sessions.
276 Self::TimeDecayMicro => None,
277 // Collapse: drops oldest messages to reach target. By definition modifies early history
278 // → prefix invalidation at the drop point.
279 Self::Collapse { .. } => Some(0),
280 // AutoCompact: drops all but last K turns → even more aggressive prefix invalidation.
281 Self::AutoCompact { .. } => Some(0),
282 }
283 }
284}
285
286/// An ordered set of eviction actions returned by the planner. Empty = no compression needed
287/// ("能不压就不压"). The order is the execution order.
288#[derive(Debug, Clone, Default)]
289pub struct EvictionPlan {
290 pub ops: Vec<EvictionOp>,
291}
292
293impl EvictionPlan {
294 pub fn empty() -> Self {
295 Self::default()
296 }
297
298 pub fn is_empty(&self) -> bool {
299 self.ops.is_empty()
300 }
301
302 /// Whether the plan includes the Layer-3 idle/time-decay micro op.
303 pub fn has_time_decay(&self) -> bool {
304 self.ops
305 .iter()
306 .any(|op| matches!(op, EvictionOp::TimeDecayMicro))
307 }
308
309 /// Map legacy `PressureAction` → the new specific op (for behavior-preserving migration).
310 /// The old `recommend()` returns one of 5 actions; we map them 1:1 onto the new ops.
311 pub fn from_legacy_action(
312 action: PressureAction,
313 target_tokens: u32,
314 preserve_turns: usize,
315 ) -> Self {
316 let ops = match action {
317 PressureAction::None => vec![],
318 PressureAction::SnipCompact => vec![EvictionOp::Snip {
319 per_msg_ratio: 0.10,
320 }],
321 PressureAction::MicroCompact => vec![EvictionOp::TimeDecayMicro],
322 PressureAction::ContextCollapse => vec![EvictionOp::Collapse { target_tokens }],
323 PressureAction::AutoCompact => vec![EvictionOp::AutoCompact { preserve_turns }],
324 };
325 Self { ops }
326 }
327}
328
329/// Pure eviction planner (M3): the **single decision point** for the per-turn compression
330/// checkpoint. Packages the two previously-scattered decisions — Layer-3 idle/time-decay and the
331/// rho-driven pressure recommendation — into one ordered [`EvictionPlan`], in execution order
332/// (time-decay micro first, then the pressure action). Behavior-preserving: the inputs are exactly
333/// what the state machine already computed (`ContextManager::should_time_decay_compact` and
334/// `PressureMonitor::recommend`); this only centralizes their ordering and makes the plan testable.
335///
336/// W1-1 收口: `target_tokens` / `preserve_turns` are the **real** config-derived values supplied by
337/// the caller (`ContextManager::plan_compaction_params`), so the emitted ops carry truthful params
338/// instead of the old magic-number placeholders. The plan is now the single decision point for *what*
339/// to compact and *to what target*; the executor honors `Collapse { target_tokens }` verbatim rather
340/// than re-deriving it. (The richer `(rho, idle_ms, &HandleTable, &cfg)` signature with explicit
341/// cache-cost ordering remains a future refinement; the `invalidates_prefix_at` metadata is already
342/// carried per op.)
343pub fn plan_eviction(
344 recommended: PressureAction,
345 idle_decay: bool,
346 target_tokens: u32,
347 preserve_turns: usize,
348) -> EvictionPlan {
349 let mut ops = Vec::new();
350 if idle_decay {
351 ops.push(EvictionOp::TimeDecayMicro);
352 }
353 // Map the pressure recommendation to a specific op; `None` yields an empty plan (no op appended).
354 if recommended != PressureAction::None {
355 ops.extend(
356 EvictionPlan::from_legacy_action(recommended, target_tokens, preserve_turns).ops,
357 );
358 }
359 EvictionPlan { ops }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn resident_tokens_counts_only_resident() {
368 let mut table = HandleTable::new();
369 table.insert(Handle::resident(1, HandleKind::ToolResult, 100));
370 table.insert(Handle {
371 id: 2,
372 kind: HandleKind::ToolResult,
373 residency: Residency::External {
374 payload_ref: "payload:x".into(),
375 digest: "sha256:".to_string() + &"a".repeat(64),
376 original_size: 5_000,
377 },
378 tokens: 5000,
379 source: None,
380 });
381 table.insert(Handle {
382 id: 3,
383 kind: HandleKind::MemoryPage,
384 residency: Residency::Collapsed,
385 tokens: 200,
386 source: None,
387 });
388 assert_eq!(table.resident_tokens(), 100);
389 }
390
391 #[test]
392 fn handle_table_insert_is_idempotent_by_id() {
393 let mut table = HandleTable::new();
394 table.insert(Handle::resident(1, HandleKind::ToolResult, 100));
395 table.insert(Handle::resident(1, HandleKind::ToolResult, 250));
396 assert_eq!(table.all().len(), 1);
397 assert_eq!(table.get(1).unwrap().tokens, 250);
398 }
399
400 #[test]
401 fn residency_occupies_context_only_when_resident() {
402 assert!(Residency::Resident.occupies_context());
403 assert!(!Residency::Collapsed.occupies_context());
404 assert!(!paged_out().occupies_context());
405 assert!(!external().occupies_context());
406 }
407
408 fn external() -> Residency {
409 Residency::External {
410 payload_ref: "payload:01J".into(),
411 digest: "sha256:".to_string() + &"a".repeat(64),
412 original_size: 90_000,
413 }
414 }
415
416 fn paged_out() -> Residency {
417 Residency::PagedOut {
418 payload_ref: "payload:02K".into(),
419 digest: "sha256:".to_string() + &"b".repeat(64),
420 }
421 }
422
423 /// §7.10 · a residency is loadable exactly when it is verifiable: the two accessors agree on
424 /// the same set, so no page-in can address a body it could not then check.
425 #[test]
426 fn only_externally_backed_residencies_expose_a_locator_and_a_digest() {
427 for residency in [external(), paged_out()] {
428 assert!(residency.payload_ref().is_some(), "{residency:?}");
429 assert!(residency.digest().is_some(), "{residency:?}");
430 }
431 for residency in [Residency::Resident, Residency::Collapsed] {
432 assert_eq!(residency.payload_ref(), None, "{residency:?}");
433 assert_eq!(residency.digest(), None, "{residency:?}");
434 }
435 }
436
437 /// B19 · "generated over the limit" and "evicted under pressure" are different facts, and the
438 /// label is what a host event log reads them by.
439 #[test]
440 fn external_and_paged_out_are_distinguishable_states() {
441 assert_eq!(external().label(), "external");
442 assert_eq!(paged_out().label(), "paged_out");
443 assert_ne!(external(), paged_out());
444 }
445
446 #[test]
447 fn plan_eviction_empty_when_no_pressure_and_no_idle() {
448 assert!(plan_eviction(PressureAction::None, false, 50_000, 2).is_empty());
449 }
450
451 #[test]
452 fn plan_eviction_emits_specific_op_for_recommended_action() {
453 let plan = plan_eviction(PressureAction::AutoCompact, false, 50_000, 3);
454 // The op carries the real preserve_turns the caller passed, not a placeholder.
455 assert!(matches!(
456 &plan.ops[..],
457 [EvictionOp::AutoCompact { preserve_turns: 3 }]
458 ));
459 }
460
461 #[test]
462 fn plan_eviction_collapse_carries_caller_target_tokens() {
463 // W1-1 收口: the planner stamps the caller's real target into the Collapse op (no placeholder),
464 // and the executor honors it verbatim.
465 let plan = plan_eviction(PressureAction::ContextCollapse, false, 12_345, 2);
466 assert!(matches!(
467 &plan.ops[..],
468 [EvictionOp::Collapse {
469 target_tokens: 12_345
470 }]
471 ));
472 }
473
474 #[test]
475 fn plan_eviction_orders_time_decay_before_pressure() {
476 // Idle + rho both fire: time-decay micro runs first, then the specific op — matching
477 // the legacy checkpoint order exactly.
478 let plan = plan_eviction(PressureAction::ContextCollapse, true, 50_000, 2);
479 assert_eq!(plan.ops.len(), 2);
480 assert!(matches!(plan.ops[0], EvictionOp::TimeDecayMicro));
481 assert!(matches!(plan.ops[1], EvictionOp::Collapse { .. }));
482 }
483
484 #[test]
485 fn plan_eviction_time_decay_only() {
486 let plan = plan_eviction(PressureAction::None, true, 50_000, 2);
487 assert_eq!(plan.ops.len(), 1);
488 assert!(matches!(plan.ops[0], EvictionOp::TimeDecayMicro));
489 }
490
491 #[test]
492 fn plan_eviction_micro_compact_emits_time_decay_without_idle() {
493 // Regression: a pressure-driven MicroCompact emits a TimeDecayMicro op *independent* of the
494 // idle-decay flag. So `has_time_decay()` can be true while `idle_decay` is false — the state
495 // machine's compaction checkpoint must assert the implication (`idle_decay ⇒ has_time_decay`),
496 // NOT equality (the old `debug_assert_eq!(has_time_decay, idle_decay)` wrongly aborted here).
497 let plan = plan_eviction(PressureAction::MicroCompact, false, 50_000, 2);
498 assert!(
499 plan.has_time_decay(),
500 "MicroCompact yields a time-decay op even when not idle"
501 );
502 // And the checkpoint invariant the fixed assertion encodes holds for every combination:
503 for recommended in [
504 PressureAction::None,
505 PressureAction::MicroCompact,
506 PressureAction::AutoCompact,
507 PressureAction::ContextCollapse,
508 ] {
509 for idle in [false, true] {
510 let p = plan_eviction(recommended, idle, 50_000, 2);
511 assert!(
512 !idle || p.has_time_decay(),
513 "idle_decay must imply a time-decay op"
514 );
515 }
516 }
517 }
518
519 #[test]
520 fn eviction_op_labels() {
521 assert_eq!(EvictionOp::Snip { per_msg_ratio: 0.1 }.label(), "snip");
522 assert_eq!(EvictionOp::TimeDecayMicro.label(), "time_decay_micro");
523 assert_eq!(
524 EvictionOp::Collapse {
525 target_tokens: 5000
526 }
527 .label(),
528 "collapse"
529 );
530 assert_eq!(
531 EvictionOp::AutoCompact { preserve_turns: 2 }.label(),
532 "auto_compact"
533 );
534 }
535}