Skip to main content

deepstrike_core/context/
manager.rs

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