1use super::compression::CompressionPipeline;
2use super::config::ContextConfig;
3use super::partitions::ContextPartitions;
4use super::pressure::{PressureAction, PressureMonitor};
5use super::renderer::RenderedContext;
6use super::renewal::{HandoffArtifact, RenewalPolicy};
7use super::sections::{ContextSectionPartition, ContextSectionRegistry};
8use super::snapshot::{ContextSnapshotHint, ContextSnapshot};
9use super::skill_catalog::SkillCatalog;
10use super::task_state::{TaskState, TaskUpdate};
11use super::token_engine::ContextTokenEngine;
12use crate::mm::handle::{Handle, HandleId, HandleKind, HandleTable, Residency};
13use crate::types::capability::{CapabilityKind, CapabilityManifest};
14use crate::types::message::{Content, ContentPart, Message, ToolSchema};
15use crate::types::skill::SkillMetadata;
16use compact_str::CompactString;
17
18pub const MEMORY_TOOL_NAME: &str = "memory";
19pub const KNOWLEDGE_TOOL_NAME: &str = "knowledge";
20
21#[doc(hidden)]
26pub struct ContextManager {
27 pub partitions: ContextPartitions,
28 pub max_tokens: u32,
29 pub config: ContextConfig,
30 pub engine: ContextTokenEngine,
31 pub sprint: u32,
32 pub last_handoff: Option<HandoffArtifact>,
33 pub skills: SkillCatalog,
34 pub active_skills: std::collections::BTreeSet<CompactString>,
39 pub stable_core_tools: std::collections::HashSet<CompactString>,
43 pub capabilities: CapabilityManifest,
44 pub sections: ContextSectionRegistry,
45 pub memory_enabled: bool,
46 pub knowledge_enabled: bool,
47 pub plan_tool_enabled: bool,
48 last_observed_prompt_tokens: Option<u32>,
49 compression: CompressionPipeline,
50 pressure: PressureMonitor,
51 renewal: RenewalPolicy,
52
53 pub last_activity_ms: u64,
58
59 pub last_compact_ms: Option<u64>,
62
63 pub handles: HandleTable,
69 next_handle_id: HandleId,
71
72 frozen_history_len: usize,
78}
79
80impl ContextManager {
81 pub fn new(max_tokens: u32) -> Self {
82 Self::with_config(max_tokens, ContextConfig::default(), ContextTokenEngine::char_approx())
83 }
84
85 pub fn with_config(max_tokens: u32, config: ContextConfig, engine: ContextTokenEngine) -> Self {
86 let compression = CompressionPipeline::new(&config);
87 let pressure = PressureMonitor::new(max_tokens, config.clone());
88 let renewal = RenewalPolicy::from_config(&config);
89 let partitions = ContextPartitions::new(&config);
90 Self {
91 partitions, max_tokens, config, engine,
92 sprint: 0, last_handoff: None,
93 skills: SkillCatalog::new(),
94 active_skills: std::collections::BTreeSet::new(),
95 stable_core_tools: std::collections::HashSet::new(),
96 capabilities: CapabilityManifest::new(),
97 sections: ContextSectionRegistry::default_agent_sections(),
98 memory_enabled: false, knowledge_enabled: false, plan_tool_enabled: false,
99 last_observed_prompt_tokens: None,
100 compression, pressure, renewal,
101 last_activity_ms: 0,
102 last_compact_ms: None,
103 handles: HandleTable::new(),
104 next_handle_id: 0,
105 frozen_history_len: 0,
106 }
107 }
108
109 pub fn record_activity(&mut self, now_ms: u64) {
113 self.last_activity_ms = now_ms;
114 }
115
116 pub fn should_time_decay_compact(&self, now_ms: u64) -> bool {
119 let idle_ms = if let Some(last_compact) = self.last_compact_ms {
120 now_ms.saturating_sub(last_compact)
122 } else {
123 now_ms.saturating_sub(self.last_activity_ms)
125 };
126
127 let idle_minutes = idle_ms / 60_000;
128 idle_minutes >= self.config.micro_compact_idle_minutes as u64
129 }
130
131 pub fn recompute_handle_residency(&mut self) {
146 if self.rho() < self.config.collapse_threshold {
148 return;
149 }
150 let keep = self.config.preserve_recent_msgs;
151 let total = self
154 .handles
155 .all()
156 .iter()
157 .filter(|h| matches!(h.kind, HandleKind::ToolResult))
158 .count();
159 let cutoff = total.saturating_sub(keep);
160 for (i, handle) in self.handles.tool_result_handles_mut().enumerate() {
161 if i < cutoff && matches!(handle.residency, Residency::Resident) {
164 handle.residency = Residency::Collapsed;
165 }
166 }
167 }
168
169 pub fn reset_collapse_generation(&mut self) {
175 for handle in self.handles.all_mut() {
176 if matches!(handle.residency, Residency::Collapsed) {
177 handle.residency = Residency::Resident;
178 }
179 }
180 }
181
182 pub fn prune_orphaned_handles(&mut self) {
189 let live: std::collections::HashSet<CompactString> = self
190 .partitions
191 .history
192 .messages
193 .iter()
194 .flat_map(|m| match &m.content {
195 Content::Parts(parts) => parts
196 .iter()
197 .filter_map(|p| match p {
198 ContentPart::ToolResult { call_id, .. } => Some(call_id.clone()),
199 _ => None,
200 })
201 .collect::<Vec<_>>(),
202 _ => Vec::new(),
203 })
204 .collect();
205 self.handles
206 .retain(|h| h.source.as_ref().is_none_or(|s| live.contains(s)));
207 }
208
209 pub fn mark_spooled(&mut self, call_id: &str, spool_ref: impl Into<String>) {
213 let spool_ref = spool_ref.into();
214 if let Some(handle) = self
215 .handles
216 .all_mut()
217 .iter_mut()
218 .find(|h| h.source.as_deref() == Some(call_id))
219 {
220 handle.residency = Residency::SpooledOut { r: spool_ref };
221 }
222 }
223
224 pub fn rho(&self) -> f64 {
231 self.pressure
232 .pressure(&self.partitions, &self.engine, self.last_observed_prompt_tokens)
233 }
234
235 pub fn effective_rho(&self) -> f64 {
246 if self.max_tokens == 0 || self.last_observed_prompt_tokens.is_some() {
247 return self.rho();
248 }
249 let total = self.partitions.total_tokens(&self.engine);
250 let effective = total.saturating_sub(self.handles.non_resident_tokens());
251 effective as f64 / self.max_tokens as f64
252 }
253
254 pub fn set_observed_prompt_tokens(&mut self, tokens: u32) {
255 self.last_observed_prompt_tokens = Some(tokens);
256 }
257
258 pub fn should_compress(&self) -> PressureAction {
259 self.pressure.recommend(self.rho())
268 }
269
270 pub fn compress(&mut self, action: PressureAction) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
271 self.compress_with_time(action, None)
272 }
273
274 pub fn compress_with_time(
275 &mut self,
276 action: PressureAction,
277 now_ms: Option<u64>,
278 ) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
279 if self.sections.is_partition_pinned(ContextSectionPartition::History) {
280 return (0, None, vec![], None);
281 }
282
283 let result = {
284 let target = self.config.target_tokens(self.max_tokens);
285 self.compression.compress(&mut self.partitions, action, self.max_tokens, target, &self.engine)
286 };
287
288 if let Some(ts) = now_ms {
290 self.last_compact_ms = Some(ts);
291 }
292
293 if !result.2.is_empty() {
295 self.prune_orphaned_handles();
296 self.reset_collapse_generation();
299 }
300 if result.3.is_some() {
307 self.frozen_history_len = self.partitions.history.messages.len();
308 }
309
310 result
311 }
312
313 pub fn force_compress(&mut self) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
314 if self.sections.is_partition_pinned(ContextSectionPartition::History) {
315 return (0, None, vec![], None);
316 }
317 let result = self.compression.compress(&mut self.partitions, PressureAction::AutoCompact, self.max_tokens, 0, &self.engine);
318 if !result.2.is_empty() {
319 self.prune_orphaned_handles();
320 self.reset_collapse_generation();
323 }
324 if result.3.is_some() {
331 self.frozen_history_len = self.partitions.history.messages.len();
332 }
333 result
334 }
335
336 pub fn compress_with_target(
342 &mut self,
343 action: PressureAction,
344 target_tokens: u32,
345 now_ms: Option<u64>,
346 ) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
347 if self.sections.is_partition_pinned(ContextSectionPartition::History) {
348 return (0, None, vec![], None);
349 }
350 let result =
351 self.compression
352 .compress(&mut self.partitions, action, self.max_tokens, target_tokens, &self.engine);
353 if let Some(ts) = now_ms {
354 self.last_compact_ms = Some(ts);
355 }
356 if !result.2.is_empty() {
357 self.prune_orphaned_handles();
358 self.reset_collapse_generation();
361 }
362 if result.3.is_some() {
369 self.frozen_history_len = self.partitions.history.messages.len();
370 }
371 result
372 }
373
374 pub fn plan_compaction_params(&self) -> (u32, usize) {
378 (
379 self.config.target_tokens(self.max_tokens),
380 self.config.preserve_recent_turns,
381 )
382 }
383
384 pub fn should_renew(&self) -> bool {
387 self.renewal.should_renew(&self.pressure, &self.partitions, &self.engine)
388 }
389
390 pub fn renew(&mut self) {
391 let goal = self.partitions.task_state.goal.clone();
392 let (renewed, artifact) = self.renewal.renew(&self.partitions, &goal, self.sprint, self.max_tokens);
393 self.partitions = renewed;
394 self.last_handoff = Some(artifact);
395 self.sprint += 1;
396 self.prune_orphaned_handles();
399 self.reset_collapse_generation();
400 self.frozen_history_len = self.partitions.history.messages.len();
402 }
403
404 pub fn render(&self) -> RenderedContext {
407 super::renderer::render_projected(
408 &self.partitions,
409 self.max_tokens,
410 &self.engine,
411 self.config.preserve_recent_msgs,
412 &self.handles,
413 self.frozen_history_len,
414 )
415 }
416
417 pub fn snapshot_hint(&self) -> ContextSnapshotHint {
418 ContextSnapshotHint::from_parts(&self.sections, &self.capabilities)
419 }
420
421 pub fn take_snapshot(&self, turn: u32) -> ContextSnapshot {
422 ContextSnapshot {
423 turn,
424 system_messages: self.partitions.system.messages.clone(),
425 knowledge_messages: self.partitions.knowledge.messages.clone(),
426 history_messages: self.partitions.history.messages.clone(),
427 task_state: self.partitions.task_state.clone(),
428 }
429 }
430
431 pub fn push_history(&mut self, msg: Message, tokens: u32) {
434 if let Content::Parts(parts) = &msg.content {
438 for part in parts {
439 if let ContentPart::ToolResult { call_id, output, .. } = part {
440 let id = self.alloc_handle_id();
441 let tok = self.engine.count(output).max(1);
442 self.handles.insert(Handle::resident_for(
443 id,
444 HandleKind::ToolResult,
445 tok,
446 call_id.clone(),
447 ));
448 }
449 }
450 }
451 self.partitions.history.push(msg, tokens);
452 }
453
454 fn alloc_handle_id(&mut self) -> HandleId {
455 let id = self.next_handle_id;
456 self.next_handle_id = self.next_handle_id.wrapping_add(1);
457 id
458 }
459
460 pub fn push_knowledge(&mut self, msg: Message, tokens: u32) {
462 self.partitions.knowledge.push(msg, tokens);
463 }
464
465 pub fn push_signal(&mut self, text: String) {
468 self.partitions.signals.push(text);
469 }
470
471 pub fn record_directive(&mut self, text: impl Into<String>) {
475 self.partitions.task_state.record_directive(text);
476 }
477
478 pub fn init_task(&mut self, goal: String, criteria: Vec<String>) {
481 self.partitions.task_state = TaskState { goal, criteria, ..Default::default() };
482 }
483
484 pub fn update_task(&mut self, update: TaskUpdate) {
485 self.partitions.task_state.apply(update);
486 }
487
488 pub fn pin_section(&mut self, id: &str) -> bool { self.sections.pin(id) }
491 pub fn unpin_section(&mut self, id: &str) -> bool { self.sections.unpin(id) }
492
493 pub fn set_available_skills(&mut self, skills: Vec<SkillMetadata>) {
496 self.capabilities.remove_kind(CapabilityKind::Skill);
497 for skill in &skills { self.capabilities.add_skill(skill.clone()); }
498 self.skills.set_available(skills);
499 }
500
501 pub fn set_stable_core_tools(&mut self, ids: impl IntoIterator<Item = CompactString>) {
503 self.stable_core_tools = ids.into_iter().collect();
504 }
505
506 pub fn activate_skill(&mut self, name: impl Into<CompactString>) -> bool {
510 self.active_skills.insert(name.into())
511 }
512
513 pub fn active_skill_tool_filter(&self) -> Option<std::collections::HashSet<CompactString>> {
519 if self.active_skills.is_empty() {
520 return None;
521 }
522 let mut union = std::collections::HashSet::new();
523 for name in &self.active_skills {
524 let declared = self.skills.allowed_tools(name);
525 if declared.is_empty() {
526 return None; }
528 union.extend(declared.iter().cloned());
529 }
530 Some(union)
531 }
532
533 pub fn skill_tool_schema(&self) -> Option<ToolSchema> {
534 self.skills.build_tool_schema()
535 }
536
537 pub fn set_memory_enabled(&mut self, enabled: bool) {
540 self.memory_enabled = enabled;
541 if enabled {
542 self.capabilities.add_marker(CapabilityKind::Memory, MEMORY_TOOL_NAME,
543 "Search long-term memory through the memory meta-tool.");
544 } else {
545 self.capabilities.remove(CapabilityKind::Memory, MEMORY_TOOL_NAME);
546 }
547 }
548
549 pub fn set_knowledge_enabled(&mut self, enabled: bool) {
550 self.knowledge_enabled = enabled;
551 if enabled {
552 self.capabilities.add_marker(CapabilityKind::Knowledge, KNOWLEDGE_TOOL_NAME,
553 "Search external knowledge through the knowledge meta-tool.");
554 } else {
555 self.capabilities.remove(CapabilityKind::Knowledge, KNOWLEDGE_TOOL_NAME);
556 }
557 }
558
559 pub fn set_plan_tool_enabled(&mut self, enabled: bool) {
560 self.plan_tool_enabled = enabled;
561 if enabled {
562 self.capabilities.add_marker(CapabilityKind::Tool, "update_plan",
563 "Update task plan and progress through the planning meta-tool.");
564 } else {
565 self.capabilities.remove(CapabilityKind::Tool, "update_plan");
566 }
567 }
568
569 pub fn capability_inventory(&self) -> String { self.capabilities.format_inventory() }
570
571 pub fn meta_tool_schemas(&self) -> Vec<ToolSchema> {
572 let mut tools = Vec::new();
573 if let Some(t) = self.skill_tool_schema() { tools.push(t); }
574 if let Some(t) = self.memory_tool_schema() { tools.push(t); }
575 if let Some(t) = self.knowledge_tool_schema() { tools.push(t); }
576 if let Some(t) = self.plan_tool_schema() { tools.push(t); }
577 tools.sort_by(|a, b| a.name.cmp(&b.name));
578 tools
579 }
580
581 pub fn plan_tool_schema(&self) -> Option<ToolSchema> {
582 if !self.plan_tool_enabled { return None; }
583 Some(ToolSchema {
584 name: CompactString::new("update_plan"),
585 description: "Update your task plan and progress. Call this after completing a step or when the plan changes.".to_string(),
586 parameters: serde_json::json!({
587 "type": "object",
588 "properties": {
589 "plan": { "type": "array", "items": { "type": "string" } },
590 "current_step": { "type": "integer" },
591 "progress": { "type": "string" },
592 "blocked_on": { "type": "array", "items": { "type": "string" } }
593 }
594 }),
595 })
596 }
597
598 pub fn memory_tool_schema(&self) -> Option<ToolSchema> {
599 if !self.memory_enabled { return None; }
600 Some(ToolSchema {
601 name: CompactString::new(MEMORY_TOOL_NAME),
602 description: "Search your long-term memory for relevant past experiences and knowledge.".to_string(),
603 parameters: serde_json::json!({
604 "type": "object",
605 "properties": {
606 "query": { "type": "string" },
607 "top_k": { "type": "integer" }
608 },
609 "required": ["query"]
610 }),
611 })
612 }
613
614 pub fn knowledge_tool_schema(&self) -> Option<ToolSchema> {
615 if !self.knowledge_enabled { return None; }
616 Some(ToolSchema {
617 name: CompactString::new(KNOWLEDGE_TOOL_NAME),
618 description: "Search the external knowledge base for facts, documentation, or reference data.".to_string(),
619 parameters: serde_json::json!({
620 "type": "object",
621 "properties": {
622 "query": { "type": "string" },
623 "top_k": { "type": "integer" }
624 },
625 "required": ["query"]
626 }),
627 })
628 }
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634 use crate::context::task_state::PlanStep;
635 use crate::types::message::Message;
636 use crate::types::skill::SkillMetadata;
637
638 #[test]
639 fn manager_renew_uses_task_state_goal() {
640 let mut mgr = ContextManager::new(1_000);
641 mgr.init_task("test goal".to_string(), vec![]);
642 mgr.partitions.system.push(Message::system("rules"), 10);
643 for i in 0..10 { mgr.push_history(Message::user(format!("msg {i}")), 50); }
644 mgr.renew();
645 let artifact = mgr.last_handoff.as_ref().unwrap();
646 assert_eq!(artifact.goal, "test goal");
647 assert_eq!(mgr.sprint, 1);
648 }
649
650 #[test]
651 fn compress_only_touches_history() {
652 let mut mgr = ContextManager::new(1_000);
653 mgr.push_knowledge(Message::system("knowledge content"), 100);
654 for _ in 0..30 { mgr.push_history(Message::user("history msg"), 50); }
655 let knowledge_before = mgr.partitions.knowledge.token_count;
656 let history_before = mgr.partitions.history.token_count;
657 mgr.compress(PressureAction::AutoCompact);
658 assert_eq!(mgr.partitions.knowledge.token_count, knowledge_before);
659 assert!(mgr.partitions.history.token_count < history_before);
660 }
661
662 #[test]
663 fn init_task_sets_goal_and_criteria() {
664 let mut mgr = ContextManager::new(1_000);
665 mgr.init_task("analyse data".to_string(), vec!["criterion A".to_string()]);
666 assert_eq!(mgr.partitions.task_state.goal, "analyse data");
667 assert_eq!(mgr.partitions.task_state.criteria, ["criterion A"]);
668 }
669
670 #[test]
671 fn update_task_applies_plan() {
672 let mut mgr = ContextManager::new(1_000);
673 mgr.init_task("g".to_string(), vec![]);
674 mgr.update_task(TaskUpdate {
675 plan: Some(vec!["step 1".to_string(), "step 2".to_string()]),
676 current_step: Some(0),
677 ..Default::default()
678 });
679 assert_eq!(mgr.partitions.task_state.plan.len(), 2);
680 assert_eq!(mgr.partitions.task_state.current_step, Some(0));
681 }
682
683 #[test]
684 fn task_state_survives_autocompact() {
685 let mut mgr = ContextManager::new(1_000);
686 mgr.init_task("survive compression".to_string(), vec![]);
687 mgr.update_task(TaskUpdate {
688 plan: Some(vec!["fetch data".to_string(), "analyse".to_string()]),
689 ..Default::default()
690 });
691 for _ in 0..10 { mgr.push_history(Message::user("filler"), 50); }
692 mgr.compress(PressureAction::AutoCompact);
693 assert_eq!(mgr.partitions.task_state.goal, "survive compression");
694 assert_eq!(mgr.partitions.task_state.plan.len(), 2);
695 }
696
697 #[test]
698 fn render_includes_task_state_in_state_turn_not_system() {
699 let mut mgr = ContextManager::new(10_000);
700 mgr.init_task("find anomalies".to_string(), vec![]);
701 let rc = mgr.render();
702 assert!(!rc.system_text.contains("[TASK STATE]"), "task_state must not be in system_text");
703 let state = rc.state_turn.as_ref().expect("should have a state turn");
705 assert!(state.content.as_text().unwrap().contains("[TASK STATE] goal: find anomalies"));
706 }
707
708 #[test]
709 fn renewal_open_tasks_from_task_state() {
710 let mut mgr = ContextManager::new(1_000);
711 mgr.init_task("g".to_string(), vec![]);
712 mgr.partitions.task_state.plan = vec![
713 PlanStep { label: "done".to_string(), done: true },
714 PlanStep { label: "pending".to_string(), done: false },
715 ];
716 mgr.renew();
717 let artifact = mgr.last_handoff.as_ref().unwrap();
718 assert_eq!(artifact.open_tasks, vec!["pending"]);
719 }
720
721 #[test]
722 fn pinned_history_section_skips_compression() {
723 let mut mgr = ContextManager::new(1_000);
724 for _ in 0..30 { mgr.push_history(Message::user("filler message for pinning test"), 50); }
725 let tokens_before = mgr.partitions.history.token_count;
726 mgr.pin_section("history.rolling");
727 let (saved, _, _, _) = mgr.compress(PressureAction::AutoCompact);
728 assert_eq!(saved, 0);
729 assert_eq!(mgr.partitions.history.token_count, tokens_before);
730 }
731
732 #[test]
733 fn unpinned_history_section_allows_compression() {
734 let mut mgr = ContextManager::new(1_000);
735 for _ in 0..30 { mgr.push_history(Message::user("filler"), 50); }
736 mgr.pin_section("history.rolling");
737 mgr.unpin_section("history.rolling");
738 let (saved, _, _, _) = mgr.compress(PressureAction::AutoCompact);
739 assert!(saved > 0);
740 }
741
742 #[test]
743 fn force_compress_also_skips_when_history_pinned() {
744 let mut mgr = ContextManager::new(1_000);
745 for _ in 0..10 { mgr.push_history(Message::user("filler"), 50); }
746 mgr.pin_section("history.rolling");
747 let (saved, _, _, _) = mgr.force_compress();
748 assert_eq!(saved, 0);
749 }
750
751 #[test]
754 fn auto_compact_entry_logs_auto_compact_action() {
755 let mut mgr = ContextManager::new(1_000);
763 for i in 0..40 {
764 mgr.push_history(Message::user(format!("turn {i}: {}", "ctx ".repeat(40))), 200);
765 }
766 let (saved, summary, _, _) = mgr.force_compress();
767 assert!(saved > 0, "force_compress should compact a large history");
768 assert!(summary.is_some(), "auto-compact summarizes the archived turns");
769 let actions: Vec<&str> = mgr
770 .partitions
771 .task_state
772 .compression_log
773 .iter()
774 .map(|e| e.action.as_str())
775 .collect();
776 assert!(
777 actions.last() == Some(&"auto_compact"),
778 "auto-compact entry must log an auto_compact action; got {actions:?}"
779 );
780 }
781
782 #[test]
783 fn skill_tool_schema_empty_when_no_skills() {
784 let mgr = ContextManager::new(10_000);
785 assert!(mgr.skill_tool_schema().is_none());
786 }
787
788 #[test]
789 fn skill_tool_schema_present_when_registered() {
790 let mut mgr = ContextManager::new(10_000);
791 mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
792 assert!(mgr.skill_tool_schema().unwrap().description.contains("debug"));
793 }
794
795 #[test]
796 fn available_skills_are_reflected_in_capability_manifest() {
797 let mut mgr = ContextManager::new(1_000);
798 mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
799 let inventory = mgr.capability_inventory();
800 assert!(inventory.contains("debug"));
801 assert!(inventory.contains("Debug helper"));
802 }
803
804 #[test]
805 fn toggled_meta_tools_are_reflected_in_capability_manifest() {
806 let mut mgr = ContextManager::new(1_000);
807 mgr.set_memory_enabled(true);
808 assert!(mgr.capability_inventory().contains(MEMORY_TOOL_NAME));
809 mgr.set_memory_enabled(false);
810 assert!(!mgr.capability_inventory().contains(MEMORY_TOOL_NAME));
811 }
812
813 #[test]
814 fn meta_tool_schemas_are_sorted() {
815 let mut mgr = ContextManager::new(1_000);
816 mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
817 mgr.set_memory_enabled(true);
818 mgr.set_knowledge_enabled(true);
819 let names = mgr.meta_tool_schemas().into_iter().map(|s| s.name.to_string()).collect::<Vec<_>>();
820 assert_eq!(names, ["knowledge", "memory", "skill"]);
821 }
822
823 #[test]
824 fn section_registry_is_available_on_manager() {
825 let mgr = ContextManager::new(1_000);
826 assert!(mgr.sections.get("capabilities.inventory").is_some());
827 }
828
829 #[test]
830 fn b1_active_skill_state_and_tool_filter() {
831 let mut mgr = ContextManager::new(1_000);
832 let mut debug = SkillMetadata::new("debug", "Debug helper");
833 debug.allowed_tools = vec![CompactString::new("read"), CompactString::new("grep")];
834 let mut review = SkillMetadata::new("review", "Reviewer");
835 review.allowed_tools = vec![CompactString::new("git_diff")];
836 let plain = SkillMetadata::new("plain", "No tools declared"); mgr.set_available_skills(vec![debug, review, plain]);
838
839 assert!(mgr.active_skill_tool_filter().is_none());
841
842 assert!(mgr.activate_skill("debug"));
844 assert!(!mgr.activate_skill("debug")); let f = mgr.active_skill_tool_filter().unwrap();
848 assert_eq!(f.len(), 2);
849 assert!(f.contains(&CompactString::new("read")) && f.contains(&CompactString::new("grep")));
850
851 mgr.activate_skill("review");
853 let f = mgr.active_skill_tool_filter().unwrap();
854 assert_eq!(f.len(), 3);
855 assert!(f.contains(&CompactString::new("git_diff")));
856
857 mgr.activate_skill("plain");
859 assert!(mgr.active_skill_tool_filter().is_none());
860 }
861
862 #[test]
863 fn snapshot_hint_changes_when_capabilities_change() {
864 let mut mgr = ContextManager::new(1_000);
865 let before = mgr.snapshot_hint();
866 mgr.set_memory_enabled(true);
867 let after = mgr.snapshot_hint();
868 assert_ne!(before.capability_manifest_hash, after.capability_manifest_hash);
869 }
870
871 #[test]
872 fn update_collapse_mode_collapses_old_tool_results_under_pressure() {
873 let mut mgr = ContextManager::new(1_000);
874 for i in 0..10 {
875 let m = Message::tool(vec![ContentPart::ToolResult {
876 call_id: format!("c{i}").into(),
877 output: "x".repeat(40),
878 is_error: false,
879 }]);
880 mgr.push_history(m, 40);
881 }
882 mgr.set_observed_prompt_tokens(950); assert!(mgr.rho() >= mgr.config.collapse_threshold);
885
886 mgr.recompute_handle_residency();
887 assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Collapsed));
889 assert_eq!(mgr.handles.residency_for_source("c9"), Some(&Residency::Resident));
890
891 mgr.set_observed_prompt_tokens(100); mgr.recompute_handle_residency();
895 assert_eq!(
896 mgr.handles.residency_for_source("c0"),
897 Some(&Residency::Collapsed),
898 "collapse is sticky until a compaction boundary"
899 );
900
901 mgr.reset_collapse_generation();
903 assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Resident));
904 }
905
906 #[test]
907 fn frozen_prefix_len_anchors_at_compaction_and_holds_across_appends() {
908 let mut mgr = ContextManager::new(1_000);
909 for i in 0..30 {
911 mgr.push_history(Message::user(format!("turn {i}: {}", "ctx ".repeat(30))), 150);
912 }
913 assert!(mgr.render().frozen_prefix_len.is_none(), "no frozen region before any compaction");
914
915 let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
916 assert!(saved > 0 && !archived.is_empty(), "expected archival");
917
918 assert!(mgr.render().frozen_prefix_len.is_none(), "deep == tail right after compaction");
920
921 mgr.push_history(Message::user("new 1"), 5);
923 let f1 = mgr.render().frozen_prefix_len.expect("frozen region exists once the tail grows");
924 mgr.push_history(Message::assistant("reply 1"), 5);
925 mgr.push_history(Message::user("new 2"), 5);
926 let rc = mgr.render();
927 let f2 = rc.frozen_prefix_len.expect("frozen region holds");
928 assert_eq!(f1, f2, "the deep boundary is fixed between compactions; only the tail grows");
929 assert!(f2 < rc.turns.len(), "deep boundary is distinct from the rolling tail");
930 }
931
932 #[test]
933 fn frozen_boundary_holds_through_a_prefix_safe_compaction() {
934 let mut mgr = ContextManager::new(10_000);
937 for i in 0..5 {
938 mgr.push_history(Message::user(format!("m{i}")), 5);
939 }
940 mgr.frozen_history_len = 3; let (_, _, _, cache_at) = mgr.compress(PressureAction::None);
945 assert!(cache_at.is_none(), "no-op compaction is prefix-safe");
946 assert_eq!(mgr.frozen_history_len, 3, "prefix-safe compaction preserves the deep-cache anchor");
947 }
948
949 #[test]
950 fn collapse_generation_resets_on_autocompact() {
951 let mut mgr = ContextManager::new(1_000);
952 for i in 0..20 {
955 mgr.push_history(tool_result_msg(&format!("c{i}"), &"x".repeat(120)), 60);
956 }
957 mgr.set_observed_prompt_tokens(980); mgr.recompute_handle_residency();
959 assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Collapsed));
960
961 let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
962 assert!(saved > 0 && !archived.is_empty(), "expected archival");
963
964 for h in mgr.handles.all() {
967 if matches!(h.kind, HandleKind::ToolResult) {
968 assert_eq!(h.residency, Residency::Resident, "generation reset un-collapses survivors");
969 }
970 }
971 }
972
973 #[test]
974 fn mark_spooled_sets_residency_and_survives_residency_recompute() {
975 let mut mgr = ContextManager::new(1_000);
976 mgr.push_history(
977 Message::tool(vec![ContentPart::ToolResult {
978 call_id: "big".into(),
979 output: "preview only".to_string(),
980 is_error: false,
981 }]),
982 10,
983 );
984 mgr.mark_spooled("big", "disk://big");
985 assert_eq!(
986 mgr.handles.residency_for_source("big"),
987 Some(&Residency::SpooledOut { r: "disk://big".to_string() })
988 );
989
990 mgr.set_observed_prompt_tokens(990);
993 mgr.recompute_handle_residency();
994 assert_eq!(
995 mgr.handles.residency_for_source("big"),
996 Some(&Residency::SpooledOut { r: "disk://big".to_string() })
997 );
998 }
999
1000 #[test]
1001 fn push_history_indexes_tool_results_as_resident_handles() {
1002 let mut mgr = ContextManager::new(10_000);
1003 let msg = Message::tool(vec![ContentPart::ToolResult {
1004 call_id: "call_1".into(),
1005 output: "the tool output".to_string(),
1006 is_error: false,
1007 }]);
1008 mgr.push_history(msg, 20);
1009 assert_eq!(mgr.handles.all().len(), 1);
1011 assert_eq!(
1012 mgr.handles.residency_for_source("call_1"),
1013 Some(&Residency::Resident)
1014 );
1015 mgr.push_history(Message::user("hello"), 5);
1017 assert_eq!(mgr.handles.all().len(), 1);
1018 }
1019
1020 fn tool_result_msg(call_id: &str, output: &str) -> Message {
1023 Message::tool(vec![ContentPart::ToolResult {
1024 call_id: call_id.into(),
1025 output: output.to_string(),
1026 is_error: false,
1027 }])
1028 }
1029
1030 #[test]
1031 fn effective_rho_discounts_paged_out_handles() {
1032 let mut mgr = ContextManager::new(1_000);
1033 let big = "data ".repeat(200);
1035 let tok = mgr.engine.count(&big);
1036 mgr.push_history(tool_result_msg("c0", &big), tok);
1037 mgr.push_history(Message::user("u"), 50);
1038
1039 let raw = mgr.rho();
1040 assert_eq!(mgr.handles.non_resident_tokens(), 0);
1042 assert!((mgr.effective_rho() - raw).abs() < f64::EPSILON);
1043
1044 mgr.mark_spooled("c0", "disk://c0");
1046 let paged = mgr.handles.non_resident_tokens();
1047 assert!(paged > 0, "handle is now non-resident with a real token weight");
1048
1049 assert!((mgr.rho() - raw).abs() < f64::EPSILON, "raw rho unchanged by paging");
1051 let total = mgr.partitions.total_tokens(&mgr.engine);
1053 let expected = total.saturating_sub(paged) as f64 / 1_000.0;
1054 assert!((mgr.effective_rho() - expected).abs() < f64::EPSILON);
1055 assert!(mgr.effective_rho() < raw, "effective pressure relieved by paging");
1056
1057 mgr.set_observed_prompt_tokens(900);
1060 assert!((mgr.effective_rho() - mgr.rho()).abs() < f64::EPSILON);
1061 }
1062
1063 #[test]
1064 fn prune_orphaned_handles_drops_handles_whose_message_left_history() {
1065 let mut mgr = ContextManager::new(10_000);
1066 mgr.push_history(tool_result_msg("c0", "out 0"), 20);
1067 mgr.push_history(tool_result_msg("c1", "out 1"), 20);
1068 assert_eq!(mgr.handles.all().len(), 2);
1069
1070 mgr.partitions.history.messages.remove(0);
1072 mgr.prune_orphaned_handles();
1073
1074 assert_eq!(mgr.handles.all().len(), 1);
1076 assert!(mgr.handles.residency_for_source("c0").is_none());
1077 assert_eq!(
1078 mgr.handles.residency_for_source("c1"),
1079 Some(&Residency::Resident)
1080 );
1081 }
1082
1083 #[test]
1084 fn autocompact_prunes_handles_for_archived_tool_results() {
1085 let mut mgr = ContextManager::new(1_000);
1086 for i in 0..30 {
1088 mgr.push_history(tool_result_msg(&format!("c{i}"), &"x".repeat(200)), 80);
1089 }
1090 assert_eq!(mgr.handles.all().len(), 30);
1091
1092 let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
1093 assert!(saved > 0 && !archived.is_empty(), "expected archival");
1094
1095 let live_tool_results = mgr
1098 .partitions
1099 .history
1100 .messages
1101 .iter()
1102 .filter(|m| matches!(&m.content, Content::Parts(p)
1103 if p.iter().any(|x| matches!(x, ContentPart::ToolResult { .. }))))
1104 .count();
1105 assert_eq!(mgr.handles.all().len(), live_tool_results);
1106 assert!(mgr.handles.all().len() < 30, "table must shrink with archival");
1107 }
1108
1109 #[test]
1110 fn renew_prunes_handles_for_dropped_history() {
1111 let mut mgr = ContextManager::new(1_000);
1112 mgr.init_task("g".to_string(), vec![]);
1113 for i in 0..20 {
1114 mgr.push_history(tool_result_msg(&format!("c{i}"), "data"), 60);
1115 }
1116 mgr.renew();
1117 for h in mgr.handles.all() {
1119 if let Some(src) = h.source.as_ref() {
1120 assert!(
1121 mgr.handles.residency_for_source(src).is_some(),
1122 "no dangling handle survives renewal"
1123 );
1124 }
1125 }
1126 assert!(mgr.handles.all().len() <= 20);
1127 }
1128
1129 #[test]
1130 fn recompute_residency_index_semantics_with_spooled_in_the_middle() {
1131 let mut mgr = ContextManager::new(1_000);
1134 for i in 0..6 {
1135 mgr.push_history(tool_result_msg(&format!("c{i}"), &"y".repeat(40)), 40);
1136 }
1137 mgr.mark_spooled("c2", "disk://c2");
1138
1139 mgr.set_observed_prompt_tokens(950); mgr.recompute_handle_residency();
1141
1142 assert_eq!(
1144 mgr.handles.residency_for_source("c2"),
1145 Some(&Residency::SpooledOut { r: "disk://c2".to_string() })
1146 );
1147 assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Collapsed));
1148 assert_eq!(mgr.handles.residency_for_source("c5"), Some(&Residency::Resident));
1149 }
1150}