Skip to main content

deepstrike_core/context/
manager.rs

1use super::compression::CompressionPipeline;
2use super::config::{ContextConfig, PromptBudgetConfig};
3use super::partitions::ContextPartitions;
4use super::policy::ContextPolicy;
5use super::pressure::{PressureAction, PressureMonitor};
6use super::renderer::InternalRenderedContext;
7use super::renewal::RenewalPolicy;
8use super::skill_catalog::SkillCatalog;
9use super::task_state::{TaskState, TaskUpdate};
10use super::token_engine::ContextTokenEngine;
11use crate::mm::handle::{Handle, HandleId, HandleKind, HandleTable, Residency};
12use crate::types::capability::{Capability, CapabilityKind, CapabilityManifest};
13use crate::types::message::{Content, ContentPart, CoreMessage, ToolSchema};
14use crate::types::skill::SkillMetadata;
15use compact_str::CompactString;
16
17pub const MEMORY_TOOL_NAME: &str = "memory";
18pub const KNOWLEDGE_TOOL_NAME: &str = "knowledge";
19/// O7: the evicted-result re-fetch meta-tool (see `read_result_tool_schema`).
20pub const READ_RESULT_TOOL_NAME: &str = "read_result";
21
22/// Control-plane meta-tools: kernel-handled tools that drive state/capabilities rather than do task
23/// work. Excluded from the `recent_actions` progress log (2b) so the footer reflects real progress.
24const META_TOOL_NAMES: &[&str] = &[
25    "pace",
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/// The kernel-owned meta surfaces injected by [`ContextManager::meta_tool_schemas`] — exactly the
42/// tools the kernel itself adds to every turn's toolset, and the single source of truth for which
43/// names tool *config* must not be able to remove.
44///
45/// **Invariant**: these are how the model reaches kernel state — load a skill, search
46/// memory/knowledge, update the plan, re-read a result the kernel evicted (truncation markers
47/// literally instruct the model to "call the read_result tool with call_id …"). Run-level tool
48/// config (`allowedToolIds` ⇒ `capability_filter.allowed_ids`) and skill-level `allowed_tools` both
49/// enumerate *task* tools; neither is a statement about kernel surfaces, so neither may narrow one
50/// away — the same rationale the pace tool encodes by being pushed after every filter.
51///
52/// Scope: this exempts the **id axis** only. An explicit *kind* restriction
53/// (`capability_filter.allowed_kinds`, e.g. sub-agent isolation admitting only
54/// [`CapabilityKind::Tool`]) is a deliberate statement about capability families and still applies.
55///
56/// Distinct from [`META_TOOL_NAMES`], which is the wider "not task progress" set (it also covers
57/// pace and workflow authoring) used for the recency log and repeat fuse.
58pub(crate) const EXPOSURE_EXEMPT_META_TOOLS: &[&str] = &[
59    "skill",
60    MEMORY_TOOL_NAME,
61    KNOWLEDGE_TOOL_NAME,
62    "update_plan",
63    READ_RESULT_TOOL_NAME,
64];
65
66/// Whether `name` is a kernel-owned meta surface exempt from tool-config narrowing
67/// (see [`EXPOSURE_EXEMPT_META_TOOLS`]). Used by both filters in `emit_call_llm`.
68pub(crate) fn is_exposure_exempt_meta_tool(name: &str) -> bool {
69    EXPOSURE_EXEMPT_META_TOOLS.contains(&name)
70}
71
72/// Internal context engine backing the canonical operation driver.
73///
74/// Exposed for in-crate use and tests; external callers should drive the kernel
75/// through the canonical kernel rather than this type directly.
76#[doc(hidden)]
77pub struct ContextManager {
78    pub partitions: ContextPartitions,
79    pub max_tokens: u32,
80    pub config: ContextConfig,
81    pub engine: ContextTokenEngine,
82    /// Provider envelope/tool-schema overhead plus output and safety reserves. Deducted from the
83    /// model context window before any system/state/history content is selected.
84    pub prompt_budget: PromptBudgetConfig,
85    pub sprint: u32,
86    pub skills: SkillCatalog,
87    /// P1-B tool gating: the skills the model has loaded this session (by name), each with an
88    /// optional lease expiry turn (K3: `None` = permanent, today's default). Their declared
89    /// `allowed_tools` are unioned to narrow the exposed toolset in `emit_call_llm`. A map (not a
90    /// single value) because the model may load several skills and still needs each one's tools
91    /// (D1). K3 adds eviction: explicit `deactivate_skill` or lease expiry — both also unpin the
92    /// skill's `skill:<name>` knowledge entry (boundary-swept). NOT snapshotted — rebuilt on wake
93    /// by replaying `skill` tool calls (graceful).
94    pub active_skills: std::collections::BTreeMap<CompactString, Option<u32>>,
95    /// P1-B/D stable-core: tool ids that stay exposed even when a skill narrows the toolset (the
96    /// "everyone uses these" set — read/search/bash etc.). Configured once by the SDK; empty by
97    /// default (铁律: no config ⇒ skills narrow to exactly their declared tools + meta-tools).
98    pub stable_core_tools: std::collections::HashSet<CompactString>,
99    pub capabilities: CapabilityManifest,
100    pub memory_enabled: bool,
101    pub knowledge_enabled: bool,
102    pub plan_tool_enabled: bool,
103    last_observed_prompt_tokens: Option<u32>,
104    compression: CompressionPipeline,
105    pressure: PressureMonitor,
106    renewal: RenewalPolicy,
107
108    // ── Layer 3: Time tracking for decay ─────────────────────────────────
109    /// Last activity timestamp (milliseconds since epoch).
110    /// Updated on each ProviderResult and ToolResults.
111    pub last_activity_ms: u64,
112
113    /// Last compression timestamp (milliseconds since epoch).
114    /// Updated on each compression pass.
115    pub last_compact_ms: Option<u64>,
116
117    // ── P3: handle table (context as address space) ─────────────────────────
118    /// Per-task handle table: one [`Handle`] per addressable working-context object (tool results
119    /// today). Residency transitions on these handles drive read-time projection; the original
120    /// messages in `partitions` are never mutated by projection.
121    pub handles: HandleTable,
122    /// Monotonic allocator for [`HandleId`]s.
123    next_handle_id: HandleId,
124
125    /// P1-E: history length (message count) as of the last compaction/renewal. Messages below this
126    /// index are the **frozen prefix** — byte-stable until the next compaction — so the renderer can
127    /// hand providers a `frozen_prefix_len` for a long-lived deep cache breakpoint. 0 before any
128    /// compaction (no frozen region yet). The canonical logical checkpoint projects this boundary:
129    /// it is part of the next provider effect (`frozen_prefix_len`) and therefore must survive a
130    /// restore even though it does not change model-visible text.
131    frozen_history_len: usize,
132
133    /// K1: boundary-sweep results awaiting drain into `KnowledgeSwept` observations. Not
134    /// snapshotted (observation-only bookkeeping, same class as `frozen_history_len`).
135    pending_knowledge_sweeps: Vec<crate::context::partitions::KnowledgeSweep>,
136
137    /// K2: whether the budget warning already fired this cache generation (warn-once; reset by
138    /// the boundary sweep). Not snapshotted — a resume re-warns at most once, harmless.
139    knowledge_budget_warned: bool,
140    /// Monotonic, input-derived clock for knowledge-reference recency.
141    knowledge_reference_step: u64,
142}
143
144impl ContextManager {
145    pub fn new(max_tokens: u32) -> Self {
146        // spc_011-C-01: production default is the real-BPE-backed estimator, not char/4
147        // approximation — see `ContextTokenEngine::fallback_estimator` doc comment.
148        Self::with_config(
149            max_tokens,
150            ContextConfig::default(),
151            ContextTokenEngine::fallback_estimator(),
152        )
153    }
154
155    pub fn with_config(max_tokens: u32, config: ContextConfig, engine: ContextTokenEngine) -> Self {
156        let compression = CompressionPipeline::new(&config);
157        let pressure = PressureMonitor::new(max_tokens, config.clone());
158        let renewal = RenewalPolicy::from_config(&config);
159        let partitions = ContextPartitions::new(&config);
160        Self {
161            partitions,
162            max_tokens,
163            config,
164            engine,
165            prompt_budget: PromptBudgetConfig::default(),
166            sprint: 0,
167            skills: SkillCatalog::new(),
168            active_skills: std::collections::BTreeMap::new(),
169            stable_core_tools: std::collections::HashSet::new(),
170            capabilities: CapabilityManifest::new(),
171            memory_enabled: false,
172            knowledge_enabled: false,
173            plan_tool_enabled: false,
174            last_observed_prompt_tokens: None,
175            compression,
176            pressure,
177            renewal,
178            last_activity_ms: 0,
179            last_compact_ms: None,
180            handles: HandleTable::new(),
181            next_handle_id: 0,
182            frozen_history_len: 0,
183            pending_knowledge_sweeps: Vec::new(),
184            knowledge_budget_warned: false,
185            knowledge_reference_step: 0,
186        }
187    }
188
189    /// Atomically install the stable replay policy and rebuild every component derived from it.
190    pub fn apply_context_policy(&mut self, policy: &ContextPolicy) {
191        policy.apply_to(&mut self.config);
192        self.compression = CompressionPipeline::new(&self.config);
193        self.pressure = PressureMonitor::new(self.max_tokens, self.config.clone());
194        self.renewal = RenewalPolicy::from_config(&self.config);
195    }
196
197    // ── Layer 3: Time-based decay ─────────────────────────────────────────────
198
199    /// Update activity timestamp (call on each ProviderResult and ToolResults).
200    pub fn record_activity(&mut self, now_ms: u64) {
201        self.last_activity_ms = now_ms;
202    }
203
204    /// Check if Micro-Compact should trigger based on time decay (Layer 3).
205    /// Returns true if idle time exceeds `micro_compact_idle_minutes`.
206    pub fn should_time_decay_compact(&self, now_ms: u64) -> bool {
207        let idle_ms = if let Some(last_compact) = self.last_compact_ms {
208            // Time since last compression
209            now_ms.saturating_sub(last_compact)
210        } else {
211            // Time since first activity
212            now_ms.saturating_sub(self.last_activity_ms)
213        };
214
215        let idle_minutes = idle_ms / 60_000;
216        idle_minutes >= self.config.micro_compact_idle_minutes as u64
217    }
218
219    // ── Layer 4: read-time projection (handle residency) ────────────────────
220
221    /// Recompute tool-result handle residency for Layer-4 read-time projection (call before
222    /// `render`). When pressure (`rho`) reaches `collapse_threshold`, all but the most recent
223    /// `preserved_tool_results` tool results are marked `Collapsed` (rendered as previews).
224    ///
225    /// **Monotonic within a cache generation (P0-C):** collapse is one-way here —
226    /// `Resident → Collapsed` only, never the reverse. The old two-way version un-collapsed when
227    /// `rho` fell back below the threshold, which (a) rewrote mid-history bytes and invalidated the
228    /// prompt-cache prefix on every threshold oscillation, and (b) re-billed a full tool-result body
229    /// for near-zero attention gain (an old result that already faded). Un-collapsing now happens
230    /// only at compaction/renewal boundaries via [`Self::reset_collapse_generation`] — the one moment
231    /// the prefix is rewritten anyway, so the cache cost is already paid. Non-destructive:
232    /// `partitions` is untouched. Spooled/paged-out handles are left as-is.
233    pub fn recompute_handle_residency(&mut self) {
234        // Monotonic: below the threshold we never *un*-collapse, so there is nothing to do.
235        if self.rho() < self.config.collapse_threshold {
236            return;
237        }
238        let keep = self.config.preserved_tool_results;
239        // Single mutable pass in insertion order. `tool_result_handles_mut().enumerate()` yields the
240        // collapse candidates oldest-first; `i < cutoff` protects the most recent `keep` results.
241        let total = self
242            .handles
243            .all()
244            .iter()
245            .filter(|h| matches!(h.kind, HandleKind::ToolResult))
246            .count();
247        let cutoff = total.saturating_sub(keep);
248        for (i, handle) in self.handles.tool_result_handles_mut().enumerate() {
249            // Only fold the reversible Resident → Collapsed axis; never clobber a handle that has
250            // been externalized or paged out, and never reverse an existing collapse mid-generation.
251            if i < cutoff && matches!(handle.residency, Residency::Resident) {
252                handle.residency = Residency::Collapsed;
253            }
254        }
255    }
256
257    /// Start a fresh collapse generation: un-collapse every `Collapsed` handle back to `Resident`.
258    /// Called only at compaction/renewal boundaries — the sole points where un-collapsing is
259    /// cache-free, since the rendered prefix is rewritten there regardless. Between boundaries
260    /// [`Self::recompute_handle_residency`] keeps collapse strictly one-way (P0-C). Spooled/paged-out
261    /// handles are untouched (they leave the Resident↔Collapsed cycle deliberately).
262    pub fn reset_collapse_generation(&mut self) {
263        for handle in self.handles.all_mut() {
264            if matches!(handle.residency, Residency::Collapsed) {
265                handle.residency = Residency::Resident;
266            }
267        }
268    }
269
270    /// Drop handles whose anchored source message no longer lives in `partitions.history` — i.e.
271    /// archived by a compaction or dropped on renewal. Without this the handle table grows with
272    /// total session length (a handle per tool result, never removed), which also inflates the
273    /// per-turn `recompute_handle_residency` scan. Called at compaction/renewal boundaries, so the
274    /// table tracks the working set, not the whole session. Handles with no `source` anchor (future
275    /// non-tool-result kinds) are always kept — they can't be orphaned by this check.
276    ///
277    /// §25.9 · a handle whose body lives **outside** core is also always kept, whatever happened to
278    /// the message it was anchored to. The handle table is the kernel's only record of an external
279    /// payload: pruning one does not free the body, it only makes the body permanently unreachable
280    /// — and page-in is exactly the operation that outlives the preview's stay in working context.
281    pub fn prune_orphaned_handles(&mut self) {
282        let live: std::collections::HashSet<CompactString> = self
283            .partitions
284            .history
285            .messages
286            .iter()
287            .flat_map(|m| match &m.content {
288                Content::Parts(parts) => parts
289                    .iter()
290                    .filter_map(|p| match p {
291                        ContentPart::ToolResult { call_id, .. } => Some(call_id.clone()),
292                        _ => None,
293                    })
294                    .collect::<Vec<_>>(),
295                _ => Vec::new(),
296            })
297            .collect();
298        self.handles.retain(|h| {
299            h.residency.payload_ref().is_some()
300                || h.source.as_ref().is_none_or(|s| live.contains(s))
301        });
302    }
303
304    /// §7.10 / §25.9 · move the payload residency of the handle addressed by `source`, minting the
305    /// handle when the kernel is hearing about this body for the first time (a page-out archive has
306    /// no anchored message: its handle *is* the address). Returns the residency it replaced, or
307    /// `None` when the handle was minted here.
308    ///
309    /// This is the **single** writer of the external/paged-out axis, which is what makes "the P3
310    /// handle table is the only kernel fact about where a body lives" checkable rather than
311    /// aspirational: every caller goes through here, and every caller therefore has a residency
312    /// transition to observe.
313    ///
314    /// `tokens` is the weight this handle contributes to working context, so it is `0` for every
315    /// body that lives outside core — an external result's anchored message holds the *preview*,
316    /// not the body, so unlike a `Collapsed` handle there is no over-count to discount — and the
317    /// body's real weight only when a page-in brought it home.
318    pub fn set_payload_residency(
319        &mut self,
320        source: &str,
321        kind: HandleKind,
322        tokens: u32,
323        residency: Residency,
324    ) -> Option<Residency> {
325        if let Some(handle) = self
326            .handles
327            .all_mut()
328            .iter_mut()
329            .find(|h| h.source.as_deref() == Some(source))
330        {
331            let previous = std::mem::replace(&mut handle.residency, residency);
332            handle.tokens = tokens;
333            return Some(previous);
334        }
335        let id = self.alloc_handle_id();
336        self.handles.insert(Handle {
337            id,
338            kind,
339            residency,
340            tokens,
341            source: Some(source.into()),
342        });
343        None
344    }
345
346    /// The payload residency of the handle addressed by `source`. The read half of
347    /// [`Self::set_payload_residency`] — a page-in asks this before it can address anything.
348    ///
349    /// `source` **is** the wire address of a handle: the tool `call_id` for a result, which is
350    /// exactly what the truncation marker tells the model to pass back to `read_result`, and the
351    /// kernel-minted archive id for a page-out.
352    pub fn payload_residency(&self, source: &str) -> Option<&Residency> {
353        self.handles.residency_for_source(source)
354    }
355
356    // ── Pressure ──────────────────────────────────────────────────────────────
357
358    /// **Raw** rho — full partition weight (or provider-observed tokens when available). This is the
359    /// projection-decision rho: [`Self::recompute_handle_residency`] marks the Resident↔Collapsed set
360    /// from *this* value, so it must NOT discount paged content (else collapse → rho drops →
361    /// un-collapse would oscillate).
362    pub fn rho(&self) -> f64 {
363        self.pressure.pressure(
364            &self.partitions,
365            &self.engine,
366            self.last_observed_prompt_tokens,
367        )
368    }
369
370    pub fn set_observed_prompt_tokens(&mut self, tokens: u32) {
371        self.last_observed_prompt_tokens = Some(tokens);
372    }
373
374    pub fn should_compress(&self) -> PressureAction {
375        // Compaction-tier recommendation runs on **raw** rho. A paging-aware discount
376        // (`effective_rho`) was tried during W1-1 and over-relieved pressure: once
377        // `micro_compact` paged out tool-result handles, the discounted rho fell below the
378        // collapse/auto_compact thresholds and the heavy tiers never fired. Raw rho keeps
379        // escalation intact (recoverable from git if a cache-aware planner ever lands).
380        self.pressure.recommend(self.rho())
381    }
382
383    pub fn compress(
384        &mut self,
385        action: PressureAction,
386    ) -> (u32, Option<String>, Vec<CoreMessage>, Option<usize>) {
387        self.compress_with_time(action, None)
388    }
389
390    pub fn compress_with_time(
391        &mut self,
392        action: PressureAction,
393        now_ms: Option<u64>,
394    ) -> (u32, Option<String>, Vec<CoreMessage>, Option<usize>) {
395        let target = self.config.target_tokens(self.max_tokens);
396        self.compress_with_target(action, target, now_ms)
397    }
398
399    pub fn force_compress(&mut self) -> (u32, Option<String>, Vec<CoreMessage>, Option<usize>) {
400        self.compress_with_target(PressureAction::AutoCompact, 0, None)
401    }
402
403    /// W1-1 收口: run one compaction `action` toward an **explicit** `target_tokens`, instead of
404    /// re-deriving the target from config. This is what lets `EvictionOp::Collapse { target_tokens }`
405    /// flow from the planner (the single decision point) straight to the executor — the compactor no
406    /// longer re-decides the target. This is the single compaction implementation;
407    /// `compress_with_time` (config-derived target) and `force_compress` (AutoCompact, target 0)
408    /// are thin delegations.
409    pub fn compress_with_target(
410        &mut self,
411        action: PressureAction,
412        target_tokens: u32,
413        now_ms: Option<u64>,
414    ) -> (u32, Option<String>, Vec<CoreMessage>, Option<usize>) {
415        let result = self.compression.compress(
416            &mut self.partitions,
417            action,
418            self.max_tokens,
419            target_tokens,
420            &self.engine,
421        );
422        if let Some(ts) = now_ms {
423            self.last_compact_ms = Some(ts);
424        }
425        // Archived messages have left history — drop their now-orphaned handles (bounds the table).
426        if !result.2.is_empty() {
427            self.prune_orphaned_handles();
428            // Compaction rewrote the history prefix — start a fresh collapse generation so
429            // surviving handles re-evaluate from Resident (P0-C: the one cache-free un-collapse point).
430            self.reset_collapse_generation();
431            // K1: the prompt-cache prefix is being rebuilt anyway — the one cache-free moment to
432            // apply deferred knowledge upserts/removals (rewriting system[1] bytes).
433            self.sweep_knowledge_at_boundary();
434        }
435        // P2-D × P1-E: re-anchor the frozen-prefix boundary only when the compaction actually broke
436        // the prompt-cache prefix (`result.3` = the planner's per-step `cache_at` cost, `Some` ⇒ a
437        // prefix break). A prefix-safe compaction (late Snip/Excerpt that touches no early message)
438        // leaves `[0..frozen]` byte-stable, so the deep cache survives the compaction and the boundary
439        // holds — strictly more precise than the old `archived`-keyed reset, which missed an early
440        // in-place Snip and needlessly re-anchored after a prefix-safe pass.
441        if result.3.is_some() {
442            self.frozen_history_len = self.partitions.history.messages.len();
443        }
444        result
445    }
446
447    /// W1-1 收口: the truthful compaction parameters the planner stamps into the [`EvictionPlan`],
448    /// read once from config so the ops carry real values (not magic-number placeholders) and the
449    /// executor stays a pure executor. Returns `(target_tokens, preserve_recent_turns)`.
450    pub fn plan_compaction_params(&self) -> (u32, usize) {
451        (
452            self.config.target_tokens(self.max_tokens),
453            self.config.preserve_recent_turns,
454        )
455    }
456
457    // ── Renewal ───────────────────────────────────────────────────────────────
458
459    pub fn should_renew(&self) -> bool {
460        self.renewal
461            .should_renew(&self.pressure, &self.partitions, &self.engine)
462    }
463
464    pub fn renew(&mut self) {
465        self.partitions = self
466            .renewal
467            .renew(&self.partitions, self.max_tokens, &self.engine);
468        self.sprint += 1;
469        // History was rebuilt wholesale — drop handles anchored to messages it no longer carries,
470        // and start a fresh collapse generation (P0-C) since the whole prefix changed.
471        self.prune_orphaned_handles();
472        self.reset_collapse_generation();
473        // K1: renewal is a boundary — apply deferred knowledge upserts/removals now.
474        self.sweep_knowledge_at_boundary();
475        // P1-E: the renewed history is the new frozen base.
476        self.frozen_history_len = self.partitions.history.messages.len();
477    }
478
479    // ── Render ────────────────────────────────────────────────────────────────
480
481    pub fn set_prompt_budget(&mut self, prompt_budget: PromptBudgetConfig) {
482        self.prompt_budget = prompt_budget;
483    }
484
485    pub fn available_input_tokens(&self) -> u32 {
486        self.max_tokens
487            .saturating_sub(self.prompt_budget.reserved_tokens())
488    }
489
490    pub fn render(&self) -> InternalRenderedContext {
491        super::renderer::render_projected(
492            &self.partitions,
493            self.available_input_tokens(),
494            &self.engine,
495            self.config.preserve_recent_units,
496            &self.handles,
497            self.frozen_history_len,
498            self.config.collapse_assistant_narration,
499        )
500    }
501
502    // ── History / Knowledge ───────────────────────────────────────────────────
503
504    pub fn push_history(&mut self, msg: CoreMessage, tokens: u32) {
505        self.knowledge_reference_step = self.knowledge_reference_step.saturating_add(1);
506        self.partitions
507            .knowledge
508            .observe_references(&msg, self.knowledge_reference_step);
509        // P3 (3a): index each tool result entering working context as a handle, anchored to its
510        // call_id. Pure bookkeeping — render/compression still read `partitions` until 3b. The
511        // handle's residency later drives read-time projection without mutating the message.
512        if let Content::Parts(parts) = &msg.content {
513            for part in parts {
514                if let ContentPart::ToolResult {
515                    call_id, output, ..
516                } = part
517                {
518                    let id = self.alloc_handle_id();
519                    let tok = self.engine.count(output).max(1);
520                    self.handles.insert(Handle::resident_for(
521                        id,
522                        HandleKind::ToolResult,
523                        tok,
524                        call_id.clone(),
525                    ));
526                }
527            }
528        }
529        self.partitions.history.push(msg, tokens);
530    }
531
532    fn alloc_handle_id(&mut self) -> HandleId {
533        let id = self.next_handle_id;
534        self.next_handle_id = self.next_handle_id.wrapping_add(1);
535        id
536    }
537
538    /// The next handle id this allocator will hand out.
539    ///
540    /// Read by the §12.1 checkpoint projection: a restored kernel that restarted the allocator
541    /// would re-issue an id an outstanding `LoadPayload` effect still addresses.
542    pub fn next_handle_id(&self) -> HandleId {
543        self.next_handle_id
544    }
545
546    /// §12.2 · reinstall the allocator a checkpoint recorded.
547    ///
548    /// The mirror of [`Self::next_handle_id`], and the reason handle identity survives a restore:
549    /// the handle *table* is repopulated by id, and this is what stops the next allocation from
550    /// colliding with one of them.
551    pub fn restore_next_handle_id(&mut self, next: HandleId) {
552        self.next_handle_id = next;
553    }
554
555    pub fn frozen_history_len(&self) -> usize {
556        self.frozen_history_len
557    }
558
559    pub fn restore_frozen_history_len(&mut self, len: usize) -> bool {
560        if len > self.partitions.history.messages.len() {
561            return false;
562        }
563        self.frozen_history_len = len;
564        true
565    }
566
567    /// Push content into the Knowledge slot (memory retrievals, skill defs, artifacts).
568    pub fn push_knowledge(&mut self, msg: CoreMessage, tokens: u32) {
569        self.partitions.knowledge.push(msg, tokens);
570    }
571
572    /// K1: keyed knowledge push — fresh key appends immediately (cache-cheap direction), an
573    /// existing key stages a boundary-deferred upsert. `pinned` entries are exempt from the
574    /// K2 budget sweep.
575    pub fn push_knowledge_entry(
576        &mut self,
577        key: Option<CompactString>,
578        msg: CoreMessage,
579        tokens: u32,
580        pinned: bool,
581    ) {
582        self.partitions
583            .knowledge
584            .push_entry(key, msg, tokens, pinned);
585    }
586
587    /// K1: mark a keyed knowledge entry for removal at the next compaction/renewal boundary.
588    /// Errs-open: unknown key is a no-op (returns false).
589    pub fn remove_knowledge(&mut self, key: &str) -> bool {
590        self.partitions.knowledge.remove(key)
591    }
592
593    /// K1: run the boundary sweep (apply pending upserts, drop marked entries) and stash the
594    /// result for the state machine to drain into a `KnowledgeSwept` observation. Called only
595    /// from the compaction/renewal boundary blocks — the one place system[1] bytes may change.
596    fn sweep_knowledge_at_boundary(&mut self) {
597        let sweep = self.partitions.knowledge.sweep_at_boundary();
598        if sweep.changed {
599            // P9: the model must not have knowledge silently vanish under it. The boundary
600            // already broke the prompt-cache prefix, so a one-line ephemeral tail note is
601            // cache-free; keyed removals name what left and how to get it back.
602            if !sweep.removed_keys.is_empty() {
603                self.partitions.signals.push(format!(
604                    "[KNOWLEDGE] entries removed at this boundary: {} — re-fetch via the memory tool if still needed.",
605                    sweep.removed_keys.join(", ")
606                ));
607            }
608            self.pending_knowledge_sweeps.push(sweep);
609        }
610        // K2: a boundary starts a fresh cache generation — the budget warning may fire again.
611        self.knowledge_budget_warned = false;
612    }
613
614    /// K2: knowledge-budget check, run per turn before render. Over budget ⇒ mark the LOWEST-VALUE
615    /// unpinned, non-skill entries for eviction at the next boundary until the projected usage
616    /// (used − already-marked) fits, and return `Some((used, budget))` ONCE per cache generation
617    /// for the `KnowledgeBudgetExceeded` observation (marking itself is idempotent and repeats
618    /// harmlessly). Skill pins are exempt — deactivation/lease governs them, the budget never
619    /// silently unloads a skill the model believes is active. If marking every eligible entry
620    /// still exceeds the budget, the warning stands and the overweight remainder is the host's
621    /// explicit choice (errs-open). `knowledge_budget_ratio <= 0.0` disables.
622    pub fn enforce_knowledge_budget(&mut self) -> Option<(u32, u32)> {
623        let ratio = self.config.knowledge_budget_ratio;
624        if ratio <= 0.0 {
625            return None;
626        }
627        let budget = (self.max_tokens as f64 * ratio) as u32;
628        let used = self.partitions.knowledge.token_count;
629        if used <= budget {
630            return None;
631        }
632        let marked: u32 = self
633            .partitions
634            .knowledge
635            .entries
636            .iter()
637            .filter(|e| e.evict_at_boundary)
638            .map(|e| e.tokens)
639            .sum();
640        let mut projected = used.saturating_sub(marked);
641        let mut candidates = self
642            .partitions
643            .knowledge
644            .entries
645            .iter()
646            .enumerate()
647            .filter(|(_, entry)| {
648                !entry.evict_at_boundary
649                    && !entry.pinned
650                    && !entry
651                        .key
652                        .as_deref()
653                        .is_some_and(|key| key.starts_with("skill:"))
654            })
655            .map(|(index, _)| {
656                let score = self
657                    .partitions
658                    .knowledge
659                    .retention_score(index, self.knowledge_reference_step)
660                    .unwrap_or(i64::MIN);
661                (score, index)
662            })
663            .collect::<Vec<_>>();
664        candidates.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
665        for (_, index) in candidates {
666            if projected <= budget {
667                break;
668            }
669            let entry = &mut self.partitions.knowledge.entries[index];
670            entry.evict_at_boundary = true;
671            projected = projected.saturating_sub(entry.tokens);
672        }
673        if self.knowledge_budget_warned {
674            return None;
675        }
676        self.knowledge_budget_warned = true;
677        Some((used, budget))
678    }
679
680    /// K1: drain boundary-sweep results (state-machine side turns these into observations).
681    pub fn take_knowledge_sweeps(&mut self) -> Vec<crate::context::partitions::KnowledgeSweep> {
682        std::mem::take(&mut self.pending_knowledge_sweeps)
683    }
684
685    /// Push a runtime signal into the current turn's State slot.
686    /// Rendering does not consume signals. The state machine clears only the prefix acknowledged by
687    /// a correlated provider result, so provider failures and retries see the same signal payload.
688    pub fn push_signal(&mut self, text: String) {
689        self.partitions.signals.push(text);
690    }
691
692    /// Record a durable user directive in the (non-compressible, renewal-carried) task_state, so a
693    /// mid-task user command keeps its salience across compaction/renewal — unlike the ephemeral
694    /// signal channel, which is cleared on renewal.
695    pub fn record_directive(&mut self, text: impl Into<String>) {
696        self.partitions.task_state.record_directive(text);
697    }
698
699    // ── Task state ────────────────────────────────────────────────────────────
700
701    pub fn init_task(&mut self, goal: String, criteria: Vec<String>) {
702        self.partitions.task_state = TaskState {
703            goal,
704            criteria,
705            ..Default::default()
706        };
707    }
708
709    pub fn update_task(&mut self, update: TaskUpdate) {
710        self.partitions.task_state.apply(update);
711    }
712
713    /// 2b: record this turn's tool activity into the task-state recency log (kernel-derived progress
714    /// that feeds the State-turn footer). Each entry is `(name, compact_args)`; the rendered signature
715    /// is `name(args)` (or bare `name` for no-arg calls) so the no-progress STOP keys on the WHOLE
716    /// call — same tool with different args (a legit loop over items) reads as distinct progress, not
717    /// a repeat. Control-plane meta-tools (plan/skill/memory/knowledge/workflow authoring) are noise,
718    /// not task progress — filtered by name. A turn with only meta-tool calls records nothing.
719    pub fn note_tool_actions(&mut self, calls: &[(String, String)]) {
720        let summary = calls
721            .iter()
722            .filter(|(name, _)| !is_meta_tool(name))
723            .map(|(name, args)| {
724                if args.is_empty() {
725                    name.clone()
726                } else {
727                    format!("{name}({args})")
728                }
729            })
730            .collect::<Vec<_>>()
731            .join(", ");
732        self.partitions.task_state.note_actions(summary);
733    }
734
735    // ── Section pinning ───────────────────────────────────────────────────────
736
737    // ── Skills ────────────────────────────────────────────────────────────────
738
739    pub fn set_available_skills(&mut self, skills: Vec<SkillMetadata>) {
740        self.capabilities.remove_kind(CapabilityKind::Skill);
741        for skill in &skills {
742            self.capabilities.add_skill(skill.clone());
743        }
744        self.skills.set_available(skills);
745    }
746
747    /// P1-B/D: set the stable-core tool ids (always exposed under skill gating). Replaces any prior.
748    pub fn set_stable_core_tools(&mut self, ids: impl IntoIterator<Item = CompactString>) {
749        self.stable_core_tools = ids.into_iter().collect();
750    }
751
752    /// P1-B: record that the model has loaded a skill (its content is now in context). Returns
753    /// `true` if this changed the active set — an epoch boundary the SDK can use to re-anchor the
754    /// prompt cache (D). Re-activating an already-active skill refreshes its lease (K3) but
755    /// returns false (no epoch change).
756    pub fn activate_skill(&mut self, name: impl Into<CompactString>) -> bool {
757        self.activate_skill_leased(name, None)
758    }
759
760    /// K3: activate with an optional lease expiry turn (`None` = permanent). Same epoch semantics
761    /// as [`Self::activate_skill`]; a re-activation overwrites the prior lease (latest wins).
762    pub fn activate_skill_leased(
763        &mut self,
764        name: impl Into<CompactString>,
765        expires_at_turn: Option<u32>,
766    ) -> bool {
767        self.active_skills
768            .insert(name.into(), expires_at_turn)
769            .is_none()
770    }
771
772    /// K3: deactivate a skill — the toolset re-widens at the next `emit_call_llm` (an epoch event,
773    /// same cache cost class as activation) and the skill's `skill:<name>` knowledge pin is marked
774    /// for the next boundary sweep. Errs-open: not-active is a no-op (returns false).
775    pub fn deactivate_skill(&mut self, name: &str) -> bool {
776        if self.active_skills.remove(name).is_none() {
777            return false;
778        }
779        self.partitions.knowledge.remove(&format!("skill:{name}"));
780        true
781    }
782
783    /// K3: expire skill leases whose turn has passed (mirrors the capability lease sweep — runs at
784    /// the head of every event). Each expiry takes the same path as an explicit deactivation.
785    pub fn sweep_expired_skill_leases(&mut self, current_turn: u32) {
786        let expired: Vec<CompactString> = self
787            .active_skills
788            .iter()
789            .filter(|(_, lease)| lease.is_some_and(|t| current_turn >= t))
790            .map(|(name, _)| name.clone())
791            .collect();
792        for name in expired {
793            self.deactivate_skill(&name);
794            // P9: lease expiry re-widens the toolset invisibly otherwise — tell the model.
795            self.partitions.signals.push(format!(
796                "[SKILL] lease expired: {name} unloaded; the full toolset is restored."
797            ));
798        }
799    }
800
801    /// P1-B: the tool-id allow-set to narrow the exposed toolset to, given the active skills.
802    /// Returns `None` ⇒ **do not narrow** (no skill active, or some active skill declares no
803    /// `allowed_tools` ⇒ unbounded, errs-open per D3). `Some(set)` ⇒ narrow to `set` (the union of
804    /// every active skill's declared tools). Meta-tools and stable-core are layered on in
805    /// `emit_call_llm`, not here.
806    pub fn active_skill_tool_filter(&self) -> Option<std::collections::HashSet<CompactString>> {
807        if self.active_skills.is_empty() {
808            return None;
809        }
810        let mut union = std::collections::HashSet::new();
811        for name in self.active_skills.keys() {
812            let declared = self.skills.allowed_tools(name);
813            if declared.is_empty() {
814                return None; // an unrestricted active skill ⇒ no narrowing (D3)
815            }
816            union.extend(declared.iter().cloned());
817        }
818        Some(union)
819    }
820
821    /// Fine-grained authority contributed by active skills. Activation is checked by the canonical
822    /// driver before it mutates this set; deactivation and lease expiry remove grants by removing
823    /// the skill name from `active_skills`.
824    pub fn active_skill_capabilities(&self) -> Vec<Capability> {
825        self.active_skills
826            .keys()
827            .flat_map(|name| self.skills.capability_grants(name).iter().cloned())
828            .collect()
829    }
830
831    pub fn skill_capability_grants(&self, name: &str) -> &[Capability] {
832        self.skills.capability_grants(name)
833    }
834
835    pub fn skill_tool_schema(&self) -> Option<ToolSchema> {
836        self.skills.build_tool_schema()
837    }
838
839    /// Whether the operation's catalog declares this skill (see
840    /// [`SkillCatalog::is_available`](crate::context::skill_catalog::SkillCatalog::is_available)).
841    pub fn skill_available(&self, name: &str) -> bool {
842        self.skills.is_available(name)
843    }
844
845    // ── Meta-tools ────────────────────────────────────────────────────────────
846
847    pub fn set_memory_enabled(&mut self, enabled: bool) {
848        self.memory_enabled = enabled;
849        if enabled {
850            self.capabilities.add_marker(
851                CapabilityKind::Memory,
852                MEMORY_TOOL_NAME,
853                "Search long-term memory through the memory meta-tool.",
854            );
855        } else {
856            self.capabilities
857                .remove(CapabilityKind::Memory, MEMORY_TOOL_NAME);
858        }
859    }
860
861    pub fn set_knowledge_enabled(&mut self, enabled: bool) {
862        self.knowledge_enabled = enabled;
863        if enabled {
864            self.capabilities.add_marker(
865                CapabilityKind::Knowledge,
866                KNOWLEDGE_TOOL_NAME,
867                "Search external knowledge through the knowledge meta-tool.",
868            );
869        } else {
870            self.capabilities
871                .remove(CapabilityKind::Knowledge, KNOWLEDGE_TOOL_NAME);
872        }
873    }
874
875    pub fn set_plan_tool_enabled(&mut self, enabled: bool) {
876        self.plan_tool_enabled = enabled;
877        if enabled {
878            self.capabilities.add_marker(
879                CapabilityKind::Tool,
880                "update_plan",
881                "Update task plan and progress through the planning meta-tool.",
882            );
883        } else {
884            self.capabilities
885                .remove(CapabilityKind::Tool, "update_plan");
886        }
887    }
888
889    pub fn capability_inventory(&self) -> String {
890        self.capabilities.format_inventory()
891    }
892
893    pub fn meta_tool_schemas(&self) -> Vec<ToolSchema> {
894        let mut tools = Vec::new();
895        if let Some(t) = self.skill_tool_schema() {
896            tools.push(t);
897        }
898        if let Some(t) = self.memory_tool_schema() {
899            tools.push(t);
900        }
901        if let Some(t) = self.knowledge_tool_schema() {
902            tools.push(t);
903        }
904        if let Some(t) = self.plan_tool_schema() {
905            tools.push(t);
906        }
907        if let Some(t) = self.read_result_tool_schema() {
908            tools.push(t);
909        }
910        tools.sort_by(|a, b| a.name.cmp(&b.name));
911        tools
912    }
913
914    /// O7: the `read_result` meta-tool — re-fetch a tool result the kernel evicted from context
915    /// (external / collapsed / paged out). Exposed DYNAMICALLY: only once at least one
916    /// handle has actually left residency, so runs that never evict see an unchanged toolset
917    /// (progressive disclosure; golden fixtures and cache prefixes stay byte-stable). Content is
918    /// host-resolved through the payload store — the kernel only advertises the capability.
919    pub fn read_result_tool_schema(&self) -> Option<ToolSchema> {
920        let any_evicted = self
921            .handles
922            .all()
923            .iter()
924            .any(|h| !h.residency.occupies_context());
925        if !any_evicted {
926            return None;
927        }
928        Some(ToolSchema {
929            name: CompactString::new(READ_RESULT_TOOL_NAME),
930            description: "Re-read the full content of a tool result that was truncated to save \
931                          context (marked '[Output truncated: … call the read_result tool …]'). \
932                          Pass that marker's call_id; use offset/max_bytes to page through \
933                          large content."
934                .to_string(),
935            parameters: serde_json::json!({
936                "type": "object",
937                "properties": {
938                    "call_id": { "type": "string" },
939                    "offset": { "type": "integer", "description": "Byte offset to start from (default 0)." },
940                    "max_bytes": { "type": "integer", "description": "Max bytes to return (default 4000)." }
941                },
942                "required": ["call_id"]
943            }),
944        })
945    }
946
947    pub fn plan_tool_schema(&self) -> Option<ToolSchema> {
948        if !self.plan_tool_enabled {
949            return None;
950        }
951        Some(ToolSchema {
952            name: CompactString::new("update_plan"),
953            description: "Update your task plan and progress. Call this after completing a step or when the plan changes.".to_string(),
954            parameters: serde_json::json!({
955                "type": "object",
956                "properties": {
957                    "plan": { "type": "array", "items": { "type": "string" } },
958                    "current_step": { "type": "integer" },
959                    "progress": { "type": "string" },
960                    "blocked_on": { "type": "array", "items": { "type": "string" } }
961                }
962            }),
963        })
964    }
965
966    pub fn memory_tool_schema(&self) -> Option<ToolSchema> {
967        if !self.memory_enabled {
968            return None;
969        }
970        Some(ToolSchema {
971            name: CompactString::new(MEMORY_TOOL_NAME),
972            description:
973                "Search your long-term memory for relevant past experiences and knowledge."
974                    .to_string(),
975            parameters: serde_json::json!({
976                "type": "object",
977                "properties": {
978                    "query": { "type": "string" },
979                    "top_k": { "type": "integer" }
980                },
981                "required": ["query"]
982            }),
983        })
984    }
985
986    pub fn knowledge_tool_schema(&self) -> Option<ToolSchema> {
987        if !self.knowledge_enabled {
988            return None;
989        }
990        Some(ToolSchema {
991            name: CompactString::new(KNOWLEDGE_TOOL_NAME),
992            description:
993                "Search the external knowledge base for facts, documentation, or reference data."
994                    .to_string(),
995            parameters: serde_json::json!({
996                "type": "object",
997                "properties": {
998                    "query": { "type": "string" },
999                    "top_k": { "type": "integer" }
1000                },
1001                "required": ["query"]
1002            }),
1003        })
1004    }
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009    use super::*;
1010    use crate::context::task_state::PlanStep;
1011    use crate::types::message::CoreMessage;
1012    use crate::types::skill::SkillMetadata;
1013
1014    #[test]
1015    fn note_tool_actions_keys_on_name_and_args_so_legit_loops_dont_false_stop() {
1016        // Same tool, DIFFERENT args across turns = real progress (e.g. process item 1, 2, 3) —
1017        // must NOT trip the no-progress STOP backstop.
1018        let mut mgr = ContextManager::new(100_000);
1019        mgr.init_task("process items".to_string(), vec![]);
1020        mgr.note_tool_actions(&[("step".to_string(), "{\"n\":1}".to_string())]);
1021        mgr.note_tool_actions(&[("step".to_string(), "{\"n\":2}".to_string())]);
1022        mgr.note_tool_actions(&[("step".to_string(), "{\"n\":3}".to_string())]);
1023        assert_eq!(
1024            mgr.partitions.task_state.recent_actions,
1025            ["step({\"n\":1})", "step({\"n\":2})", "step({\"n\":3})"]
1026        );
1027        let txt = mgr
1028            .render()
1029            .state_turn
1030            .unwrap()
1031            .content
1032            .as_text()
1033            .unwrap()
1034            .to_string();
1035        assert!(
1036            !txt.contains("STOP:"),
1037            "same-tool/diff-args loop must not trip STOP: {txt}"
1038        );
1039
1040        // Genuine stall — same tool, SAME args repeated — DOES trip the STOP.
1041        let mut mgr2 = ContextManager::new(100_000);
1042        mgr2.init_task("g".to_string(), vec![]);
1043        for _ in 0..3 {
1044            mgr2.note_tool_actions(&[("document_read".to_string(), "{\"id\":\"x\"}".to_string())]);
1045        }
1046        let txt2 = mgr2
1047            .render()
1048            .state_turn
1049            .unwrap()
1050            .content
1051            .as_text()
1052            .unwrap()
1053            .to_string();
1054        assert!(
1055            txt2.contains("STOP:"),
1056            "identical repeated call must trip STOP: {txt2}"
1057        );
1058
1059        // Meta-tools are control plane, not task progress — filtered out entirely.
1060        let mut mgr3 = ContextManager::new(100_000);
1061        mgr3.init_task("g".to_string(), vec![]);
1062        mgr3.note_tool_actions(&[(
1063            "update_plan".to_string(),
1064            "{\"current_step\":1}".to_string(),
1065        )]);
1066        assert!(mgr3.partitions.task_state.recent_actions.is_empty());
1067    }
1068
1069    #[test]
1070    fn manager_renew_advances_sprint_and_keeps_goal() {
1071        let mut mgr = ContextManager::new(1_000);
1072        mgr.init_task("test goal".to_string(), vec![]);
1073        mgr.partitions.system.push(CoreMessage::system("rules"), 10);
1074        for i in 0..10 {
1075            mgr.push_history(CoreMessage::user(format!("msg {i}")), 50);
1076        }
1077        mgr.renew();
1078        assert_eq!(mgr.partitions.task_state.goal, "test goal");
1079        assert_eq!(mgr.sprint, 1);
1080    }
1081
1082    #[test]
1083    fn compress_only_touches_history() {
1084        let mut mgr = ContextManager::new(1_000);
1085        mgr.push_knowledge(CoreMessage::system("knowledge content"), 100);
1086        for _ in 0..30 {
1087            mgr.push_history(CoreMessage::user("history msg"), 50);
1088        }
1089        let knowledge_before = mgr.partitions.knowledge.token_count;
1090        let history_before = mgr.partitions.history.token_count;
1091        mgr.compress(PressureAction::AutoCompact);
1092        assert_eq!(mgr.partitions.knowledge.token_count, knowledge_before);
1093        assert!(mgr.partitions.history.token_count < history_before);
1094    }
1095
1096    #[test]
1097    fn init_task_sets_goal_and_criteria() {
1098        let mut mgr = ContextManager::new(1_000);
1099        mgr.init_task("analyse data".to_string(), vec!["criterion A".to_string()]);
1100        assert_eq!(mgr.partitions.task_state.goal, "analyse data");
1101        assert_eq!(mgr.partitions.task_state.criteria, ["criterion A"]);
1102    }
1103
1104    #[test]
1105    fn update_task_applies_plan() {
1106        let mut mgr = ContextManager::new(1_000);
1107        mgr.init_task("g".to_string(), vec![]);
1108        mgr.update_task(TaskUpdate {
1109            plan: Some(vec!["step 1".to_string(), "step 2".to_string()]),
1110            current_step: Some(0),
1111            ..Default::default()
1112        });
1113        assert_eq!(mgr.partitions.task_state.plan.len(), 2);
1114        assert_eq!(mgr.partitions.task_state.current_step, Some(0));
1115    }
1116
1117    #[test]
1118    fn task_state_survives_autocompact() {
1119        let mut mgr = ContextManager::new(1_000);
1120        mgr.init_task("survive compression".to_string(), vec![]);
1121        mgr.update_task(TaskUpdate {
1122            plan: Some(vec!["fetch data".to_string(), "analyse".to_string()]),
1123            ..Default::default()
1124        });
1125        for _ in 0..10 {
1126            mgr.push_history(CoreMessage::user("filler"), 50);
1127        }
1128        mgr.compress(PressureAction::AutoCompact);
1129        assert_eq!(mgr.partitions.task_state.goal, "survive compression");
1130        assert_eq!(mgr.partitions.task_state.plan.len(), 2);
1131    }
1132
1133    #[test]
1134    fn render_includes_task_state_in_state_turn_not_system() {
1135        let mut mgr = ContextManager::new(10_000);
1136        mgr.init_task("find anomalies".to_string(), vec![]);
1137        let rc = mgr.render();
1138        assert!(
1139            !rc.system_text.contains("[TASK STATE]"),
1140            "task_state must not be in system_text"
1141        );
1142        // State turn is separated from the cacheable history (turns).
1143        let state = rc.state_turn.as_ref().expect("should have a state turn");
1144        assert!(
1145            state
1146                .content
1147                .as_text()
1148                .unwrap()
1149                .contains("[TASK STATE] goal: find anomalies")
1150        );
1151    }
1152
1153    #[test]
1154    fn renewal_keeps_open_plan_steps_in_task_state() {
1155        let mut mgr = ContextManager::new(1_000);
1156        mgr.init_task("g".to_string(), vec![]);
1157        mgr.partitions.task_state.plan = vec![
1158            PlanStep {
1159                label: "done".to_string(),
1160                done: true,
1161            },
1162            PlanStep {
1163                label: "pending".to_string(),
1164                done: false,
1165            },
1166        ];
1167        mgr.renew();
1168        assert_eq!(mgr.partitions.task_state.open_steps(), vec!["pending"]);
1169    }
1170
1171    // ── W1-1 完成态 regression gates (Step 0). RED until the planner/pure-executor rewrite. ──
1172
1173    #[test]
1174    fn auto_compact_entry_logs_auto_compact_action() {
1175        // C regression gate: `force_compress` is the auto-compact entry point; the summary the
1176        // provider eventually sees (rendered from `compression_log`) must carry the **auto_compact**
1177        // label. The broken W1 cascade ran `compress(AutoCompact, target=0)`, so `CollapseCompactor`
1178        // drained the whole history first and logged `context_collapse`, then `AutoCompactor` had
1179        // nothing to archive — the event was labeled `auto_compact` but the log/render showed
1180        // `context_collapse`. The pure-executor model logs with the op's own label, restoring the
1181        // op-label == log-label contract end users observe (node K04/K09).
1182        let mut mgr = ContextManager::new(1_000);
1183        for i in 0..40 {
1184            mgr.push_history(
1185                CoreMessage::user(format!("turn {i}: {}", "ctx ".repeat(40))),
1186                200,
1187            );
1188        }
1189        let (saved, summary, _, _) = mgr.force_compress();
1190        assert!(saved > 0, "force_compress should compact a large history");
1191        assert!(
1192            summary.is_some(),
1193            "auto-compact summarizes the archived turns"
1194        );
1195        let actions: Vec<&str> = mgr
1196            .partitions
1197            .task_state
1198            .compression_log
1199            .iter()
1200            .map(|e| e.action.as_str())
1201            .collect();
1202        assert!(
1203            actions.last() == Some(&"auto_compact"),
1204            "auto-compact entry must log an auto_compact action; got {actions:?}"
1205        );
1206    }
1207
1208    #[test]
1209    fn skill_tool_schema_empty_when_no_skills() {
1210        let mgr = ContextManager::new(10_000);
1211        assert!(mgr.skill_tool_schema().is_none());
1212    }
1213
1214    #[test]
1215    fn skill_tool_schema_present_when_registered() {
1216        let mut mgr = ContextManager::new(10_000);
1217        mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
1218        assert!(
1219            mgr.skill_tool_schema()
1220                .unwrap()
1221                .description
1222                .contains("debug")
1223        );
1224    }
1225
1226    #[test]
1227    fn available_skills_are_reflected_in_capability_manifest() {
1228        let mut mgr = ContextManager::new(1_000);
1229        mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
1230        let inventory = mgr.capability_inventory();
1231        assert!(inventory.contains("debug"));
1232        assert!(inventory.contains("Debug helper"));
1233    }
1234
1235    #[test]
1236    fn toggled_meta_tools_are_reflected_in_capability_manifest() {
1237        let mut mgr = ContextManager::new(1_000);
1238        mgr.set_memory_enabled(true);
1239        assert!(mgr.capability_inventory().contains(MEMORY_TOOL_NAME));
1240        mgr.set_memory_enabled(false);
1241        assert!(!mgr.capability_inventory().contains(MEMORY_TOOL_NAME));
1242    }
1243
1244    #[test]
1245    fn meta_tool_schemas_are_sorted() {
1246        let mut mgr = ContextManager::new(1_000);
1247        mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
1248        mgr.set_memory_enabled(true);
1249        mgr.set_knowledge_enabled(true);
1250        let names = mgr
1251            .meta_tool_schemas()
1252            .into_iter()
1253            .map(|s| s.name.to_string())
1254            .collect::<Vec<_>>();
1255        assert_eq!(names, ["knowledge", "memory", "skill"]);
1256    }
1257
1258    #[test]
1259    fn b1_active_skill_state_and_tool_filter() {
1260        let mut mgr = ContextManager::new(1_000);
1261        let mut debug = SkillMetadata::new("debug", "Debug helper");
1262        debug.allowed_tools = vec![CompactString::new("read"), CompactString::new("grep")];
1263        let mut review = SkillMetadata::new("review", "Reviewer");
1264        review.allowed_tools = vec![CompactString::new("git_diff")];
1265        let plain = SkillMetadata::new("plain", "No tools declared"); // empty allowed_tools
1266        mgr.set_available_skills(vec![debug, review, plain]);
1267
1268        // No active skill ⇒ no narrowing.
1269        assert!(mgr.active_skill_tool_filter().is_none());
1270
1271        // Activating returns the epoch-boundary changed flag.
1272        assert!(mgr.activate_skill("debug"));
1273        assert!(!mgr.activate_skill("debug")); // already active ⇒ no change
1274
1275        // One restricted skill ⇒ narrow to its tools.
1276        let f = mgr.active_skill_tool_filter().unwrap();
1277        assert_eq!(f.len(), 2);
1278        assert!(f.contains(&CompactString::new("read")) && f.contains(&CompactString::new("grep")));
1279
1280        // Second restricted skill ⇒ union (D1).
1281        mgr.activate_skill("review");
1282        let f = mgr.active_skill_tool_filter().unwrap();
1283        assert_eq!(f.len(), 3);
1284        assert!(f.contains(&CompactString::new("git_diff")));
1285
1286        // An active skill with NO declared tools ⇒ unbounded ⇒ do not narrow (D3, errs-open).
1287        mgr.activate_skill("plain");
1288        assert!(mgr.active_skill_tool_filter().is_none());
1289    }
1290
1291    #[test]
1292    fn active_skill_capability_grants_follow_activation_deactivation_and_lease_expiry() {
1293        use crate::types::capability::{
1294            ActionSet, Capability, CapabilityId, ConstraintSet, Principal, ResourceSelector,
1295        };
1296
1297        let grant = Capability {
1298            id: CapabilityId("read-src".into()),
1299            kind: CapabilityKind::Tool,
1300            resource: ResourceSelector("/repo/src/**".into()),
1301            actions: ActionSet(["read".into()].into_iter().collect()),
1302            constraints: ConstraintSet::default(),
1303            lease: None,
1304            delegatable: false,
1305            issuer: Principal("root".into()),
1306        };
1307        let mut review = SkillMetadata::new("review", "Review source files");
1308        review.capability_grants = vec![grant.clone()];
1309
1310        let mut mgr = ContextManager::new(1_000);
1311        mgr.set_available_skills(vec![review]);
1312        assert!(mgr.active_skill_capabilities().is_empty());
1313
1314        mgr.activate_skill("review");
1315        assert_eq!(mgr.active_skill_capabilities(), vec![grant.clone()]);
1316
1317        mgr.deactivate_skill("review");
1318        assert!(mgr.active_skill_capabilities().is_empty());
1319
1320        mgr.activate_skill_leased("review", Some(3));
1321        mgr.sweep_expired_skill_leases(2);
1322        assert_eq!(mgr.active_skill_capabilities(), vec![grant]);
1323
1324        mgr.sweep_expired_skill_leases(3);
1325        assert!(mgr.active_skill_capabilities().is_empty());
1326    }
1327
1328    #[test]
1329    fn update_collapse_mode_collapses_old_tool_results_under_pressure() {
1330        let mut mgr = ContextManager::new(1_000);
1331        for i in 0..10 {
1332            let m = CoreMessage::tool(vec![ContentPart::ToolResult {
1333                call_id: format!("c{i}").into(),
1334                output: "x".repeat(40),
1335                is_error: false,
1336                durable_content: None,
1337            }]);
1338            mgr.push_history(m, 40);
1339        }
1340        // Drive rho past collapse_threshold deterministically via observed prompt tokens.
1341        mgr.set_observed_prompt_tokens(950); // 950 / 1000 = 0.95 >= 0.90
1342        assert!(mgr.rho() >= mgr.config.collapse_threshold);
1343
1344        mgr.recompute_handle_residency();
1345        // Oldest is collapsed; the most recent configured tool-result handles stay resident.
1346        assert_eq!(
1347            mgr.handles.residency_for_source("c0"),
1348            Some(&Residency::Collapsed)
1349        );
1350        assert_eq!(
1351            mgr.handles.residency_for_source("c9"),
1352            Some(&Residency::Resident)
1353        );
1354
1355        // P0-C — monotonic within a generation: once collapsed, dropping pressure does NOT
1356        // un-collapse (un-collapsing would re-bill the body and churn the cache prefix).
1357        mgr.set_observed_prompt_tokens(100); // 0.10 < 0.90
1358        mgr.recompute_handle_residency();
1359        assert_eq!(
1360            mgr.handles.residency_for_source("c0"),
1361            Some(&Residency::Collapsed),
1362            "collapse is sticky until a compaction boundary"
1363        );
1364
1365        // Only a generation reset (compaction/renewal) un-collapses.
1366        mgr.reset_collapse_generation();
1367        assert_eq!(
1368            mgr.handles.residency_for_source("c0"),
1369            Some(&Residency::Resident)
1370        );
1371    }
1372
1373    #[test]
1374    fn frozen_prefix_len_anchors_at_compaction_and_holds_across_appends() {
1375        let mut mgr = ContextManager::new(1_000);
1376        // Pre-compaction: no frozen region yet → providers use the rolling-pair fallback.
1377        for i in 0..30 {
1378            mgr.push_history(
1379                CoreMessage::user(format!("turn {i}: {}", "ctx ".repeat(30))),
1380                150,
1381            );
1382        }
1383        assert!(
1384            mgr.render().frozen_prefix_len.is_none(),
1385            "no frozen region before any compaction"
1386        );
1387
1388        let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
1389        assert!(saved > 0 && !archived.is_empty(), "expected archival");
1390
1391        // Immediately after compaction the hot tail is empty → deep would coincide with the tail → None.
1392        assert!(
1393            mgr.render().frozen_prefix_len.is_none(),
1394            "deep == tail right after compaction"
1395        );
1396
1397        // As turns are appended, the deep boundary holds fixed while the tail grows.
1398        mgr.push_history(CoreMessage::user("new 1"), 5);
1399        let f1 = mgr
1400            .render()
1401            .frozen_prefix_len
1402            .expect("frozen region exists once the tail grows");
1403        mgr.push_history(CoreMessage::assistant("reply 1"), 5);
1404        mgr.push_history(CoreMessage::user("new 2"), 5);
1405        let rc = mgr.render();
1406        let f2 = rc.frozen_prefix_len.expect("frozen region holds");
1407        assert_eq!(
1408            f1, f2,
1409            "the deep boundary is fixed between compactions; only the tail grows"
1410        );
1411        assert!(
1412            f2 < rc.turns.len(),
1413            "deep boundary is distinct from the rolling tail"
1414        );
1415    }
1416
1417    #[test]
1418    fn frozen_boundary_holds_through_a_prefix_safe_compaction() {
1419        // P2-D × P1-E: the boundary re-anchors on a prefix-breaking compaction (cache_at = Some) but
1420        // is preserved through a prefix-safe one (cache_at = None) — the deep cache survives.
1421        let mut mgr = ContextManager::new(10_000);
1422        for i in 0..5 {
1423            mgr.push_history(CoreMessage::user(format!("m{i}")), 5);
1424        }
1425        mgr.frozen_history_len = 3; // pretend a prior compaction anchored the deep cache here
1426
1427        // A no-op / prefix-safe compaction (PressureAction::None ⇒ cache_at None) must NOT move the
1428        // anchor — the cached [0..3] prefix is untouched, so the deep breakpoint stays put.
1429        let (_, _, _, cache_at) = mgr.compress(PressureAction::None);
1430        assert!(cache_at.is_none(), "no-op compaction is prefix-safe");
1431        assert_eq!(
1432            mgr.frozen_history_len, 3,
1433            "prefix-safe compaction preserves the deep-cache anchor"
1434        );
1435    }
1436
1437    #[test]
1438    fn collapse_generation_resets_on_autocompact() {
1439        let mut mgr = ContextManager::new(1_000);
1440        // Many oversized tool results: some will be archived by AutoCompact, the survivors
1441        // should come back Resident (fresh generation), not stay stuck Collapsed.
1442        for i in 0..20 {
1443            mgr.push_history(tool_result_msg(&format!("c{i}"), &"x".repeat(120)), 60);
1444        }
1445        mgr.set_observed_prompt_tokens(980); // force collapse of the older results
1446        mgr.recompute_handle_residency();
1447        assert_eq!(
1448            mgr.handles.residency_for_source("c0"),
1449            Some(&Residency::Collapsed)
1450        );
1451
1452        let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
1453        assert!(saved > 0 && !archived.is_empty(), "expected archival");
1454
1455        // Every surviving tool-result handle is Resident again — the compaction boundary
1456        // rewrote the prefix, so the next pressure cycle re-decides from scratch.
1457        for h in mgr.handles.all() {
1458            if matches!(h.kind, HandleKind::ToolResult) {
1459                assert_eq!(
1460                    h.residency,
1461                    Residency::Resident,
1462                    "generation reset un-collapses survivors"
1463                );
1464            }
1465        }
1466    }
1467
1468    #[test]
1469    fn push_history_indexes_tool_results_as_resident_handles() {
1470        let mut mgr = ContextManager::new(10_000);
1471        let msg = CoreMessage::tool(vec![ContentPart::ToolResult {
1472            call_id: "call_1".into(),
1473            output: "the tool output".to_string(),
1474            is_error: false,
1475            durable_content: None,
1476        }]);
1477        mgr.push_history(msg, 20);
1478        // A handle was indexed, anchored to the call_id, resident by default.
1479        assert_eq!(mgr.handles.all().len(), 1);
1480        assert_eq!(
1481            mgr.handles.residency_for_source("call_1"),
1482            Some(&Residency::Resident)
1483        );
1484        // A plain text turn allocates no handle.
1485        mgr.push_history(CoreMessage::user("hello"), 5);
1486        assert_eq!(mgr.handles.all().len(), 1);
1487    }
1488
1489    // ── W1-3: handle-table GC (prune orphaned handles + bounded recompute) ──
1490
1491    fn tool_result_msg(call_id: &str, output: &str) -> CoreMessage {
1492        CoreMessage::tool(vec![ContentPart::ToolResult {
1493            call_id: call_id.into(),
1494            output: output.to_string(),
1495            is_error: false,
1496            durable_content: None,
1497        }])
1498    }
1499
1500    #[test]
1501    fn prune_orphaned_handles_drops_handles_whose_message_left_history() {
1502        let mut mgr = ContextManager::new(10_000);
1503        mgr.push_history(tool_result_msg("c0", "out 0"), 20);
1504        mgr.push_history(tool_result_msg("c1", "out 1"), 20);
1505        assert_eq!(mgr.handles.all().len(), 2);
1506
1507        // Simulate compaction archiving the oldest tool-result message out of history.
1508        mgr.partitions.history.messages.remove(0);
1509        mgr.prune_orphaned_handles();
1510
1511        // The handle for the evicted message is gone; the live one is retained.
1512        assert_eq!(mgr.handles.all().len(), 1);
1513        assert!(mgr.handles.residency_for_source("c0").is_none());
1514        assert_eq!(
1515            mgr.handles.residency_for_source("c1"),
1516            Some(&Residency::Resident)
1517        );
1518    }
1519
1520    #[test]
1521    fn autocompact_prunes_handles_for_archived_tool_results() {
1522        let mut mgr = ContextManager::new(1_000);
1523        // Enough oversized tool results to force AutoCompact to archive some.
1524        for i in 0..30 {
1525            mgr.push_history(tool_result_msg(&format!("c{i}"), &"x".repeat(200)), 80);
1526        }
1527        assert_eq!(mgr.handles.all().len(), 30);
1528
1529        let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
1530        assert!(saved > 0 && !archived.is_empty(), "expected archival");
1531
1532        // After compaction the table tracks only the tool results still in working history —
1533        // not the whole session. (No handle outlives its backing message.)
1534        let live_tool_results = mgr
1535            .partitions
1536            .history
1537            .messages
1538            .iter()
1539            .filter(|m| {
1540                matches!(&m.content, Content::Parts(p)
1541                if p.iter().any(|x| matches!(x, ContentPart::ToolResult { .. })))
1542            })
1543            .count();
1544        assert_eq!(mgr.handles.all().len(), live_tool_results);
1545        assert!(
1546            mgr.handles.all().len() < 30,
1547            "table must shrink with archival"
1548        );
1549    }
1550
1551    #[test]
1552    fn renew_prunes_handles_for_dropped_history() {
1553        let mut mgr = ContextManager::new(1_000);
1554        mgr.init_task("g".to_string(), vec![]);
1555        for i in 0..20 {
1556            mgr.push_history(tool_result_msg(&format!("c{i}"), "data"), 60);
1557        }
1558        mgr.renew();
1559        // Every retained handle must still be anchored to a message present in the renewed history.
1560        for h in mgr.handles.all() {
1561            if let Some(src) = h.source.as_ref() {
1562                assert!(
1563                    mgr.handles.residency_for_source(src).is_some(),
1564                    "no dangling handle survives renewal"
1565                );
1566            }
1567        }
1568        assert!(mgr.handles.all().len() <= 20);
1569    }
1570
1571    // ── K2: knowledge budget ─────────────────────────────────────────────────
1572
1573    #[test]
1574    fn knowledge_budget_uses_stable_order_for_equal_value_and_warns_once() {
1575        // max_tokens 100 × default ratio 0.25 ⇒ budget 25. Four 10-token entries (40 used):
1576        // two evictable, one pinned, one skill pin.
1577        let mut mgr = ContextManager::new(100);
1578        mgr.push_knowledge(CoreMessage::system("oldest unkeyed"), 10);
1579        mgr.push_knowledge_entry(Some("a".into()), CoreMessage::system("keyed"), 10, false);
1580        mgr.push_knowledge_entry(Some("p".into()), CoreMessage::system("pinned"), 10, true);
1581        mgr.push_knowledge_entry(
1582            Some("skill:x".into()),
1583            CoreMessage::system("skill"),
1584            10,
1585            false,
1586        );
1587
1588        let warn = mgr.enforce_knowledge_budget();
1589        assert_eq!(warn, Some((40, 25)));
1590        // Equal scores retain the deterministic insertion-order tie-break: unkeyed then "a".
1591        let e = &mgr.partitions.knowledge.entries;
1592        assert!(e[0].evict_at_boundary);
1593        assert!(e[1].evict_at_boundary);
1594        assert!(!e[2].evict_at_boundary, "pinned exempt");
1595        assert!(!e[3].evict_at_boundary, "skill pin exempt");
1596
1597        // Warn-once per generation; marking stays idempotent.
1598        assert_eq!(mgr.enforce_knowledge_budget(), None);
1599
1600        // The boundary sweep drops the marked entries and re-arms the warning.
1601        let sweep = mgr.partitions.knowledge.sweep_at_boundary();
1602        assert_eq!(sweep.tokens_freed, 20);
1603        assert_eq!(mgr.partitions.knowledge.token_count, 20);
1604        // Back under budget ⇒ no further warning even though it re-armed.
1605        assert_eq!(mgr.enforce_knowledge_budget(), None);
1606    }
1607
1608    #[test]
1609    fn knowledge_budget_warning_stands_when_only_exempt_weight_remains() {
1610        let mut mgr = ContextManager::new(100);
1611        mgr.push_knowledge_entry(
1612            Some("p".into()),
1613            CoreMessage::system("pinned heavy"),
1614            30,
1615            true,
1616        );
1617        mgr.push_knowledge_entry(
1618            Some("skill:x".into()),
1619            CoreMessage::system("skill heavy"),
1620            30,
1621            false,
1622        );
1623
1624        // Over budget (60 > 25) but nothing evictable — warning fires, nothing marked.
1625        assert_eq!(mgr.enforce_knowledge_budget(), Some((60, 25)));
1626        assert!(
1627            mgr.partitions
1628                .knowledge
1629                .entries
1630                .iter()
1631                .all(|e| !e.evict_at_boundary)
1632        );
1633    }
1634
1635    #[test]
1636    fn knowledge_budget_retains_old_referenced_entry_over_new_irrelevant_entry() {
1637        let mut mgr = ContextManager::new(100);
1638        mgr.push_knowledge_entry(
1639            Some("project:orchid".into()),
1640            CoreMessage::system("ORCHID uses the Atlas storage engine"),
1641            10,
1642            false,
1643        );
1644        // A committed history input is the deterministic usage fact.
1645        mgr.push_history(CoreMessage::user("For project:orchid keep using Atlas"), 5);
1646        mgr.push_knowledge_entry(
1647            Some("project:new".into()),
1648            CoreMessage::system("unrelated fresh material"),
1649            10,
1650            false,
1651        );
1652        mgr.push_knowledge_entry(
1653            Some("project:other".into()),
1654            CoreMessage::system("another unused reference"),
1655            10,
1656            false,
1657        );
1658
1659        assert_eq!(mgr.enforce_knowledge_budget(), Some((30, 25)));
1660        let entries = &mgr.partitions.knowledge.entries;
1661        assert!(
1662            !entries[0].evict_at_boundary,
1663            "a real reference must raise retention"
1664        );
1665        assert!(
1666            entries[1].evict_at_boundary,
1667            "lowest-value entry evicts first"
1668        );
1669        assert!(
1670            !entries[2].evict_at_boundary,
1671            "one eviction is enough to fit"
1672        );
1673    }
1674
1675    #[test]
1676    fn knowledge_budget_ratio_zero_disables() {
1677        let mut mgr = ContextManager::new(100);
1678        mgr.config.knowledge_budget_ratio = 0.0;
1679        mgr.push_knowledge(CoreMessage::system("huge"), 90);
1680        assert_eq!(mgr.enforce_knowledge_budget(), None);
1681        assert!(!mgr.partitions.knowledge.entries[0].evict_at_boundary);
1682    }
1683
1684    #[test]
1685    fn provider_and_output_reservations_reduce_the_hard_input_budget() {
1686        use crate::context::config::PromptBudgetConfig;
1687
1688        let mut mgr = ContextManager::new(100);
1689        mgr.set_prompt_budget(PromptBudgetConfig {
1690            prompt_overhead_tokens: 20,
1691            output_reserve_tokens: 20,
1692            safety_margin_tokens: 10,
1693        });
1694        // spc_011-C-01: `render()`'s fixed-context accounting always recounts the system
1695        // partition via `engine.count()` (never trusts a stored `token_count` — see
1696        // `renderer.rs::render_projected`'s `system_tokens` line), so this text must overflow
1697        // the 50-token budget under whichever engine is configured. A single repeated character
1698        // (the previous `"x".repeat(240)`) reliably hit 60 under char/4 math, but real BPE
1699        // merges long identical-byte runs into a handful of tokens (~30, well under budget) —
1700        // that was calibrated to the old default, not to the overflow behavior under test.
1701        mgr.partitions.system.push(
1702            CoreMessage::system(
1703                "System policy directive number seven requires strict adherence. ".repeat(10),
1704            ),
1705            60,
1706        );
1707
1708        let rendered = mgr.render();
1709        let overflow = rendered
1710            .budget_overflow
1711            .expect("fixed context exceeds input allowance");
1712        assert_eq!(overflow.max_tokens, 50);
1713        assert!(overflow.required_tokens > overflow.max_tokens);
1714    }
1715}