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