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.
318pub fn plan_spool(output: &str, threshold_bytes: u32, preview_bytes: u32) -> Option<SpoolDecision> {
319 let size = output.len();
320 if threshold_bytes == 0 || size <= threshold_bytes as usize {
321 return None;
322 }
323 let mut end = (preview_bytes as usize).min(size);
324 while end > 0 && !output.is_char_boundary(end) {
325 end -= 1;
326 }
327 let preview = format!(
328 "{}\n[…tool result spooled: {} bytes total, {} byte preview shown; full content persisted to disk by the SDK…]",
329 &output[..end],
330 size,
331 end
332 );
333 Some(SpoolDecision {
334 original_size: size as u32,
335 preview,
336 })
337}
338
339/// Pure eviction planner (M3): the **single decision point** for the per-turn compression
340/// checkpoint. Packages the two previously-scattered decisions — Layer-3 idle/time-decay and the
341/// rho-driven pressure recommendation — into one ordered [`EvictionPlan`], in execution order
342/// (time-decay micro first, then the pressure action). Behavior-preserving: the inputs are exactly
343/// what the state machine already computed (`ContextManager::should_time_decay_compact` and
344/// `PressureMonitor::recommend`); this only centralizes their ordering and makes the plan testable.
345///
346/// Layer-1 spool is decided at tool-result ingestion (handle size), not here.
347///
348/// W1-1 收口: `target_tokens` / `preserve_turns` are the **real** config-derived values supplied by
349/// the caller (`ContextManager::plan_compaction_params`), so the emitted ops carry truthful params
350/// instead of the old magic-number placeholders. The plan is now the single decision point for *what*
351/// to compact and *to what target*; the executor honors `Collapse { target_tokens }` verbatim rather
352/// than re-deriving it. (The richer `(rho, idle_ms, &HandleTable, &cfg)` signature with explicit
353/// cache-cost ordering remains a future refinement; the `invalidates_prefix_at` metadata is already
354/// carried per op.)
355pub fn plan_eviction(
356 recommended: PressureAction,
357 idle_decay: bool,
358 target_tokens: u32,
359 preserve_turns: usize,
360) -> EvictionPlan {
361 let mut ops = Vec::new();
362 if idle_decay {
363 ops.push(EvictionOp::TimeDecayMicro);
364 }
365 // Map the pressure recommendation to a specific op; `None` yields an empty plan (no op appended).
366 if recommended != PressureAction::None {
367 ops.extend(
368 EvictionPlan::from_legacy_action(recommended, target_tokens, preserve_turns).ops,
369 );
370 }
371 EvictionPlan { ops }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn resident_tokens_counts_only_resident() {
380 let mut table = HandleTable::new();
381 table.insert(Handle::resident(1, HandleKind::ToolResult, 100));
382 table.insert(Handle {
383 id: 2,
384 kind: HandleKind::SpoolFile,
385 residency: Residency::SpooledOut {
386 r: "disk://x".into(),
387 },
388 tokens: 5000,
389 source: None,
390 });
391 table.insert(Handle {
392 id: 3,
393 kind: HandleKind::MemoryPage,
394 residency: Residency::Collapsed,
395 tokens: 200,
396 source: None,
397 });
398 assert_eq!(table.resident_tokens(), 100);
399 }
400
401 #[test]
402 fn handle_table_insert_is_idempotent_by_id() {
403 let mut table = HandleTable::new();
404 table.insert(Handle::resident(1, HandleKind::ToolResult, 100));
405 table.insert(Handle::resident(1, HandleKind::ToolResult, 250));
406 assert_eq!(table.all().len(), 1);
407 assert_eq!(table.get(1).unwrap().tokens, 250);
408 }
409
410 #[test]
411 fn residency_occupies_context_only_when_resident() {
412 assert!(Residency::Resident.occupies_context());
413 assert!(!Residency::Collapsed.occupies_context());
414 assert!(
415 !Residency::PagedOut {
416 tier: MemoryTierHint::Semantic
417 }
418 .occupies_context()
419 );
420 }
421
422 #[test]
423 fn plan_eviction_empty_when_no_pressure_and_no_idle() {
424 assert!(plan_eviction(PressureAction::None, false, 50_000, 2).is_empty());
425 }
426
427 #[test]
428 fn plan_eviction_emits_specific_op_for_recommended_action() {
429 let plan = plan_eviction(PressureAction::AutoCompact, false, 50_000, 3);
430 // The op carries the real preserve_turns the caller passed, not a placeholder.
431 assert!(matches!(
432 &plan.ops[..],
433 [EvictionOp::AutoCompact { preserve_turns: 3 }]
434 ));
435 }
436
437 #[test]
438 fn plan_eviction_collapse_carries_caller_target_tokens() {
439 // W1-1 收口: the planner stamps the caller's real target into the Collapse op (no placeholder),
440 // and the executor honors it verbatim.
441 let plan = plan_eviction(PressureAction::ContextCollapse, false, 12_345, 2);
442 assert!(matches!(
443 &plan.ops[..],
444 [EvictionOp::Collapse {
445 target_tokens: 12_345
446 }]
447 ));
448 }
449
450 #[test]
451 fn plan_eviction_orders_time_decay_before_pressure() {
452 // Idle + rho both fire: time-decay micro runs first, then the specific op — matching
453 // the legacy checkpoint order exactly.
454 let plan = plan_eviction(PressureAction::ContextCollapse, true, 50_000, 2);
455 assert_eq!(plan.ops.len(), 2);
456 assert!(matches!(plan.ops[0], EvictionOp::TimeDecayMicro));
457 assert!(matches!(plan.ops[1], EvictionOp::Collapse { .. }));
458 }
459
460 #[test]
461 fn plan_eviction_time_decay_only() {
462 let plan = plan_eviction(PressureAction::None, true, 50_000, 2);
463 assert_eq!(plan.ops.len(), 1);
464 assert!(matches!(plan.ops[0], EvictionOp::TimeDecayMicro));
465 }
466
467 #[test]
468 fn plan_eviction_micro_compact_emits_time_decay_without_idle() {
469 // Regression: a pressure-driven MicroCompact emits a TimeDecayMicro op *independent* of the
470 // idle-decay flag. So `has_time_decay()` can be true while `idle_decay` is false — the state
471 // machine's compaction checkpoint must assert the implication (`idle_decay ⇒ has_time_decay`),
472 // NOT equality (the old `debug_assert_eq!(has_time_decay, idle_decay)` wrongly aborted here).
473 let plan = plan_eviction(PressureAction::MicroCompact, false, 50_000, 2);
474 assert!(
475 plan.has_time_decay(),
476 "MicroCompact yields a time-decay op even when not idle"
477 );
478 // And the checkpoint invariant the fixed assertion encodes holds for every combination:
479 for recommended in [
480 PressureAction::None,
481 PressureAction::MicroCompact,
482 PressureAction::AutoCompact,
483 PressureAction::ContextCollapse,
484 ] {
485 for idle in [false, true] {
486 let p = plan_eviction(recommended, idle, 50_000, 2);
487 assert!(
488 !idle || p.has_time_decay(),
489 "idle_decay must imply a time-decay op"
490 );
491 }
492 }
493 }
494
495 #[test]
496 fn eviction_op_labels() {
497 assert_eq!(EvictionOp::Spool(1).label(), "spool");
498 assert_eq!(EvictionOp::Snip { per_msg_ratio: 0.1 }.label(), "snip");
499 assert_eq!(EvictionOp::TimeDecayMicro.label(), "time_decay_micro");
500 assert_eq!(
501 EvictionOp::Collapse {
502 target_tokens: 5000
503 }
504 .label(),
505 "collapse"
506 );
507 assert_eq!(
508 EvictionOp::AutoCompact { preserve_turns: 2 }.label(),
509 "auto_compact"
510 );
511 }
512
513 #[test]
514 fn plan_spool_keeps_small_output_inline() {
515 assert_eq!(plan_spool("small", 50, 16), None);
516 // threshold 0 disables spooling.
517 assert_eq!(plan_spool(&"x".repeat(1000), 0, 16), None);
518 }
519
520 #[test]
521 fn plan_spool_previews_large_output() {
522 let output = "y".repeat(1000);
523 let d = plan_spool(&output, 100, 32).expect("should spool");
524 assert_eq!(d.original_size, 1000);
525 assert!(d.preview.starts_with(&"y".repeat(32)));
526 assert!(d.preview.contains("1000 bytes total"));
527 assert!(d.preview.len() < output.len());
528 }
529
530 #[test]
531 fn plan_spool_truncates_on_char_boundary() {
532 // multi-byte chars: preview cut must not split a char.
533 let output = "🚀".repeat(100); // 4 bytes each = 400 bytes
534 let d = plan_spool(&output, 50, 10).expect("should spool");
535 // No panic / valid UTF-8 preview is the assertion.
536 assert!(d.preview.contains("400 bytes total"));
537 }
538}