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}