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/// spc_006-05: what an [`ObjectDescriptor`] refers to. A pure additive extension over
223/// [`HandleKind`] (see [`From<HandleKind> for ObjectKind`](#impl-From<HandleKind>-for-ObjectKind))
224/// — every existing `HandleKind` maps onto exactly one `ObjectKind`, `HandleKind` itself is
225/// untouched, and `Handle`/`HandleTable` keep working unmodified (spc_006 §4: Public
226/// `Memory`/`Knowledge`/`Artifact`/`ToolResult` naming is unchanged; only the Kernel-internal
227/// descriptor is unified).
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(rename_all = "snake_case")]
230pub enum ObjectKind {
231    ToolResult,
232    Memory,
233    Knowledge,
234    Artifact,
235    AgentResult,
236    Dataset,
237    File,
238    WorkflowOutput,
239    Custom(CompactString),
240}
241
242impl From<HandleKind> for ObjectKind {
243    fn from(kind: HandleKind) -> Self {
244        match kind {
245            HandleKind::ToolResult => Self::ToolResult,
246            HandleKind::MemoryPage => Self::Memory,
247            HandleKind::KnowledgeEntry => Self::Knowledge,
248            HandleKind::SubAgentJoin => Self::Custom(CompactString::from("sub_agent_join")),
249        }
250    }
251}
252
253/// spc_006-05: the unified Kernel-internal descriptor spc_006 §4 targets for
254/// ToolResult/Memory/Knowledge/Artifact/AgentResult (and the three IPC-facing kinds
255/// Dataset/File/WorkflowOutput `ObjectKind` adds room for). Additive and parallel to
256/// [`Handle`]/[`HandleTable`] — this card does not replace or migrate either.
257///
258/// Field type choices (spec left both open, resolved here):
259/// - `id: ObjectId` reuses [`HandleId`] rather than a new id space — `ObjectDescriptor` is framed
260///   throughout spc_006 §4-§5 as the *same* addressable-object concept `Handle` already is, just
261///   generalized beyond in-context residency (this file's own header already anticipates the
262///   context manager becoming "a view over" the handle table); a second parallel id space would
263///   fight that convergence instead of serving it.
264/// - `digest: String` and `payload_ref: Option<String>` mirror [`Residency::External`]'s own field
265///   types exactly (spc_006-05's own instruction: "复用 Residency::External 里已有的 digest 类
266///   型") — no `Digest`/`PayloadRef` newtype exists anywhere in this codebase to reuse, and
267///   inventing one here would be exactly the "重新发明" the card says not to do.
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269pub struct ObjectDescriptor {
270    pub id: ObjectId,
271    pub kind: ObjectKind,
272    pub owner: crate::scheduler::tcb::TaskId,
273    pub digest: String,
274    pub size: u64,
275    pub residency: Residency,
276    pub payload_ref: Option<String>,
277    pub version: u64,
278    /// spc_006-06: a short excerpt a cross-task reader sees by default — never the full body.
279    /// `None` for descriptors with no externally-stored content (e.g. small `Resident` objects
280    /// short enough that the whole thing already fits, per `Residency::occupies_context`).
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub preview: Option<CompactString>,
283}
284
285impl ObjectDescriptor {
286    /// Project an existing handle into the unified object registry without copying its body.
287    pub fn from_handle(
288        owner: crate::scheduler::tcb::TaskId,
289        handle: &Handle,
290        version: u64,
291    ) -> Self {
292        let digest = handle.residency.digest().unwrap_or_default().to_string();
293        let payload_ref = handle.residency.payload_ref().map(str::to_string);
294        let size = match &handle.residency {
295            Residency::External { original_size, .. } => *original_size,
296            _ => handle.tokens as u64,
297        };
298        Self {
299            id: handle.id,
300            kind: handle.kind.into(),
301            owner,
302            digest,
303            size,
304            residency: handle.residency.clone(),
305            payload_ref,
306            version,
307            preview: None,
308        }
309    }
310
311    /// spc_006-06: build the descriptor a cross-task reader (Agent B) receives for an object
312    /// whose full body lives outside working context (Agent A's large Artifact/ToolResult/etc.).
313    /// By construction — `ObjectDescriptor` has no `payload`/`content` field at all — a caller
314    /// holding one physically cannot read the full body without a separate, explicit page-in
315    /// (spc_006 §5: "Pass handles, not prompts").
316    ///
317    /// Takes an already-built [`Residency::External`] (rather than its three fields individually)
318    /// so `payload_ref`/`digest` are entered once, not twice over — a caller passing the wrong
319    /// [`Residency`] variant gets a clear panic rather than a descriptor silently missing its
320    /// locator.
321    pub fn external(
322        id: ObjectId,
323        kind: ObjectKind,
324        owner: crate::scheduler::tcb::TaskId,
325        version: u64,
326        residency: Residency,
327        preview: impl Into<CompactString>,
328    ) -> Self {
329        let Residency::External {
330            payload_ref,
331            digest,
332            original_size,
333        } = &residency
334        else {
335            panic!("ObjectDescriptor::external requires a Residency::External, got {residency:?}");
336        };
337        let (payload_ref, digest, size) = (payload_ref.clone(), digest.clone(), *original_size);
338        Self {
339            id,
340            kind,
341            owner,
342            digest,
343            size,
344            residency,
345            payload_ref: Some(payload_ref),
346            version,
347            preview: Some(preview.into()),
348        }
349    }
350}
351
352/// See [`ObjectDescriptor::id`]'s doc comment for why this is a `HandleId` alias, not a new type.
353pub type ObjectId = HandleId;
354
355/// spc_009-07 · the Object invariant plan.md §7.3 names "Capability controls object access":
356/// bridges spc_004's [`Capability`] attenuation matching to spc_006's [`ObjectDescriptor`]. The
357/// two structures were never designed to reference each other — `ObjectDescriptor` carries an
358/// `owner: TaskId`, not a `ResourceSelector`-shaped field a `Capability.resource` could match
359/// against directly — so this establishes the minimal resource-naming convention needed to bridge
360/// them (`"object:{owner}/{id}"`), reusing [`resource_prefix`]'s exact prefix-containment rule
361/// rather than inventing a second matching algorithm. Neither `ObjectDescriptor` nor `Capability`
362/// is changed to make this work — the bridge is one pure function, not a structural merge.
363///
364pub fn object_access_allowed(
365    capabilities: &[crate::types::capability::Capability],
366    action: &str,
367    descriptor: &ObjectDescriptor,
368) -> bool {
369    object_access_allowed_at(capabilities, action, descriptor, 0)
370}
371
372/// Runtime form of [`object_access_allowed`] that also rejects expired capability leases.
373pub fn object_access_allowed_at(
374    capabilities: &[crate::types::capability::Capability],
375    action: &str,
376    descriptor: &ObjectDescriptor,
377    now_turn: u32,
378) -> bool {
379    let resource = format!("object:{}/{}", descriptor.owner, descriptor.id);
380    capabilities.iter().any(|capability| {
381        capability.actions.0.contains(action)
382            && capability
383                .lease
384                .as_ref()
385                .is_none_or(|lease| !lease.is_expired(now_turn))
386            && crate::types::capability::resource_matches(&capability.resource, &resource)
387    })
388}
389
390/// One ordered eviction action in an [`EvictionPlan`]. Maps the pressure pyramid onto explicit
391/// ops the planner emits directly (the old `Pressure(PressureAction)` umbrella is deleted), each
392/// annotated with cache-aware metadata via [`EvictionOp::invalidates_prefix_at`].
393///
394/// P1-6 (async LLM semantic summary) is **not** a distinct op here: every archiving op already
395/// emits the drained messages as `archived` on the `Compressed` observation, and the SDK upgrades
396/// that summary out-of-band (LLM call = SDK I/O, a kernel non-goal), writing back a second
397/// `compressed` event. A separate in-kernel `Summarize` op would be a never-produced dead variant.
398///
399/// **Layer boundary vs [`crate::context::pressure::PressureAction`] (do not collapse the two):**
400/// `EvictionOp` is the *planner-op* vocabulary — what `plan_eviction` decides to do, carrying the
401/// per-op payload (`target_tokens` / `per_msg_ratio` / `preserve_turns`). `PressureAction` is the
402/// *pressure-level* vocabulary owned by the pressure subsystem: it is what `PressureMonitor::recommend`
403/// and `ContextManager::should_compress` return, the `Ord`-keyed cascade selector inside the
404/// compression pipeline, and the canonical wire label. They map ~1:1 by layer but are not redundant —
405/// `TimeDecayMicro` doesn't sit on the linear pressure cascade, and `PressureAction` carries no
406/// per-op data. The one bridge is `execute_eviction_op`, which is the intended seam, not duplication.
407#[derive(Debug, Clone)]
408pub enum EvictionOp {
409    /// Layer 2: cap oversized messages at a per-message token limit (in-place rewrite).
410    Snip { per_msg_ratio: f64 },
411    /// Layer 3: idle/time-decay micro-compact — excerpt large tool results to placeholders.
412    /// Independent of rho; stamps `last_compact_ms` and uses the non-time compress path.
413    TimeDecayMicro,
414    /// Layer 4: collapse (read-time projection) — drop oldest messages until within target.
415    /// Now a distinct op (no longer bundled under `Pressure`), so the planner can annotate it
416    /// with cache-aware metadata and order it explicitly.
417    Collapse { target_tokens: u32 },
418    /// Layer 5: auto-compact — collapse history entirely except last K turns. Distinct from Collapse
419    /// for the same reason: the planner needs to control ordering and metadata.
420    AutoCompact { preserve_turns: usize },
421}
422
423impl EvictionOp {
424    pub fn label(&self) -> &'static str {
425        match self {
426            Self::Snip { .. } => "snip",
427            Self::TimeDecayMicro => "time_decay_micro",
428            Self::Collapse { .. } => "collapse",
429            Self::AutoCompact { .. } => "auto_compact",
430        }
431    }
432
433    /// Cache-aware metadata: the message index at which this op invalidates the prompt cache
434    /// prefix, if any. `None` = prefix-safe (op only affects late content).
435    /// Earlier index = higher cache cost (Anthropic cache keys off the first N messages).
436    pub fn invalidates_prefix_at(&self) -> Option<usize> {
437        match self {
438            // Snip: in-place rewrite of oversized messages anywhere in history. May hit early
439            // messages if an early turn was oversized → conservative: assume prefix invalidation.
440            Self::Snip { .. } => Some(0), // Conservative: may affect any message including early ones.
441            // TimeDecayMicro: excerpts large tool results to placeholders. Tool results are always
442            // interleaved (after their call), so they're typically mid/late history. Assuming the
443            // system prompt + first few user messages are untouched → prefix-safe for most sessions.
444            Self::TimeDecayMicro => None,
445            // Collapse: drops oldest messages to reach target. By definition modifies early history
446            // → prefix invalidation at the drop point.
447            Self::Collapse { .. } => Some(0),
448            // AutoCompact: drops all but last K turns → even more aggressive prefix invalidation.
449            Self::AutoCompact { .. } => Some(0),
450        }
451    }
452}
453
454/// An ordered set of eviction actions returned by the planner. Empty = no compression needed
455/// ("能不压就不压"). The order is the execution order.
456#[derive(Debug, Clone, Default)]
457pub struct EvictionPlan {
458    pub ops: Vec<EvictionOp>,
459}
460
461impl EvictionPlan {
462    pub fn empty() -> Self {
463        Self::default()
464    }
465
466    pub fn is_empty(&self) -> bool {
467        self.ops.is_empty()
468    }
469
470    /// Whether the plan includes the Layer-3 idle/time-decay micro op.
471    pub fn has_time_decay(&self) -> bool {
472        self.ops
473            .iter()
474            .any(|op| matches!(op, EvictionOp::TimeDecayMicro))
475    }
476
477    /// Map a pressure recommendation to one specific eviction operation.
478    /// The old `recommend()` returns one of 5 actions; we map them 1:1 onto the new ops.
479    pub fn from_pressure_action(
480        action: PressureAction,
481        target_tokens: u32,
482        preserve_turns: usize,
483    ) -> Self {
484        let ops = match action {
485            PressureAction::None => vec![],
486            PressureAction::SnipCompact => vec![EvictionOp::Snip {
487                per_msg_ratio: 0.10,
488            }],
489            PressureAction::MicroCompact => vec![EvictionOp::TimeDecayMicro],
490            PressureAction::ContextCollapse => vec![EvictionOp::Collapse { target_tokens }],
491            PressureAction::AutoCompact => vec![EvictionOp::AutoCompact { preserve_turns }],
492        };
493        Self { ops }
494    }
495}
496
497/// Pure eviction planner (M3): the **single decision point** for the per-turn compression
498/// checkpoint. Packages the two previously-scattered decisions — Layer-3 idle/time-decay and the
499/// rho-driven pressure recommendation — into one ordered [`EvictionPlan`], in execution order
500/// (time-decay micro first, then the pressure action). Behavior-preserving: the inputs are exactly
501/// what the state machine already computed (`ContextManager::should_time_decay_compact` and
502/// `PressureMonitor::recommend`); this only centralizes their ordering and makes the plan testable.
503///
504/// W1-1 收口: `target_tokens` / `preserve_turns` are the **real** config-derived values supplied by
505/// the caller (`ContextManager::plan_compaction_params`), so the emitted ops carry truthful params
506/// instead of the old magic-number placeholders. The plan is now the single decision point for *what*
507/// to compact and *to what target*; the executor honors `Collapse { target_tokens }` verbatim rather
508/// than re-deriving it. (The richer `(rho, idle_ms, &HandleTable, &cfg)` signature with explicit
509/// cache-cost ordering remains a future refinement; the `invalidates_prefix_at` metadata is already
510/// carried per op.)
511pub fn plan_eviction(
512    recommended: PressureAction,
513    idle_decay: bool,
514    target_tokens: u32,
515    preserve_turns: usize,
516) -> EvictionPlan {
517    let mut ops = Vec::new();
518    if idle_decay {
519        ops.push(EvictionOp::TimeDecayMicro);
520    }
521    // Map the pressure recommendation to a specific op; `None` yields an empty plan (no op appended).
522    if recommended != PressureAction::None {
523        ops.extend(
524            EvictionPlan::from_pressure_action(recommended, target_tokens, preserve_turns).ops,
525        );
526    }
527    EvictionPlan { ops }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn resident_tokens_counts_only_resident() {
536        let mut table = HandleTable::new();
537        table.insert(Handle::resident(1, HandleKind::ToolResult, 100));
538        table.insert(Handle {
539            id: 2,
540            kind: HandleKind::ToolResult,
541            residency: Residency::External {
542                payload_ref: "payload:x".into(),
543                digest: "sha256:".to_string() + &"a".repeat(64),
544                original_size: 5_000,
545            },
546            tokens: 5000,
547            source: None,
548        });
549        table.insert(Handle {
550            id: 3,
551            kind: HandleKind::MemoryPage,
552            residency: Residency::Collapsed,
553            tokens: 200,
554            source: None,
555        });
556        assert_eq!(table.resident_tokens(), 100);
557    }
558
559    #[test]
560    fn handle_table_insert_is_idempotent_by_id() {
561        let mut table = HandleTable::new();
562        table.insert(Handle::resident(1, HandleKind::ToolResult, 100));
563        table.insert(Handle::resident(1, HandleKind::ToolResult, 250));
564        assert_eq!(table.all().len(), 1);
565        assert_eq!(table.get(1).unwrap().tokens, 250);
566    }
567
568    #[test]
569    fn residency_occupies_context_only_when_resident() {
570        assert!(Residency::Resident.occupies_context());
571        assert!(!Residency::Collapsed.occupies_context());
572        assert!(!paged_out().occupies_context());
573        assert!(!external().occupies_context());
574    }
575
576    fn external() -> Residency {
577        Residency::External {
578            payload_ref: "payload:01J".into(),
579            digest: "sha256:".to_string() + &"a".repeat(64),
580            original_size: 90_000,
581        }
582    }
583
584    fn paged_out() -> Residency {
585        Residency::PagedOut {
586            payload_ref: "payload:02K".into(),
587            digest: "sha256:".to_string() + &"b".repeat(64),
588        }
589    }
590
591    /// §7.10 · a residency is loadable exactly when it is verifiable: the two accessors agree on
592    /// the same set, so no page-in can address a body it could not then check.
593    #[test]
594    fn only_externally_backed_residencies_expose_a_locator_and_a_digest() {
595        for residency in [external(), paged_out()] {
596            assert!(residency.payload_ref().is_some(), "{residency:?}");
597            assert!(residency.digest().is_some(), "{residency:?}");
598        }
599        for residency in [Residency::Resident, Residency::Collapsed] {
600            assert_eq!(residency.payload_ref(), None, "{residency:?}");
601            assert_eq!(residency.digest(), None, "{residency:?}");
602        }
603    }
604
605    /// B19 · "generated over the limit" and "evicted under pressure" are different facts, and the
606    /// label is what a host event log reads them by.
607    #[test]
608    fn external_and_paged_out_are_distinguishable_states() {
609        assert_eq!(external().label(), "external");
610        assert_eq!(paged_out().label(), "paged_out");
611        assert_ne!(external(), paged_out());
612    }
613
614    #[test]
615    fn plan_eviction_empty_when_no_pressure_and_no_idle() {
616        assert!(plan_eviction(PressureAction::None, false, 50_000, 2).is_empty());
617    }
618
619    #[test]
620    fn plan_eviction_emits_specific_op_for_recommended_action() {
621        let plan = plan_eviction(PressureAction::AutoCompact, false, 50_000, 3);
622        // The op carries the real preserve_turns the caller passed, not a placeholder.
623        assert!(matches!(
624            &plan.ops[..],
625            [EvictionOp::AutoCompact { preserve_turns: 3 }]
626        ));
627    }
628
629    #[test]
630    fn plan_eviction_collapse_carries_caller_target_tokens() {
631        // W1-1 收口: the planner stamps the caller's real target into the Collapse op (no placeholder),
632        // and the executor honors it verbatim.
633        let plan = plan_eviction(PressureAction::ContextCollapse, false, 12_345, 2);
634        assert!(matches!(
635            &plan.ops[..],
636            [EvictionOp::Collapse {
637                target_tokens: 12_345
638            }]
639        ));
640    }
641
642    #[test]
643    fn plan_eviction_orders_time_decay_before_pressure() {
644        // Idle + rho both fire: time-decay micro runs first, then the specific op — matching
645        // the canonical checkpoint order exactly.
646        let plan = plan_eviction(PressureAction::ContextCollapse, true, 50_000, 2);
647        assert_eq!(plan.ops.len(), 2);
648        assert!(matches!(plan.ops[0], EvictionOp::TimeDecayMicro));
649        assert!(matches!(plan.ops[1], EvictionOp::Collapse { .. }));
650    }
651
652    #[test]
653    fn plan_eviction_time_decay_only() {
654        let plan = plan_eviction(PressureAction::None, true, 50_000, 2);
655        assert_eq!(plan.ops.len(), 1);
656        assert!(matches!(plan.ops[0], EvictionOp::TimeDecayMicro));
657    }
658
659    #[test]
660    fn plan_eviction_micro_compact_emits_time_decay_without_idle() {
661        // Regression: a pressure-driven MicroCompact emits a TimeDecayMicro op *independent* of the
662        // idle-decay flag. So `has_time_decay()` can be true while `idle_decay` is false — the state
663        // machine's compaction checkpoint must assert the implication (`idle_decay ⇒ has_time_decay`),
664        // NOT equality (the old `debug_assert_eq!(has_time_decay, idle_decay)` wrongly aborted here).
665        let plan = plan_eviction(PressureAction::MicroCompact, false, 50_000, 2);
666        assert!(
667            plan.has_time_decay(),
668            "MicroCompact yields a time-decay op even when not idle"
669        );
670        // And the checkpoint invariant the fixed assertion encodes holds for every combination:
671        for recommended in [
672            PressureAction::None,
673            PressureAction::MicroCompact,
674            PressureAction::AutoCompact,
675            PressureAction::ContextCollapse,
676        ] {
677            for idle in [false, true] {
678                let p = plan_eviction(recommended, idle, 50_000, 2);
679                assert!(
680                    !idle || p.has_time_decay(),
681                    "idle_decay must imply a time-decay op"
682                );
683            }
684        }
685    }
686
687    #[test]
688    fn eviction_op_labels() {
689        assert_eq!(EvictionOp::Snip { per_msg_ratio: 0.1 }.label(), "snip");
690        assert_eq!(EvictionOp::TimeDecayMicro.label(), "time_decay_micro");
691        assert_eq!(
692            EvictionOp::Collapse {
693                target_tokens: 5000
694            }
695            .label(),
696            "collapse"
697        );
698        assert_eq!(
699            EvictionOp::AutoCompact { preserve_turns: 2 }.label(),
700            "auto_compact"
701        );
702    }
703
704    #[test]
705    fn spc_006_05_object_descriptor_fields_are_readable() {
706        let descriptor = ObjectDescriptor {
707            id: 7,
708            kind: ObjectKind::Artifact,
709            owner: crate::scheduler::tcb::TaskId::from("agent-1"),
710            digest: "sha256:".to_string() + &"a".repeat(64),
711            size: 1_200_000,
712            residency: Residency::Resident,
713            payload_ref: Some("payload:x".to_string()),
714            version: 1,
715            preview: None,
716        };
717
718        assert_eq!(descriptor.id, 7);
719        assert_eq!(descriptor.kind, ObjectKind::Artifact);
720        assert_eq!(
721            descriptor.owner,
722            crate::scheduler::tcb::TaskId::from("agent-1")
723        );
724        assert_eq!(descriptor.size, 1_200_000);
725        assert_eq!(descriptor.residency, Residency::Resident);
726        assert_eq!(descriptor.payload_ref, Some("payload:x".to_string()));
727        assert_eq!(descriptor.version, 1);
728        assert_eq!(descriptor.preview, None);
729    }
730
731    #[test]
732    fn spc_006_05_object_kind_from_handle_kind_maps_all_four_variants() {
733        assert_eq!(
734            ObjectKind::from(HandleKind::ToolResult),
735            ObjectKind::ToolResult
736        );
737        assert_eq!(ObjectKind::from(HandleKind::MemoryPage), ObjectKind::Memory);
738        assert_eq!(
739            ObjectKind::from(HandleKind::KnowledgeEntry),
740            ObjectKind::Knowledge
741        );
742        assert_eq!(
743            ObjectKind::from(HandleKind::SubAgentJoin),
744            ObjectKind::Custom(CompactString::from("sub_agent_join"))
745        );
746    }
747
748    #[test]
749    fn spc_006_06_external_descriptor_carries_a_preview_and_locator_never_the_full_body() {
750        let full_report = "x".repeat(1_200_000);
751        let descriptor = ObjectDescriptor::external(
752            7,
753            ObjectKind::Artifact,
754            crate::scheduler::tcb::TaskId::from("agent-a"),
755            1,
756            Residency::External {
757                payload_ref: "payload:research-report".to_string(),
758                digest: "sha256:deadbeef".to_string(),
759                original_size: full_report.len() as u64,
760            },
761            &full_report[..200],
762        );
763
764        // The descriptor's own type has no `payload`/`content` field — a caller can only ever see
765        // the four fields below. `size` still reports the true full-body size (so a reader knows
766        // what a page-in would cost), but the descriptor never carries that many bytes itself.
767        assert_eq!(
768            descriptor.payload_ref.as_deref(),
769            Some("payload:research-report")
770        );
771        assert_eq!(descriptor.digest, "sha256:deadbeef");
772        assert_eq!(descriptor.size, 1_200_000);
773        assert_eq!(descriptor.preview.as_deref(), Some(&full_report[..200]));
774        assert!(
775            descriptor.preview.as_ref().unwrap().len() < descriptor.size as usize,
776            "the preview must be far smaller than the full body it stands in for"
777        );
778        assert!(matches!(descriptor.residency, Residency::External { .. }));
779    }
780
781    #[test]
782    fn spc_009_07_a_capability_outside_the_objects_resource_denies_access() {
783        // Plan §8 / spc_009-07: "Capability controls object access" — task A holds a capability
784        // scoped to a *different* object than the one it tries to read on task B. Before this
785        // card there was no function at all that could answer this question (no matching call
786        // point existed anywhere in the crate), so this — an unconditional `false` — is what "the
787        // check doesn't exist" looked like; now it's a real, reasoned denial.
788        use crate::types::capability::{
789            ActionSet, Capability, CapabilityId, CapabilityKind, ConstraintSet, Principal,
790            ResourceSelector,
791        };
792
793        let b_object = ObjectDescriptor::external(
794            42,
795            ObjectKind::Artifact,
796            crate::scheduler::tcb::TaskId::from("task-b"),
797            1,
798            Residency::External {
799                payload_ref: "payload:b-report".to_string(),
800                digest: "sha256:deadbeef".to_string(),
801                original_size: 1_000,
802            },
803            "preview",
804        );
805
806        // A's capability names a *different* object entirely (task-b's object 7, not 42).
807        let a_capability = Capability {
808            id: CapabilityId("cap-a".into()),
809            kind: CapabilityKind::Tool,
810            resource: ResourceSelector("object:task-b/7".into()),
811            actions: ActionSet(["read".into()].into_iter().collect()),
812            constraints: ConstraintSet::default(),
813            lease: None,
814            delegatable: true,
815            issuer: Principal("task-a".into()),
816        };
817
818        assert!(
819            !object_access_allowed(&[a_capability], "read", &b_object),
820            "a capability scoped to a different object must not authorize this one"
821        );
822    }
823
824    #[test]
825    fn spc_009_07_a_matching_capability_allows_the_requested_action() {
826        use crate::types::capability::{
827            ActionSet, Capability, CapabilityId, CapabilityKind, ConstraintSet, Principal,
828            ResourceSelector,
829        };
830
831        let b_object = ObjectDescriptor::external(
832            42,
833            ObjectKind::Artifact,
834            crate::scheduler::tcb::TaskId::from("task-b"),
835            1,
836            Residency::External {
837                payload_ref: "payload:b-report".to_string(),
838                digest: "sha256:deadbeef".to_string(),
839                original_size: 1_000,
840            },
841            "preview",
842        );
843
844        let a_capability = Capability {
845            id: CapabilityId("cap-a".into()),
846            kind: CapabilityKind::Tool,
847            resource: ResourceSelector("object:task-b/42".into()),
848            actions: ActionSet(["read".into()].into_iter().collect()),
849            constraints: ConstraintSet::default(),
850            lease: None,
851            delegatable: true,
852            issuer: Principal("task-a".into()),
853        };
854
855        assert!(
856            object_access_allowed(std::slice::from_ref(&a_capability), "read", &b_object),
857            "a capability naming this exact object and the requested action must authorize it"
858        );
859        assert!(
860            !object_access_allowed(std::slice::from_ref(&a_capability), "write", &b_object),
861            "the same capability must not authorize an action it never granted"
862        );
863    }
864
865    #[test]
866    fn exact_object_capability_does_not_match_an_adjacent_id_prefix() {
867        use crate::types::capability::{
868            ActionSet, Capability, CapabilityId, CapabilityKind, ConstraintSet, Principal,
869            ResourceSelector,
870        };
871
872        let object = ObjectDescriptor::external(
873            77,
874            ObjectKind::Artifact,
875            crate::scheduler::tcb::TaskId::from("owner"),
876            1,
877            Residency::External {
878                payload_ref: "payload:77".to_string(),
879                digest: "sha256:77".to_string(),
880                original_size: 10,
881            },
882            "preview",
883        );
884        let capability = Capability {
885            id: CapabilityId("read-7".into()),
886            kind: CapabilityKind::Tool,
887            resource: ResourceSelector("object:owner/7".into()),
888            actions: ActionSet(["read".into()].into_iter().collect()),
889            constraints: ConstraintSet::default(),
890            lease: None,
891            delegatable: false,
892            issuer: Principal("owner".into()),
893        };
894
895        assert!(!object_access_allowed(&[capability], "read", &object));
896    }
897}