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