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