Skip to main content

deepstrike_core/context/
manager.rs

1use super::compression::CompressionPipeline;
2use super::config::ContextConfig;
3use super::partitions::ContextPartitions;
4use super::pressure::{PressureAction, PressureMonitor};
5use super::renderer::RenderedContext;
6use super::renewal::{HandoffArtifact, RenewalPolicy};
7use super::sections::{ContextSectionPartition, ContextSectionRegistry};
8use super::snapshot::{ContextSnapshotHint, ContextSnapshot};
9use super::skill_catalog::SkillCatalog;
10use super::task_state::{TaskState, TaskUpdate};
11use super::token_engine::ContextTokenEngine;
12use crate::mm::handle::{Handle, HandleId, HandleKind, HandleTable, Residency};
13use crate::types::capability::{CapabilityKind, CapabilityManifest};
14use crate::types::message::{Content, ContentPart, Message, ToolSchema};
15use crate::types::skill::SkillMetadata;
16use compact_str::CompactString;
17
18pub const MEMORY_TOOL_NAME: &str = "memory";
19pub const KNOWLEDGE_TOOL_NAME: &str = "knowledge";
20
21/// Control-plane meta-tools: kernel-handled tools that drive state/capabilities rather than do task
22/// work. Excluded from the `recent_actions` progress log (2b) so the footer reflects real progress.
23const META_TOOL_NAMES: &[&str] = &[
24    "update_plan",
25    "skill",
26    MEMORY_TOOL_NAME,
27    KNOWLEDGE_TOOL_NAME,
28    "submit_workflow_nodes",
29    "start_workflow",
30];
31
32fn is_meta_tool(name: &str) -> bool {
33    META_TOOL_NAMES.contains(&name)
34}
35
36/// Internal context engine backing [`crate::runtime::KernelRuntime`].
37///
38/// Exposed for in-crate use and tests; external callers should drive the kernel
39/// through `KernelRuntime` rather than this type directly.
40#[doc(hidden)]
41pub struct ContextManager {
42    pub partitions: ContextPartitions,
43    pub max_tokens: u32,
44    pub config: ContextConfig,
45    pub engine: ContextTokenEngine,
46    pub sprint: u32,
47    pub last_handoff: Option<HandoffArtifact>,
48    pub skills: SkillCatalog,
49    /// P1-B tool gating: the set of skills the model has loaded this session (by name). Their
50    /// declared `allowed_tools` are unioned to narrow the exposed toolset in `emit_call_llm`.
51    /// A set (not a single value) because the model may load several skills and still needs each
52    /// one's tools (D1). v1 accumulates (no eviction). Snapshotted for wake/resume.
53    pub active_skills: std::collections::BTreeSet<CompactString>,
54    /// P1-B/D stable-core: tool ids that stay exposed even when a skill narrows the toolset (the
55    /// "everyone uses these" set — read/search/bash etc.). Configured once by the SDK; empty by
56    /// default (铁律: no config ⇒ skills narrow to exactly their declared tools + meta-tools).
57    pub stable_core_tools: std::collections::HashSet<CompactString>,
58    pub capabilities: CapabilityManifest,
59    pub sections: ContextSectionRegistry,
60    pub memory_enabled: bool,
61    pub knowledge_enabled: bool,
62    pub plan_tool_enabled: bool,
63    last_observed_prompt_tokens: Option<u32>,
64    compression: CompressionPipeline,
65    pressure: PressureMonitor,
66    renewal: RenewalPolicy,
67
68    // ── Layer 3: Time tracking for decay ─────────────────────────────────
69
70    /// Last activity timestamp (milliseconds since epoch).
71    /// Updated on each ProviderResult and ToolResults.
72    pub last_activity_ms: u64,
73
74    /// Last compression timestamp (milliseconds since epoch).
75    /// Updated on each compression pass.
76    pub last_compact_ms: Option<u64>,
77
78    // ── P3: handle table (context as address space) ─────────────────────────
79
80    /// Per-task handle table: one [`Handle`] per addressable working-context object (tool results
81    /// today). Residency transitions on these handles drive read-time projection (Layer 4) and
82    /// spool (Layer 1) — the original messages in `partitions` are never mutated by projection.
83    pub handles: HandleTable,
84    /// Monotonic allocator for [`HandleId`]s.
85    next_handle_id: HandleId,
86
87    /// P1-E: history length (message count) as of the last compaction/renewal. Messages below this
88    /// index are the **frozen prefix** — byte-stable until the next compaction — so the renderer can
89    /// hand providers a `frozen_prefix_len` for a long-lived deep cache breakpoint. 0 before any
90    /// compaction (no frozen region yet). Not snapshotted: on resume it resets to 0 and rebuilds at
91    /// the next compaction (graceful — only the deep-cache durability lapses, never correctness).
92    frozen_history_len: usize,
93}
94
95impl ContextManager {
96    pub fn new(max_tokens: u32) -> Self {
97        Self::with_config(max_tokens, ContextConfig::default(), ContextTokenEngine::char_approx())
98    }
99
100    pub fn with_config(max_tokens: u32, config: ContextConfig, engine: ContextTokenEngine) -> Self {
101        let compression = CompressionPipeline::new(&config);
102        let pressure = PressureMonitor::new(max_tokens, config.clone());
103        let renewal = RenewalPolicy::from_config(&config);
104        let partitions = ContextPartitions::new(&config);
105        Self {
106            partitions, max_tokens, config, engine,
107            sprint: 0, last_handoff: None,
108            skills: SkillCatalog::new(),
109            active_skills: std::collections::BTreeSet::new(),
110            stable_core_tools: std::collections::HashSet::new(),
111            capabilities: CapabilityManifest::new(),
112            sections: ContextSectionRegistry::default_agent_sections(),
113            memory_enabled: false, knowledge_enabled: false, plan_tool_enabled: false,
114            last_observed_prompt_tokens: None,
115            compression, pressure, renewal,
116            last_activity_ms: 0,
117            last_compact_ms: None,
118            handles: HandleTable::new(),
119            next_handle_id: 0,
120            frozen_history_len: 0,
121        }
122    }
123
124    // ── Layer 3: Time-based decay ─────────────────────────────────────────────
125
126    /// Update activity timestamp (call on each ProviderResult and ToolResults).
127    pub fn record_activity(&mut self, now_ms: u64) {
128        self.last_activity_ms = now_ms;
129    }
130
131    /// Check if Micro-Compact should trigger based on time decay (Layer 3).
132    /// Returns true if idle time exceeds `micro_compact_idle_minutes`.
133    pub fn should_time_decay_compact(&self, now_ms: u64) -> bool {
134        let idle_ms = if let Some(last_compact) = self.last_compact_ms {
135            // Time since last compression
136            now_ms.saturating_sub(last_compact)
137        } else {
138            // Time since first activity
139            now_ms.saturating_sub(self.last_activity_ms)
140        };
141
142        let idle_minutes = idle_ms / 60_000;
143        idle_minutes >= self.config.micro_compact_idle_minutes as u64
144    }
145
146    // ── Layer 4: read-time projection (handle residency) ────────────────────
147
148    /// Recompute tool-result handle residency for Layer-4 read-time projection (call before
149    /// `render`). When pressure (`rho`) reaches `collapse_threshold`, all but the most recent
150    /// `preserve_recent_msgs` tool results are marked `Collapsed` (rendered as previews).
151    ///
152    /// **Monotonic within a cache generation (P0-C):** collapse is one-way here —
153    /// `Resident → Collapsed` only, never the reverse. The old two-way version un-collapsed when
154    /// `rho` fell back below the threshold, which (a) rewrote mid-history bytes and invalidated the
155    /// prompt-cache prefix on every threshold oscillation, and (b) re-billed a full tool-result body
156    /// for near-zero attention gain (an old result that already faded). Un-collapsing now happens
157    /// only at compaction/renewal boundaries via [`Self::reset_collapse_generation`] — the one moment
158    /// the prefix is rewritten anyway, so the cache cost is already paid. Non-destructive:
159    /// `partitions` is untouched. Spooled/paged-out handles are left as-is.
160    pub fn recompute_handle_residency(&mut self) {
161        // Monotonic: below the threshold we never *un*-collapse, so there is nothing to do.
162        if self.rho() < self.config.collapse_threshold {
163            return;
164        }
165        let keep = self.config.preserve_recent_msgs;
166        // Single mutable pass in insertion order. `tool_result_handles_mut().enumerate()` yields the
167        // collapse candidates oldest-first; `i < cutoff` protects the most recent `keep` results.
168        let total = self
169            .handles
170            .all()
171            .iter()
172            .filter(|h| matches!(h.kind, HandleKind::ToolResult))
173            .count();
174        let cutoff = total.saturating_sub(keep);
175        for (i, handle) in self.handles.tool_result_handles_mut().enumerate() {
176            // Only fold the reversible Resident → Collapsed axis; never clobber a handle that has
177            // been spooled or paged out, and never reverse an existing collapse mid-generation.
178            if i < cutoff && matches!(handle.residency, Residency::Resident) {
179                handle.residency = Residency::Collapsed;
180            }
181        }
182    }
183
184    /// Start a fresh collapse generation: un-collapse every `Collapsed` handle back to `Resident`.
185    /// Called only at compaction/renewal boundaries — the sole points where un-collapsing is
186    /// cache-free, since the rendered prefix is rewritten there regardless. Between boundaries
187    /// [`Self::recompute_handle_residency`] keeps collapse strictly one-way (P0-C). Spooled/paged-out
188    /// handles are untouched (they leave the Resident↔Collapsed cycle deliberately).
189    pub fn reset_collapse_generation(&mut self) {
190        for handle in self.handles.all_mut() {
191            if matches!(handle.residency, Residency::Collapsed) {
192                handle.residency = Residency::Resident;
193            }
194        }
195    }
196
197    /// Drop handles whose anchored source message no longer lives in `partitions.history` — i.e.
198    /// archived by a compaction or dropped on renewal. Without this the handle table grows with
199    /// total session length (a handle per tool result, never removed), which also inflates the
200    /// per-turn `recompute_handle_residency` scan. Called at compaction/renewal boundaries, so the
201    /// table tracks the working set, not the whole session. Handles with no `source` anchor (future
202    /// non-tool-result kinds) are always kept — they can't be orphaned by this check.
203    pub fn prune_orphaned_handles(&mut self) {
204        let live: std::collections::HashSet<CompactString> = self
205            .partitions
206            .history
207            .messages
208            .iter()
209            .flat_map(|m| match &m.content {
210                Content::Parts(parts) => parts
211                    .iter()
212                    .filter_map(|p| match p {
213                        ContentPart::ToolResult { call_id, .. } => Some(call_id.clone()),
214                        _ => None,
215                    })
216                    .collect::<Vec<_>>(),
217                _ => Vec::new(),
218            })
219            .collect();
220        self.handles
221            .retain(|h| h.source.as_ref().is_none_or(|s| live.contains(s)));
222    }
223
224    /// Mark the handle anchored to `call_id` as spooled to disk (Layer 1): the SDK persists the
225    /// full output, working context keeps only the preview. Keeps the handle out of the
226    /// Resident↔Collapsed projection cycle. No-op if no handle is anchored to `call_id`.
227    pub fn mark_spooled(&mut self, call_id: &str, spool_ref: impl Into<String>) {
228        let spool_ref = spool_ref.into();
229        if let Some(handle) = self
230            .handles
231            .all_mut()
232            .iter_mut()
233            .find(|h| h.source.as_deref() == Some(call_id))
234        {
235            handle.residency = Residency::SpooledOut { r: spool_ref };
236        }
237    }
238
239    // ── Pressure ──────────────────────────────────────────────────────────────
240
241    /// **Raw** rho — full partition weight (or provider-observed tokens when available). This is the
242    /// projection-decision rho: [`Self::recompute_handle_residency`] marks the Resident↔Collapsed set
243    /// from *this* value, so it must NOT discount paged content (else collapse → rho drops →
244    /// un-collapse would oscillate). Compaction/renewal triggers use [`Self::effective_rho`] instead.
245    pub fn rho(&self) -> f64 {
246        self.pressure
247            .pressure(&self.partitions, &self.engine, self.last_observed_prompt_tokens)
248    }
249
250    /// **Effective** rho — the pressure that actually drives compaction/renewal, made paging-aware.
251    ///
252    /// When provider usage is authoritative (`observed_prompt_tokens` set), the rendered prompt was
253    /// already collapsed (the renderer emits previews for `Collapsed` handles), so the observed count
254    /// already reflects paging — raw rho is exact and returned as-is. In the **estimate** path
255    /// (no observed tokens) we estimate from `partitions`, which still carry the full weight of
256    /// paged-out tool results (collapse is non-destructive); we subtract the non-resident handle
257    /// tokens so that collapsing/spooling a result immediately relieves pressure, rather than only
258    /// after the next provider round-trip. With no paged handles this equals [`Self::rho`], so the
259    /// pre-paging behavior is preserved exactly.
260    pub fn effective_rho(&self) -> f64 {
261        if self.max_tokens == 0 || self.last_observed_prompt_tokens.is_some() {
262            return self.rho();
263        }
264        let total = self.partitions.total_tokens(&self.engine);
265        let effective = total.saturating_sub(self.handles.non_resident_tokens());
266        effective as f64 / self.max_tokens as f64
267    }
268
269    pub fn set_observed_prompt_tokens(&mut self, tokens: u32) {
270        self.last_observed_prompt_tokens = Some(tokens);
271    }
272
273    pub fn should_compress(&self) -> PressureAction {
274        // Compaction-tier recommendation runs on **raw** rho. The paging-aware `effective_rho` was
275        // wired here during W1-1 but it over-relieved pressure: once `micro_compact` paged out
276        // tool-result handles, effective rho fell below the collapse/auto_compact thresholds, so the
277        // heavy tiers never fired — violating W1-1's own DoD ("既有压缩 golden 不变" /
278        // "AutoCompact 后 wake 注入语义摘要"). Until the full cache-aware planner lands (the planner
279        // that scores prefix-invalidation per op, `effective_rho` reserved for it), the tier trigger
280        // must use raw rho so escalation is preserved. `effective_rho` stays defined + tested for
281        // that work; it is intentionally not consulted by the trigger today.
282        self.pressure.recommend(self.rho())
283    }
284
285    pub fn compress(&mut self, action: PressureAction) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
286        self.compress_with_time(action, None)
287    }
288
289    pub fn compress_with_time(
290        &mut self,
291        action: PressureAction,
292        now_ms: Option<u64>,
293    ) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
294        if self.sections.is_partition_pinned(ContextSectionPartition::History) {
295            return (0, None, vec![], None);
296        }
297
298        let result = {
299            let target = self.config.target_tokens(self.max_tokens);
300            self.compression.compress(&mut self.partitions, action, self.max_tokens, target, &self.engine)
301        };
302
303        // Record compression timestamp if provided
304        if let Some(ts) = now_ms {
305            self.last_compact_ms = Some(ts);
306        }
307
308        // Archived messages have left history — drop their now-orphaned handles (bounds the table).
309        if !result.2.is_empty() {
310            self.prune_orphaned_handles();
311            // Compaction rewrote the history prefix — start a fresh collapse generation so
312            // surviving handles re-evaluate from Resident (P0-C: the one cache-free un-collapse point).
313            self.reset_collapse_generation();
314        }
315        // P2-D × P1-E: re-anchor the frozen-prefix boundary only when the compaction actually broke
316        // the prompt-cache prefix (`result.3` = the planner's per-step `cache_at` cost, `Some` ⇒ a
317        // prefix break). A prefix-safe compaction (late Snip/Excerpt that touches no early message)
318        // leaves `[0..frozen]` byte-stable, so the deep cache survives the compaction and the boundary
319        // holds — strictly more precise than the old `archived`-keyed reset, which missed an early
320        // in-place Snip and needlessly re-anchored after a prefix-safe pass.
321        if result.3.is_some() {
322            self.frozen_history_len = self.partitions.history.messages.len();
323        }
324
325        result
326    }
327
328    pub fn force_compress(&mut self) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
329        if self.sections.is_partition_pinned(ContextSectionPartition::History) {
330            return (0, None, vec![], None);
331        }
332        let result = self.compression.compress(&mut self.partitions, PressureAction::AutoCompact, self.max_tokens, 0, &self.engine);
333        if !result.2.is_empty() {
334            self.prune_orphaned_handles();
335            // Compaction rewrote the history prefix — start a fresh collapse generation so
336            // surviving handles re-evaluate from Resident (P0-C: the one cache-free un-collapse point).
337            self.reset_collapse_generation();
338        }
339        // P2-D × P1-E: re-anchor the frozen-prefix boundary only when the compaction actually broke
340        // the prompt-cache prefix (`result.3` = the planner's per-step `cache_at` cost, `Some` ⇒ a
341        // prefix break). A prefix-safe compaction (late Snip/Excerpt that touches no early message)
342        // leaves `[0..frozen]` byte-stable, so the deep cache survives the compaction and the boundary
343        // holds — strictly more precise than the old `archived`-keyed reset, which missed an early
344        // in-place Snip and needlessly re-anchored after a prefix-safe pass.
345        if result.3.is_some() {
346            self.frozen_history_len = self.partitions.history.messages.len();
347        }
348        result
349    }
350
351    /// W1-1 收口: run one compaction `action` toward an **explicit** `target_tokens`, instead of
352    /// re-deriving the target from config. This is what lets `EvictionOp::Collapse { target_tokens }`
353    /// flow from the planner (the single decision point) straight to the executor — the compactor no
354    /// longer re-decides the target. `compress_with_time` remains the config-derived convenience used
355    /// by the other layers (Snip/Micro), whose target equals `config.target_tokens(max_tokens)`.
356    pub fn compress_with_target(
357        &mut self,
358        action: PressureAction,
359        target_tokens: u32,
360        now_ms: Option<u64>,
361    ) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
362        if self.sections.is_partition_pinned(ContextSectionPartition::History) {
363            return (0, None, vec![], None);
364        }
365        let result =
366            self.compression
367                .compress(&mut self.partitions, action, self.max_tokens, target_tokens, &self.engine);
368        if let Some(ts) = now_ms {
369            self.last_compact_ms = Some(ts);
370        }
371        if !result.2.is_empty() {
372            self.prune_orphaned_handles();
373            // Compaction rewrote the history prefix — start a fresh collapse generation so
374            // surviving handles re-evaluate from Resident (P0-C: the one cache-free un-collapse point).
375            self.reset_collapse_generation();
376        }
377        // P2-D × P1-E: re-anchor the frozen-prefix boundary only when the compaction actually broke
378        // the prompt-cache prefix (`result.3` = the planner's per-step `cache_at` cost, `Some` ⇒ a
379        // prefix break). A prefix-safe compaction (late Snip/Excerpt that touches no early message)
380        // leaves `[0..frozen]` byte-stable, so the deep cache survives the compaction and the boundary
381        // holds — strictly more precise than the old `archived`-keyed reset, which missed an early
382        // in-place Snip and needlessly re-anchored after a prefix-safe pass.
383        if result.3.is_some() {
384            self.frozen_history_len = self.partitions.history.messages.len();
385        }
386        result
387    }
388
389    /// W1-1 收口: the truthful compaction parameters the planner stamps into the [`EvictionPlan`],
390    /// read once from config so the ops carry real values (not magic-number placeholders) and the
391    /// executor stays a pure executor. Returns `(target_tokens, preserve_recent_turns)`.
392    pub fn plan_compaction_params(&self) -> (u32, usize) {
393        (
394            self.config.target_tokens(self.max_tokens),
395            self.config.preserve_recent_turns,
396        )
397    }
398
399    // ── Renewal ───────────────────────────────────────────────────────────────
400
401    pub fn should_renew(&self) -> bool {
402        self.renewal.should_renew(&self.pressure, &self.partitions, &self.engine)
403    }
404
405    pub fn renew(&mut self) {
406        let goal = self.partitions.task_state.goal.clone();
407        let (renewed, artifact) = self.renewal.renew(&self.partitions, &goal, self.sprint, self.max_tokens);
408        self.partitions = renewed;
409        self.last_handoff = Some(artifact);
410        self.sprint += 1;
411        // History was rebuilt wholesale — drop handles anchored to messages it no longer carries,
412        // and start a fresh collapse generation (P0-C) since the whole prefix changed.
413        self.prune_orphaned_handles();
414        self.reset_collapse_generation();
415        // P1-E: the renewed history is the new frozen base.
416        self.frozen_history_len = self.partitions.history.messages.len();
417    }
418
419    // ── Render ────────────────────────────────────────────────────────────────
420
421    pub fn render(&self) -> RenderedContext {
422        super::renderer::render_projected(
423            &self.partitions,
424            self.max_tokens,
425            &self.engine,
426            self.config.preserve_recent_msgs,
427            &self.handles,
428            self.frozen_history_len,
429            self.config.collapse_assistant_narration,
430        )
431    }
432
433    pub fn snapshot_hint(&self) -> ContextSnapshotHint {
434        ContextSnapshotHint::from_parts(&self.sections, &self.capabilities)
435    }
436
437    pub fn take_snapshot(&self, turn: u32) -> ContextSnapshot {
438        ContextSnapshot {
439            turn,
440            system_messages: self.partitions.system.messages.clone(),
441            knowledge_messages: self.partitions.knowledge.messages.clone(),
442            history_messages: self.partitions.history.messages.clone(),
443            task_state: self.partitions.task_state.clone(),
444        }
445    }
446
447    // ── History / Knowledge ───────────────────────────────────────────────────
448
449    pub fn push_history(&mut self, msg: Message, tokens: u32) {
450        // P3 (3a): index each tool result entering working context as a handle, anchored to its
451        // call_id. Pure bookkeeping — render/compression still read `partitions` until 3b. The
452        // handle's residency later drives read-time projection without mutating the message.
453        if let Content::Parts(parts) = &msg.content {
454            for part in parts {
455                if let ContentPart::ToolResult { call_id, output, .. } = part {
456                    let id = self.alloc_handle_id();
457                    let tok = self.engine.count(output).max(1);
458                    self.handles.insert(Handle::resident_for(
459                        id,
460                        HandleKind::ToolResult,
461                        tok,
462                        call_id.clone(),
463                    ));
464                }
465            }
466        }
467        self.partitions.history.push(msg, tokens);
468    }
469
470    fn alloc_handle_id(&mut self) -> HandleId {
471        let id = self.next_handle_id;
472        self.next_handle_id = self.next_handle_id.wrapping_add(1);
473        id
474    }
475
476    /// Push content into the Knowledge slot (memory retrievals, skill defs, artifacts).
477    pub fn push_knowledge(&mut self, msg: Message, tokens: u32) {
478        self.partitions.knowledge.push(msg, tokens);
479    }
480
481    /// Push a runtime signal into the current turn's State slot.
482    /// Signals are ephemeral — cleared after each render.
483    pub fn push_signal(&mut self, text: String) {
484        self.partitions.signals.push(text);
485    }
486
487    /// Record a durable user directive in the (non-compressible, renewal-carried) task_state, so a
488    /// mid-task user command keeps its salience across compaction/renewal — unlike the ephemeral
489    /// signal channel, which is cleared on renewal.
490    pub fn record_directive(&mut self, text: impl Into<String>) {
491        self.partitions.task_state.record_directive(text);
492    }
493
494    // ── Task state ────────────────────────────────────────────────────────────
495
496    pub fn init_task(&mut self, goal: String, criteria: Vec<String>) {
497        self.partitions.task_state = TaskState { goal, criteria, ..Default::default() };
498    }
499
500    pub fn update_task(&mut self, update: TaskUpdate) {
501        self.partitions.task_state.apply(update);
502    }
503
504    /// 2b: record this turn's tool activity into the task-state recency log (kernel-derived progress
505    /// that feeds the State-turn footer). Each entry is `(name, compact_args)`; the rendered signature
506    /// is `name(args)` (or bare `name` for no-arg calls) so the no-progress STOP keys on the WHOLE
507    /// call — same tool with different args (a legit loop over items) reads as distinct progress, not
508    /// a repeat. Control-plane meta-tools (plan/skill/memory/knowledge/workflow authoring) are noise,
509    /// not task progress — filtered by name. A turn with only meta-tool calls records nothing.
510    pub fn note_tool_actions(&mut self, calls: &[(String, String)]) {
511        let summary = calls
512            .iter()
513            .filter(|(name, _)| !is_meta_tool(name))
514            .map(|(name, args)| {
515                if args.is_empty() { name.clone() } else { format!("{name}({args})") }
516            })
517            .collect::<Vec<_>>()
518            .join(", ");
519        self.partitions.task_state.note_actions(summary);
520    }
521
522    // ── Section pinning ───────────────────────────────────────────────────────
523
524    pub fn pin_section(&mut self, id: &str) -> bool { self.sections.pin(id) }
525    pub fn unpin_section(&mut self, id: &str) -> bool { self.sections.unpin(id) }
526
527    // ── Skills ────────────────────────────────────────────────────────────────
528
529    pub fn set_available_skills(&mut self, skills: Vec<SkillMetadata>) {
530        self.capabilities.remove_kind(CapabilityKind::Skill);
531        for skill in &skills { self.capabilities.add_skill(skill.clone()); }
532        self.skills.set_available(skills);
533    }
534
535    /// P1-B/D: set the stable-core tool ids (always exposed under skill gating). Replaces any prior.
536    pub fn set_stable_core_tools(&mut self, ids: impl IntoIterator<Item = CompactString>) {
537        self.stable_core_tools = ids.into_iter().collect();
538    }
539
540    /// P1-B: record that the model has loaded a skill (its content is now in context). Returns
541    /// `true` if this changed the active set — an epoch boundary the SDK can use to re-anchor the
542    /// prompt cache (D). No-op (returns false) when the skill was already active.
543    pub fn activate_skill(&mut self, name: impl Into<CompactString>) -> bool {
544        self.active_skills.insert(name.into())
545    }
546
547    /// P1-B: the tool-id allow-set to narrow the exposed toolset to, given the active skills.
548    /// Returns `None` ⇒ **do not narrow** (no skill active, or some active skill declares no
549    /// `allowed_tools` ⇒ unbounded, errs-open per D3). `Some(set)` ⇒ narrow to `set` (the union of
550    /// every active skill's declared tools). Meta-tools and stable-core are layered on in
551    /// `emit_call_llm`, not here.
552    pub fn active_skill_tool_filter(&self) -> Option<std::collections::HashSet<CompactString>> {
553        if self.active_skills.is_empty() {
554            return None;
555        }
556        let mut union = std::collections::HashSet::new();
557        for name in &self.active_skills {
558            let declared = self.skills.allowed_tools(name);
559            if declared.is_empty() {
560                return None; // an unrestricted active skill ⇒ no narrowing (D3)
561            }
562            union.extend(declared.iter().cloned());
563        }
564        Some(union)
565    }
566
567    pub fn skill_tool_schema(&self) -> Option<ToolSchema> {
568        self.skills.build_tool_schema()
569    }
570
571    // ── Meta-tools ────────────────────────────────────────────────────────────
572
573    pub fn set_memory_enabled(&mut self, enabled: bool) {
574        self.memory_enabled = enabled;
575        if enabled {
576            self.capabilities.add_marker(CapabilityKind::Memory, MEMORY_TOOL_NAME,
577                "Search long-term memory through the memory meta-tool.");
578        } else {
579            self.capabilities.remove(CapabilityKind::Memory, MEMORY_TOOL_NAME);
580        }
581    }
582
583    pub fn set_knowledge_enabled(&mut self, enabled: bool) {
584        self.knowledge_enabled = enabled;
585        if enabled {
586            self.capabilities.add_marker(CapabilityKind::Knowledge, KNOWLEDGE_TOOL_NAME,
587                "Search external knowledge through the knowledge meta-tool.");
588        } else {
589            self.capabilities.remove(CapabilityKind::Knowledge, KNOWLEDGE_TOOL_NAME);
590        }
591    }
592
593    pub fn set_plan_tool_enabled(&mut self, enabled: bool) {
594        self.plan_tool_enabled = enabled;
595        if enabled {
596            self.capabilities.add_marker(CapabilityKind::Tool, "update_plan",
597                "Update task plan and progress through the planning meta-tool.");
598        } else {
599            self.capabilities.remove(CapabilityKind::Tool, "update_plan");
600        }
601    }
602
603    pub fn capability_inventory(&self) -> String { self.capabilities.format_inventory() }
604
605    pub fn meta_tool_schemas(&self) -> Vec<ToolSchema> {
606        let mut tools = Vec::new();
607        if let Some(t) = self.skill_tool_schema() { tools.push(t); }
608        if let Some(t) = self.memory_tool_schema() { tools.push(t); }
609        if let Some(t) = self.knowledge_tool_schema() { tools.push(t); }
610        if let Some(t) = self.plan_tool_schema() { tools.push(t); }
611        tools.sort_by(|a, b| a.name.cmp(&b.name));
612        tools
613    }
614
615    pub fn plan_tool_schema(&self) -> Option<ToolSchema> {
616        if !self.plan_tool_enabled { return None; }
617        Some(ToolSchema {
618            name: CompactString::new("update_plan"),
619            description: "Update your task plan and progress. Call this after completing a step or when the plan changes.".to_string(),
620            parameters: serde_json::json!({
621                "type": "object",
622                "properties": {
623                    "plan": { "type": "array", "items": { "type": "string" } },
624                    "current_step": { "type": "integer" },
625                    "progress": { "type": "string" },
626                    "blocked_on": { "type": "array", "items": { "type": "string" } }
627                }
628            }),
629        })
630    }
631
632    pub fn memory_tool_schema(&self) -> Option<ToolSchema> {
633        if !self.memory_enabled { return None; }
634        Some(ToolSchema {
635            name: CompactString::new(MEMORY_TOOL_NAME),
636            description: "Search your long-term memory for relevant past experiences and knowledge.".to_string(),
637            parameters: serde_json::json!({
638                "type": "object",
639                "properties": {
640                    "query": { "type": "string" },
641                    "top_k": { "type": "integer" }
642                },
643                "required": ["query"]
644            }),
645        })
646    }
647
648    pub fn knowledge_tool_schema(&self) -> Option<ToolSchema> {
649        if !self.knowledge_enabled { return None; }
650        Some(ToolSchema {
651            name: CompactString::new(KNOWLEDGE_TOOL_NAME),
652            description: "Search the external knowledge base for facts, documentation, or reference data.".to_string(),
653            parameters: serde_json::json!({
654                "type": "object",
655                "properties": {
656                    "query": { "type": "string" },
657                    "top_k": { "type": "integer" }
658                },
659                "required": ["query"]
660            }),
661        })
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668    use crate::context::task_state::PlanStep;
669    use crate::types::message::Message;
670    use crate::types::skill::SkillMetadata;
671
672    #[test]
673    fn note_tool_actions_keys_on_name_and_args_so_legit_loops_dont_false_stop() {
674        // Same tool, DIFFERENT args across turns = real progress (e.g. process item 1, 2, 3) —
675        // must NOT trip the no-progress STOP backstop.
676        let mut mgr = ContextManager::new(100_000);
677        mgr.init_task("process items".to_string(), vec![]);
678        mgr.note_tool_actions(&[("step".to_string(), "{\"n\":1}".to_string())]);
679        mgr.note_tool_actions(&[("step".to_string(), "{\"n\":2}".to_string())]);
680        mgr.note_tool_actions(&[("step".to_string(), "{\"n\":3}".to_string())]);
681        assert_eq!(
682            mgr.partitions.task_state.recent_actions,
683            ["step({\"n\":1})", "step({\"n\":2})", "step({\"n\":3})"]
684        );
685        let txt = mgr.render().state_turn.unwrap().content.as_text().unwrap().to_string();
686        assert!(!txt.contains("STOP:"), "same-tool/diff-args loop must not trip STOP: {txt}");
687
688        // Genuine stall — same tool, SAME args repeated — DOES trip the STOP.
689        let mut mgr2 = ContextManager::new(100_000);
690        mgr2.init_task("g".to_string(), vec![]);
691        for _ in 0..3 {
692            mgr2.note_tool_actions(&[("document_read".to_string(), "{\"id\":\"x\"}".to_string())]);
693        }
694        let txt2 = mgr2.render().state_turn.unwrap().content.as_text().unwrap().to_string();
695        assert!(txt2.contains("STOP:"), "identical repeated call must trip STOP: {txt2}");
696
697        // Meta-tools are control plane, not task progress — filtered out entirely.
698        let mut mgr3 = ContextManager::new(100_000);
699        mgr3.init_task("g".to_string(), vec![]);
700        mgr3.note_tool_actions(&[("update_plan".to_string(), "{\"current_step\":1}".to_string())]);
701        assert!(mgr3.partitions.task_state.recent_actions.is_empty());
702    }
703
704    #[test]
705    fn manager_renew_uses_task_state_goal() {
706        let mut mgr = ContextManager::new(1_000);
707        mgr.init_task("test goal".to_string(), vec![]);
708        mgr.partitions.system.push(Message::system("rules"), 10);
709        for i in 0..10 { mgr.push_history(Message::user(format!("msg {i}")), 50); }
710        mgr.renew();
711        let artifact = mgr.last_handoff.as_ref().unwrap();
712        assert_eq!(artifact.goal, "test goal");
713        assert_eq!(mgr.sprint, 1);
714    }
715
716    #[test]
717    fn compress_only_touches_history() {
718        let mut mgr = ContextManager::new(1_000);
719        mgr.push_knowledge(Message::system("knowledge content"), 100);
720        for _ in 0..30 { mgr.push_history(Message::user("history msg"), 50); }
721        let knowledge_before = mgr.partitions.knowledge.token_count;
722        let history_before = mgr.partitions.history.token_count;
723        mgr.compress(PressureAction::AutoCompact);
724        assert_eq!(mgr.partitions.knowledge.token_count, knowledge_before);
725        assert!(mgr.partitions.history.token_count < history_before);
726    }
727
728    #[test]
729    fn init_task_sets_goal_and_criteria() {
730        let mut mgr = ContextManager::new(1_000);
731        mgr.init_task("analyse data".to_string(), vec!["criterion A".to_string()]);
732        assert_eq!(mgr.partitions.task_state.goal, "analyse data");
733        assert_eq!(mgr.partitions.task_state.criteria, ["criterion A"]);
734    }
735
736    #[test]
737    fn update_task_applies_plan() {
738        let mut mgr = ContextManager::new(1_000);
739        mgr.init_task("g".to_string(), vec![]);
740        mgr.update_task(TaskUpdate {
741            plan: Some(vec!["step 1".to_string(), "step 2".to_string()]),
742            current_step: Some(0),
743            ..Default::default()
744        });
745        assert_eq!(mgr.partitions.task_state.plan.len(), 2);
746        assert_eq!(mgr.partitions.task_state.current_step, Some(0));
747    }
748
749    #[test]
750    fn task_state_survives_autocompact() {
751        let mut mgr = ContextManager::new(1_000);
752        mgr.init_task("survive compression".to_string(), vec![]);
753        mgr.update_task(TaskUpdate {
754            plan: Some(vec!["fetch data".to_string(), "analyse".to_string()]),
755            ..Default::default()
756        });
757        for _ in 0..10 { mgr.push_history(Message::user("filler"), 50); }
758        mgr.compress(PressureAction::AutoCompact);
759        assert_eq!(mgr.partitions.task_state.goal, "survive compression");
760        assert_eq!(mgr.partitions.task_state.plan.len(), 2);
761    }
762
763    #[test]
764    fn render_includes_task_state_in_state_turn_not_system() {
765        let mut mgr = ContextManager::new(10_000);
766        mgr.init_task("find anomalies".to_string(), vec![]);
767        let rc = mgr.render();
768        assert!(!rc.system_text.contains("[TASK STATE]"), "task_state must not be in system_text");
769        // State turn is separated from the cacheable history (turns).
770        let state = rc.state_turn.as_ref().expect("should have a state turn");
771        assert!(state.content.as_text().unwrap().contains("[TASK STATE] goal: find anomalies"));
772    }
773
774    #[test]
775    fn renewal_open_tasks_from_task_state() {
776        let mut mgr = ContextManager::new(1_000);
777        mgr.init_task("g".to_string(), vec![]);
778        mgr.partitions.task_state.plan = vec![
779            PlanStep { label: "done".to_string(), done: true },
780            PlanStep { label: "pending".to_string(), done: false },
781        ];
782        mgr.renew();
783        let artifact = mgr.last_handoff.as_ref().unwrap();
784        assert_eq!(artifact.open_tasks, vec!["pending"]);
785    }
786
787    #[test]
788    fn pinned_history_section_skips_compression() {
789        let mut mgr = ContextManager::new(1_000);
790        for _ in 0..30 { mgr.push_history(Message::user("filler message for pinning test"), 50); }
791        let tokens_before = mgr.partitions.history.token_count;
792        mgr.pin_section("history.rolling");
793        let (saved, _, _, _) = mgr.compress(PressureAction::AutoCompact);
794        assert_eq!(saved, 0);
795        assert_eq!(mgr.partitions.history.token_count, tokens_before);
796    }
797
798    #[test]
799    fn unpinned_history_section_allows_compression() {
800        let mut mgr = ContextManager::new(1_000);
801        for _ in 0..30 { mgr.push_history(Message::user("filler"), 50); }
802        mgr.pin_section("history.rolling");
803        mgr.unpin_section("history.rolling");
804        let (saved, _, _, _) = mgr.compress(PressureAction::AutoCompact);
805        assert!(saved > 0);
806    }
807
808    #[test]
809    fn force_compress_also_skips_when_history_pinned() {
810        let mut mgr = ContextManager::new(1_000);
811        for _ in 0..10 { mgr.push_history(Message::user("filler"), 50); }
812        mgr.pin_section("history.rolling");
813        let (saved, _, _, _) = mgr.force_compress();
814        assert_eq!(saved, 0);
815    }
816
817    // ── W1-1 完成态 regression gates (Step 0). RED until the planner/pure-executor rewrite. ──
818
819    #[test]
820    fn auto_compact_entry_logs_auto_compact_action() {
821        // C regression gate: `force_compress` is the auto-compact entry point; the summary the
822        // provider eventually sees (rendered from `compression_log`) must carry the **auto_compact**
823        // label. The broken W1 cascade ran `compress(AutoCompact, target=0)`, so `CollapseCompactor`
824        // drained the whole history first and logged `context_collapse`, then `AutoCompactor` had
825        // nothing to archive — the event was labeled `auto_compact` but the log/render showed
826        // `context_collapse`. The pure-executor model logs with the op's own label, restoring the
827        // op-label == log-label contract end users observe (node K04/K09).
828        let mut mgr = ContextManager::new(1_000);
829        for i in 0..40 {
830            mgr.push_history(Message::user(format!("turn {i}: {}", "ctx ".repeat(40))), 200);
831        }
832        let (saved, summary, _, _) = mgr.force_compress();
833        assert!(saved > 0, "force_compress should compact a large history");
834        assert!(summary.is_some(), "auto-compact summarizes the archived turns");
835        let actions: Vec<&str> = mgr
836            .partitions
837            .task_state
838            .compression_log
839            .iter()
840            .map(|e| e.action.as_str())
841            .collect();
842        assert!(
843            actions.last() == Some(&"auto_compact"),
844            "auto-compact entry must log an auto_compact action; got {actions:?}"
845        );
846    }
847
848    #[test]
849    fn skill_tool_schema_empty_when_no_skills() {
850        let mgr = ContextManager::new(10_000);
851        assert!(mgr.skill_tool_schema().is_none());
852    }
853
854    #[test]
855    fn skill_tool_schema_present_when_registered() {
856        let mut mgr = ContextManager::new(10_000);
857        mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
858        assert!(mgr.skill_tool_schema().unwrap().description.contains("debug"));
859    }
860
861    #[test]
862    fn available_skills_are_reflected_in_capability_manifest() {
863        let mut mgr = ContextManager::new(1_000);
864        mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
865        let inventory = mgr.capability_inventory();
866        assert!(inventory.contains("debug"));
867        assert!(inventory.contains("Debug helper"));
868    }
869
870    #[test]
871    fn toggled_meta_tools_are_reflected_in_capability_manifest() {
872        let mut mgr = ContextManager::new(1_000);
873        mgr.set_memory_enabled(true);
874        assert!(mgr.capability_inventory().contains(MEMORY_TOOL_NAME));
875        mgr.set_memory_enabled(false);
876        assert!(!mgr.capability_inventory().contains(MEMORY_TOOL_NAME));
877    }
878
879    #[test]
880    fn meta_tool_schemas_are_sorted() {
881        let mut mgr = ContextManager::new(1_000);
882        mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
883        mgr.set_memory_enabled(true);
884        mgr.set_knowledge_enabled(true);
885        let names = mgr.meta_tool_schemas().into_iter().map(|s| s.name.to_string()).collect::<Vec<_>>();
886        assert_eq!(names, ["knowledge", "memory", "skill"]);
887    }
888
889    #[test]
890    fn section_registry_is_available_on_manager() {
891        let mgr = ContextManager::new(1_000);
892        assert!(mgr.sections.get("capabilities.inventory").is_some());
893    }
894
895    #[test]
896    fn b1_active_skill_state_and_tool_filter() {
897        let mut mgr = ContextManager::new(1_000);
898        let mut debug = SkillMetadata::new("debug", "Debug helper");
899        debug.allowed_tools = vec![CompactString::new("read"), CompactString::new("grep")];
900        let mut review = SkillMetadata::new("review", "Reviewer");
901        review.allowed_tools = vec![CompactString::new("git_diff")];
902        let plain = SkillMetadata::new("plain", "No tools declared"); // empty allowed_tools
903        mgr.set_available_skills(vec![debug, review, plain]);
904
905        // No active skill ⇒ no narrowing.
906        assert!(mgr.active_skill_tool_filter().is_none());
907
908        // Activating returns the epoch-boundary changed flag.
909        assert!(mgr.activate_skill("debug"));
910        assert!(!mgr.activate_skill("debug")); // already active ⇒ no change
911
912        // One restricted skill ⇒ narrow to its tools.
913        let f = mgr.active_skill_tool_filter().unwrap();
914        assert_eq!(f.len(), 2);
915        assert!(f.contains(&CompactString::new("read")) && f.contains(&CompactString::new("grep")));
916
917        // Second restricted skill ⇒ union (D1).
918        mgr.activate_skill("review");
919        let f = mgr.active_skill_tool_filter().unwrap();
920        assert_eq!(f.len(), 3);
921        assert!(f.contains(&CompactString::new("git_diff")));
922
923        // An active skill with NO declared tools ⇒ unbounded ⇒ do not narrow (D3, errs-open).
924        mgr.activate_skill("plain");
925        assert!(mgr.active_skill_tool_filter().is_none());
926    }
927
928    #[test]
929    fn snapshot_hint_changes_when_capabilities_change() {
930        let mut mgr = ContextManager::new(1_000);
931        let before = mgr.snapshot_hint();
932        mgr.set_memory_enabled(true);
933        let after = mgr.snapshot_hint();
934        assert_ne!(before.capability_manifest_hash, after.capability_manifest_hash);
935    }
936
937    #[test]
938    fn update_collapse_mode_collapses_old_tool_results_under_pressure() {
939        let mut mgr = ContextManager::new(1_000);
940        for i in 0..10 {
941            let m = Message::tool(vec![ContentPart::ToolResult {
942                call_id: format!("c{i}").into(),
943                output: "x".repeat(40),
944                is_error: false,
945            }]);
946            mgr.push_history(m, 40);
947        }
948        // Drive rho past collapse_threshold deterministically via observed prompt tokens.
949        mgr.set_observed_prompt_tokens(950); // 950 / 1000 = 0.95 >= 0.90
950        assert!(mgr.rho() >= mgr.config.collapse_threshold);
951
952        mgr.recompute_handle_residency();
953        // Oldest is collapsed; the most recent (within preserve_recent_msgs) stays resident.
954        assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Collapsed));
955        assert_eq!(mgr.handles.residency_for_source("c9"), Some(&Residency::Resident));
956
957        // P0-C — monotonic within a generation: once collapsed, dropping pressure does NOT
958        // un-collapse (un-collapsing would re-bill the body and churn the cache prefix).
959        mgr.set_observed_prompt_tokens(100); // 0.10 < 0.90
960        mgr.recompute_handle_residency();
961        assert_eq!(
962            mgr.handles.residency_for_source("c0"),
963            Some(&Residency::Collapsed),
964            "collapse is sticky until a compaction boundary"
965        );
966
967        // Only a generation reset (compaction/renewal) un-collapses.
968        mgr.reset_collapse_generation();
969        assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Resident));
970    }
971
972    #[test]
973    fn frozen_prefix_len_anchors_at_compaction_and_holds_across_appends() {
974        let mut mgr = ContextManager::new(1_000);
975        // Pre-compaction: no frozen region yet → providers use the rolling-pair fallback.
976        for i in 0..30 {
977            mgr.push_history(Message::user(format!("turn {i}: {}", "ctx ".repeat(30))), 150);
978        }
979        assert!(mgr.render().frozen_prefix_len.is_none(), "no frozen region before any compaction");
980
981        let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
982        assert!(saved > 0 && !archived.is_empty(), "expected archival");
983
984        // Immediately after compaction the hot tail is empty → deep would coincide with the tail → None.
985        assert!(mgr.render().frozen_prefix_len.is_none(), "deep == tail right after compaction");
986
987        // As turns are appended, the deep boundary holds fixed while the tail grows.
988        mgr.push_history(Message::user("new 1"), 5);
989        let f1 = mgr.render().frozen_prefix_len.expect("frozen region exists once the tail grows");
990        mgr.push_history(Message::assistant("reply 1"), 5);
991        mgr.push_history(Message::user("new 2"), 5);
992        let rc = mgr.render();
993        let f2 = rc.frozen_prefix_len.expect("frozen region holds");
994        assert_eq!(f1, f2, "the deep boundary is fixed between compactions; only the tail grows");
995        assert!(f2 < rc.turns.len(), "deep boundary is distinct from the rolling tail");
996    }
997
998    #[test]
999    fn frozen_boundary_holds_through_a_prefix_safe_compaction() {
1000        // P2-D × P1-E: the boundary re-anchors on a prefix-breaking compaction (cache_at = Some) but
1001        // is preserved through a prefix-safe one (cache_at = None) — the deep cache survives.
1002        let mut mgr = ContextManager::new(10_000);
1003        for i in 0..5 {
1004            mgr.push_history(Message::user(format!("m{i}")), 5);
1005        }
1006        mgr.frozen_history_len = 3; // pretend a prior compaction anchored the deep cache here
1007
1008        // A no-op / prefix-safe compaction (PressureAction::None ⇒ cache_at None) must NOT move the
1009        // anchor — the cached [0..3] prefix is untouched, so the deep breakpoint stays put.
1010        let (_, _, _, cache_at) = mgr.compress(PressureAction::None);
1011        assert!(cache_at.is_none(), "no-op compaction is prefix-safe");
1012        assert_eq!(mgr.frozen_history_len, 3, "prefix-safe compaction preserves the deep-cache anchor");
1013    }
1014
1015    #[test]
1016    fn collapse_generation_resets_on_autocompact() {
1017        let mut mgr = ContextManager::new(1_000);
1018        // Many oversized tool results: some will be archived by AutoCompact, the survivors
1019        // should come back Resident (fresh generation), not stay stuck Collapsed.
1020        for i in 0..20 {
1021            mgr.push_history(tool_result_msg(&format!("c{i}"), &"x".repeat(120)), 60);
1022        }
1023        mgr.set_observed_prompt_tokens(980); // force collapse of the older results
1024        mgr.recompute_handle_residency();
1025        assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Collapsed));
1026
1027        let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
1028        assert!(saved > 0 && !archived.is_empty(), "expected archival");
1029
1030        // Every surviving tool-result handle is Resident again — the compaction boundary
1031        // rewrote the prefix, so the next pressure cycle re-decides from scratch.
1032        for h in mgr.handles.all() {
1033            if matches!(h.kind, HandleKind::ToolResult) {
1034                assert_eq!(h.residency, Residency::Resident, "generation reset un-collapses survivors");
1035            }
1036        }
1037    }
1038
1039    #[test]
1040    fn mark_spooled_sets_residency_and_survives_residency_recompute() {
1041        let mut mgr = ContextManager::new(1_000);
1042        mgr.push_history(
1043            Message::tool(vec![ContentPart::ToolResult {
1044                call_id: "big".into(),
1045                output: "preview only".to_string(),
1046                is_error: false,
1047            }]),
1048            10,
1049        );
1050        mgr.mark_spooled("big", "disk://big");
1051        assert_eq!(
1052            mgr.handles.residency_for_source("big"),
1053            Some(&Residency::SpooledOut { r: "disk://big".to_string() })
1054        );
1055
1056        // Even under collapse pressure, a spooled handle is not pulled into the
1057        // Resident<->Collapsed projection cycle.
1058        mgr.set_observed_prompt_tokens(990);
1059        mgr.recompute_handle_residency();
1060        assert_eq!(
1061            mgr.handles.residency_for_source("big"),
1062            Some(&Residency::SpooledOut { r: "disk://big".to_string() })
1063        );
1064    }
1065
1066    #[test]
1067    fn push_history_indexes_tool_results_as_resident_handles() {
1068        let mut mgr = ContextManager::new(10_000);
1069        let msg = Message::tool(vec![ContentPart::ToolResult {
1070            call_id: "call_1".into(),
1071            output: "the tool output".to_string(),
1072            is_error: false,
1073        }]);
1074        mgr.push_history(msg, 20);
1075        // A handle was indexed, anchored to the call_id, resident by default.
1076        assert_eq!(mgr.handles.all().len(), 1);
1077        assert_eq!(
1078            mgr.handles.residency_for_source("call_1"),
1079            Some(&Residency::Resident)
1080        );
1081        // A plain text turn allocates no handle.
1082        mgr.push_history(Message::user("hello"), 5);
1083        assert_eq!(mgr.handles.all().len(), 1);
1084    }
1085
1086    // ── W1-3: handle-table GC (prune orphaned handles + bounded recompute) ──
1087
1088    fn tool_result_msg(call_id: &str, output: &str) -> Message {
1089        Message::tool(vec![ContentPart::ToolResult {
1090            call_id: call_id.into(),
1091            output: output.to_string(),
1092            is_error: false,
1093        }])
1094    }
1095
1096    #[test]
1097    fn effective_rho_discounts_paged_out_handles() {
1098        let mut mgr = ContextManager::new(1_000);
1099        // A large tool-result output so its handle carries a real token weight.
1100        let big = "data ".repeat(200);
1101        let tok = mgr.engine.count(&big);
1102        mgr.push_history(tool_result_msg("c0", &big), tok);
1103        mgr.push_history(Message::user("u"), 50);
1104
1105        let raw = mgr.rho();
1106        // Everything resident → effective equals raw (behavior-preserving when nothing is paged).
1107        assert_eq!(mgr.handles.non_resident_tokens(), 0);
1108        assert!((mgr.effective_rho() - raw).abs() < f64::EPSILON);
1109
1110        // Page the tool result out of working context.
1111        mgr.mark_spooled("c0", "disk://c0");
1112        let paged = mgr.handles.non_resident_tokens();
1113        assert!(paged > 0, "handle is now non-resident with a real token weight");
1114
1115        // Raw rho is unchanged (partitions are untouched by the non-destructive projection)...
1116        assert!((mgr.rho() - raw).abs() < f64::EPSILON, "raw rho unchanged by paging");
1117        // ...but effective rho drops by exactly the paged tokens — paging relieves pressure now.
1118        let total = mgr.partitions.total_tokens(&mgr.engine);
1119        let expected = total.saturating_sub(paged) as f64 / 1_000.0;
1120        assert!((mgr.effective_rho() - expected).abs() < f64::EPSILON);
1121        assert!(mgr.effective_rho() < raw, "effective pressure relieved by paging");
1122
1123        // When provider usage is authoritative, the rendered prompt was already collapsed, so
1124        // effective falls back to raw (no double-discount).
1125        mgr.set_observed_prompt_tokens(900);
1126        assert!((mgr.effective_rho() - mgr.rho()).abs() < f64::EPSILON);
1127    }
1128
1129    #[test]
1130    fn prune_orphaned_handles_drops_handles_whose_message_left_history() {
1131        let mut mgr = ContextManager::new(10_000);
1132        mgr.push_history(tool_result_msg("c0", "out 0"), 20);
1133        mgr.push_history(tool_result_msg("c1", "out 1"), 20);
1134        assert_eq!(mgr.handles.all().len(), 2);
1135
1136        // Simulate compaction archiving the oldest tool-result message out of history.
1137        mgr.partitions.history.messages.remove(0);
1138        mgr.prune_orphaned_handles();
1139
1140        // The handle for the evicted message is gone; the live one is retained.
1141        assert_eq!(mgr.handles.all().len(), 1);
1142        assert!(mgr.handles.residency_for_source("c0").is_none());
1143        assert_eq!(
1144            mgr.handles.residency_for_source("c1"),
1145            Some(&Residency::Resident)
1146        );
1147    }
1148
1149    #[test]
1150    fn autocompact_prunes_handles_for_archived_tool_results() {
1151        let mut mgr = ContextManager::new(1_000);
1152        // Enough oversized tool results to force AutoCompact to archive some.
1153        for i in 0..30 {
1154            mgr.push_history(tool_result_msg(&format!("c{i}"), &"x".repeat(200)), 80);
1155        }
1156        assert_eq!(mgr.handles.all().len(), 30);
1157
1158        let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
1159        assert!(saved > 0 && !archived.is_empty(), "expected archival");
1160
1161        // After compaction the table tracks only the tool results still in working history —
1162        // not the whole session. (No handle outlives its backing message.)
1163        let live_tool_results = mgr
1164            .partitions
1165            .history
1166            .messages
1167            .iter()
1168            .filter(|m| matches!(&m.content, Content::Parts(p)
1169                if p.iter().any(|x| matches!(x, ContentPart::ToolResult { .. }))))
1170            .count();
1171        assert_eq!(mgr.handles.all().len(), live_tool_results);
1172        assert!(mgr.handles.all().len() < 30, "table must shrink with archival");
1173    }
1174
1175    #[test]
1176    fn renew_prunes_handles_for_dropped_history() {
1177        let mut mgr = ContextManager::new(1_000);
1178        mgr.init_task("g".to_string(), vec![]);
1179        for i in 0..20 {
1180            mgr.push_history(tool_result_msg(&format!("c{i}"), "data"), 60);
1181        }
1182        mgr.renew();
1183        // Every retained handle must still be anchored to a message present in the renewed history.
1184        for h in mgr.handles.all() {
1185            if let Some(src) = h.source.as_ref() {
1186                assert!(
1187                    mgr.handles.residency_for_source(src).is_some(),
1188                    "no dangling handle survives renewal"
1189                );
1190            }
1191        }
1192        assert!(mgr.handles.all().len() <= 20);
1193    }
1194
1195    #[test]
1196    fn recompute_residency_index_semantics_with_spooled_in_the_middle() {
1197        // Locks the O(n)-rewrite's index/cutoff semantics against the old id+get_mut version:
1198        // a spooled handle still occupies an index position but is never toggled.
1199        let mut mgr = ContextManager::new(1_000);
1200        for i in 0..6 {
1201            mgr.push_history(tool_result_msg(&format!("c{i}"), &"y".repeat(40)), 40);
1202        }
1203        mgr.mark_spooled("c2", "disk://c2");
1204
1205        mgr.set_observed_prompt_tokens(950); // rho >= collapse_threshold
1206        mgr.recompute_handle_residency();
1207
1208        // Spooled stays spooled; the most recent preserve_recent_msgs stay resident; older collapse.
1209        assert_eq!(
1210            mgr.handles.residency_for_source("c2"),
1211            Some(&Residency::SpooledOut { r: "disk://c2".to_string() })
1212        );
1213        assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Collapsed));
1214        assert_eq!(mgr.handles.residency_for_source("c5"), Some(&Residency::Resident));
1215    }
1216}