Skip to main content

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}