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
21const META_TOOL_NAMES: &[&str] = &[
24 "update_plan",
25 "skill",
26 MEMORY_TOOL_NAME,
27 KNOWLEDGE_TOOL_NAME,
28 "submit_workflow_nodes",
29 "start_workflow",
30];
31
32fn is_meta_tool(name: &str) -> bool {
33 META_TOOL_NAMES.contains(&name)
34}
35
36#[doc(hidden)]
41pub struct ContextManager {
42 pub partitions: ContextPartitions,
43 pub max_tokens: u32,
44 pub config: ContextConfig,
45 pub engine: ContextTokenEngine,
46 pub sprint: u32,
47 pub last_handoff: Option<HandoffArtifact>,
48 pub skills: SkillCatalog,
49 pub active_skills: std::collections::BTreeSet<CompactString>,
54 pub stable_core_tools: std::collections::HashSet<CompactString>,
58 pub capabilities: CapabilityManifest,
59 pub sections: ContextSectionRegistry,
60 pub memory_enabled: bool,
61 pub knowledge_enabled: bool,
62 pub plan_tool_enabled: bool,
63 last_observed_prompt_tokens: Option<u32>,
64 compression: CompressionPipeline,
65 pressure: PressureMonitor,
66 renewal: RenewalPolicy,
67
68 pub last_activity_ms: u64,
73
74 pub last_compact_ms: Option<u64>,
77
78 pub handles: HandleTable,
84 next_handle_id: HandleId,
86
87 frozen_history_len: usize,
93}
94
95impl ContextManager {
96 pub fn new(max_tokens: u32) -> Self {
97 Self::with_config(max_tokens, ContextConfig::default(), ContextTokenEngine::char_approx())
98 }
99
100 pub fn with_config(max_tokens: u32, config: ContextConfig, engine: ContextTokenEngine) -> Self {
101 let compression = CompressionPipeline::new(&config);
102 let pressure = PressureMonitor::new(max_tokens, config.clone());
103 let renewal = RenewalPolicy::from_config(&config);
104 let partitions = ContextPartitions::new(&config);
105 Self {
106 partitions, max_tokens, config, engine,
107 sprint: 0, last_handoff: None,
108 skills: SkillCatalog::new(),
109 active_skills: std::collections::BTreeSet::new(),
110 stable_core_tools: std::collections::HashSet::new(),
111 capabilities: CapabilityManifest::new(),
112 sections: ContextSectionRegistry::default_agent_sections(),
113 memory_enabled: false, knowledge_enabled: false, plan_tool_enabled: false,
114 last_observed_prompt_tokens: None,
115 compression, pressure, renewal,
116 last_activity_ms: 0,
117 last_compact_ms: None,
118 handles: HandleTable::new(),
119 next_handle_id: 0,
120 frozen_history_len: 0,
121 }
122 }
123
124 pub fn record_activity(&mut self, now_ms: u64) {
128 self.last_activity_ms = now_ms;
129 }
130
131 pub fn should_time_decay_compact(&self, now_ms: u64) -> bool {
134 let idle_ms = if let Some(last_compact) = self.last_compact_ms {
135 now_ms.saturating_sub(last_compact)
137 } else {
138 now_ms.saturating_sub(self.last_activity_ms)
140 };
141
142 let idle_minutes = idle_ms / 60_000;
143 idle_minutes >= self.config.micro_compact_idle_minutes as u64
144 }
145
146 pub fn recompute_handle_residency(&mut self) {
161 if self.rho() < self.config.collapse_threshold {
163 return;
164 }
165 let keep = self.config.preserve_recent_msgs;
166 let total = self
169 .handles
170 .all()
171 .iter()
172 .filter(|h| matches!(h.kind, HandleKind::ToolResult))
173 .count();
174 let cutoff = total.saturating_sub(keep);
175 for (i, handle) in self.handles.tool_result_handles_mut().enumerate() {
176 if i < cutoff && matches!(handle.residency, Residency::Resident) {
179 handle.residency = Residency::Collapsed;
180 }
181 }
182 }
183
184 pub fn reset_collapse_generation(&mut self) {
190 for handle in self.handles.all_mut() {
191 if matches!(handle.residency, Residency::Collapsed) {
192 handle.residency = Residency::Resident;
193 }
194 }
195 }
196
197 pub fn prune_orphaned_handles(&mut self) {
204 let live: std::collections::HashSet<CompactString> = self
205 .partitions
206 .history
207 .messages
208 .iter()
209 .flat_map(|m| match &m.content {
210 Content::Parts(parts) => parts
211 .iter()
212 .filter_map(|p| match p {
213 ContentPart::ToolResult { call_id, .. } => Some(call_id.clone()),
214 _ => None,
215 })
216 .collect::<Vec<_>>(),
217 _ => Vec::new(),
218 })
219 .collect();
220 self.handles
221 .retain(|h| h.source.as_ref().is_none_or(|s| live.contains(s)));
222 }
223
224 pub fn mark_spooled(&mut self, call_id: &str, spool_ref: impl Into<String>) {
228 let spool_ref = spool_ref.into();
229 if let Some(handle) = self
230 .handles
231 .all_mut()
232 .iter_mut()
233 .find(|h| h.source.as_deref() == Some(call_id))
234 {
235 handle.residency = Residency::SpooledOut { r: spool_ref };
236 }
237 }
238
239 pub fn rho(&self) -> f64 {
246 self.pressure
247 .pressure(&self.partitions, &self.engine, self.last_observed_prompt_tokens)
248 }
249
250 pub fn effective_rho(&self) -> f64 {
261 if self.max_tokens == 0 || self.last_observed_prompt_tokens.is_some() {
262 return self.rho();
263 }
264 let total = self.partitions.total_tokens(&self.engine);
265 let effective = total.saturating_sub(self.handles.non_resident_tokens());
266 effective as f64 / self.max_tokens as f64
267 }
268
269 pub fn set_observed_prompt_tokens(&mut self, tokens: u32) {
270 self.last_observed_prompt_tokens = Some(tokens);
271 }
272
273 pub fn should_compress(&self) -> PressureAction {
274 self.pressure.recommend(self.rho())
283 }
284
285 pub fn compress(&mut self, action: PressureAction) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
286 self.compress_with_time(action, None)
287 }
288
289 pub fn compress_with_time(
290 &mut self,
291 action: PressureAction,
292 now_ms: Option<u64>,
293 ) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
294 if self.sections.is_partition_pinned(ContextSectionPartition::History) {
295 return (0, None, vec![], None);
296 }
297
298 let result = {
299 let target = self.config.target_tokens(self.max_tokens);
300 self.compression.compress(&mut self.partitions, action, self.max_tokens, target, &self.engine)
301 };
302
303 if let Some(ts) = now_ms {
305 self.last_compact_ms = Some(ts);
306 }
307
308 if !result.2.is_empty() {
310 self.prune_orphaned_handles();
311 self.reset_collapse_generation();
314 }
315 if result.3.is_some() {
322 self.frozen_history_len = self.partitions.history.messages.len();
323 }
324
325 result
326 }
327
328 pub fn force_compress(&mut self) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
329 if self.sections.is_partition_pinned(ContextSectionPartition::History) {
330 return (0, None, vec![], None);
331 }
332 let result = self.compression.compress(&mut self.partitions, PressureAction::AutoCompact, self.max_tokens, 0, &self.engine);
333 if !result.2.is_empty() {
334 self.prune_orphaned_handles();
335 self.reset_collapse_generation();
338 }
339 if result.3.is_some() {
346 self.frozen_history_len = self.partitions.history.messages.len();
347 }
348 result
349 }
350
351 pub fn compress_with_target(
357 &mut self,
358 action: PressureAction,
359 target_tokens: u32,
360 now_ms: Option<u64>,
361 ) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
362 if self.sections.is_partition_pinned(ContextSectionPartition::History) {
363 return (0, None, vec![], None);
364 }
365 let result =
366 self.compression
367 .compress(&mut self.partitions, action, self.max_tokens, target_tokens, &self.engine);
368 if let Some(ts) = now_ms {
369 self.last_compact_ms = Some(ts);
370 }
371 if !result.2.is_empty() {
372 self.prune_orphaned_handles();
373 self.reset_collapse_generation();
376 }
377 if result.3.is_some() {
384 self.frozen_history_len = self.partitions.history.messages.len();
385 }
386 result
387 }
388
389 pub fn plan_compaction_params(&self) -> (u32, usize) {
393 (
394 self.config.target_tokens(self.max_tokens),
395 self.config.preserve_recent_turns,
396 )
397 }
398
399 pub fn should_renew(&self) -> bool {
402 self.renewal.should_renew(&self.pressure, &self.partitions, &self.engine)
403 }
404
405 pub fn renew(&mut self) {
406 let goal = self.partitions.task_state.goal.clone();
407 let (renewed, artifact) = self.renewal.renew(&self.partitions, &goal, self.sprint, self.max_tokens);
408 self.partitions = renewed;
409 self.last_handoff = Some(artifact);
410 self.sprint += 1;
411 self.prune_orphaned_handles();
414 self.reset_collapse_generation();
415 self.frozen_history_len = self.partitions.history.messages.len();
417 }
418
419 pub fn render(&self) -> RenderedContext {
422 super::renderer::render_projected(
423 &self.partitions,
424 self.max_tokens,
425 &self.engine,
426 self.config.preserve_recent_msgs,
427 &self.handles,
428 self.frozen_history_len,
429 self.config.collapse_assistant_narration,
430 )
431 }
432
433 pub fn snapshot_hint(&self) -> ContextSnapshotHint {
434 ContextSnapshotHint::from_parts(&self.sections, &self.capabilities)
435 }
436
437 pub fn take_snapshot(&self, turn: u32) -> ContextSnapshot {
438 ContextSnapshot {
439 turn,
440 system_messages: self.partitions.system.messages.clone(),
441 knowledge_messages: self.partitions.knowledge.messages.clone(),
442 history_messages: self.partitions.history.messages.clone(),
443 task_state: self.partitions.task_state.clone(),
444 }
445 }
446
447 pub fn push_history(&mut self, msg: Message, tokens: u32) {
450 if let Content::Parts(parts) = &msg.content {
454 for part in parts {
455 if let ContentPart::ToolResult { call_id, output, .. } = part {
456 let id = self.alloc_handle_id();
457 let tok = self.engine.count(output).max(1);
458 self.handles.insert(Handle::resident_for(
459 id,
460 HandleKind::ToolResult,
461 tok,
462 call_id.clone(),
463 ));
464 }
465 }
466 }
467 self.partitions.history.push(msg, tokens);
468 }
469
470 fn alloc_handle_id(&mut self) -> HandleId {
471 let id = self.next_handle_id;
472 self.next_handle_id = self.next_handle_id.wrapping_add(1);
473 id
474 }
475
476 pub fn push_knowledge(&mut self, msg: Message, tokens: u32) {
478 self.partitions.knowledge.push(msg, tokens);
479 }
480
481 pub fn push_signal(&mut self, text: String) {
484 self.partitions.signals.push(text);
485 }
486
487 pub fn record_directive(&mut self, text: impl Into<String>) {
491 self.partitions.task_state.record_directive(text);
492 }
493
494 pub fn init_task(&mut self, goal: String, criteria: Vec<String>) {
497 self.partitions.task_state = TaskState { goal, criteria, ..Default::default() };
498 }
499
500 pub fn update_task(&mut self, update: TaskUpdate) {
501 self.partitions.task_state.apply(update);
502 }
503
504 pub fn note_tool_actions(&mut self, names: &[String]) {
509 let summary = names
510 .iter()
511 .map(|s| s.as_str())
512 .filter(|n| !is_meta_tool(n))
513 .collect::<Vec<_>>()
514 .join(", ");
515 self.partitions.task_state.note_actions(summary);
516 }
517
518 pub fn pin_section(&mut self, id: &str) -> bool { self.sections.pin(id) }
521 pub fn unpin_section(&mut self, id: &str) -> bool { self.sections.unpin(id) }
522
523 pub fn set_available_skills(&mut self, skills: Vec<SkillMetadata>) {
526 self.capabilities.remove_kind(CapabilityKind::Skill);
527 for skill in &skills { self.capabilities.add_skill(skill.clone()); }
528 self.skills.set_available(skills);
529 }
530
531 pub fn set_stable_core_tools(&mut self, ids: impl IntoIterator<Item = CompactString>) {
533 self.stable_core_tools = ids.into_iter().collect();
534 }
535
536 pub fn activate_skill(&mut self, name: impl Into<CompactString>) -> bool {
540 self.active_skills.insert(name.into())
541 }
542
543 pub fn active_skill_tool_filter(&self) -> Option<std::collections::HashSet<CompactString>> {
549 if self.active_skills.is_empty() {
550 return None;
551 }
552 let mut union = std::collections::HashSet::new();
553 for name in &self.active_skills {
554 let declared = self.skills.allowed_tools(name);
555 if declared.is_empty() {
556 return None; }
558 union.extend(declared.iter().cloned());
559 }
560 Some(union)
561 }
562
563 pub fn skill_tool_schema(&self) -> Option<ToolSchema> {
564 self.skills.build_tool_schema()
565 }
566
567 pub fn set_memory_enabled(&mut self, enabled: bool) {
570 self.memory_enabled = enabled;
571 if enabled {
572 self.capabilities.add_marker(CapabilityKind::Memory, MEMORY_TOOL_NAME,
573 "Search long-term memory through the memory meta-tool.");
574 } else {
575 self.capabilities.remove(CapabilityKind::Memory, MEMORY_TOOL_NAME);
576 }
577 }
578
579 pub fn set_knowledge_enabled(&mut self, enabled: bool) {
580 self.knowledge_enabled = enabled;
581 if enabled {
582 self.capabilities.add_marker(CapabilityKind::Knowledge, KNOWLEDGE_TOOL_NAME,
583 "Search external knowledge through the knowledge meta-tool.");
584 } else {
585 self.capabilities.remove(CapabilityKind::Knowledge, KNOWLEDGE_TOOL_NAME);
586 }
587 }
588
589 pub fn set_plan_tool_enabled(&mut self, enabled: bool) {
590 self.plan_tool_enabled = enabled;
591 if enabled {
592 self.capabilities.add_marker(CapabilityKind::Tool, "update_plan",
593 "Update task plan and progress through the planning meta-tool.");
594 } else {
595 self.capabilities.remove(CapabilityKind::Tool, "update_plan");
596 }
597 }
598
599 pub fn capability_inventory(&self) -> String { self.capabilities.format_inventory() }
600
601 pub fn meta_tool_schemas(&self) -> Vec<ToolSchema> {
602 let mut tools = Vec::new();
603 if let Some(t) = self.skill_tool_schema() { tools.push(t); }
604 if let Some(t) = self.memory_tool_schema() { tools.push(t); }
605 if let Some(t) = self.knowledge_tool_schema() { tools.push(t); }
606 if let Some(t) = self.plan_tool_schema() { tools.push(t); }
607 tools.sort_by(|a, b| a.name.cmp(&b.name));
608 tools
609 }
610
611 pub fn plan_tool_schema(&self) -> Option<ToolSchema> {
612 if !self.plan_tool_enabled { return None; }
613 Some(ToolSchema {
614 name: CompactString::new("update_plan"),
615 description: "Update your task plan and progress. Call this after completing a step or when the plan changes.".to_string(),
616 parameters: serde_json::json!({
617 "type": "object",
618 "properties": {
619 "plan": { "type": "array", "items": { "type": "string" } },
620 "current_step": { "type": "integer" },
621 "progress": { "type": "string" },
622 "blocked_on": { "type": "array", "items": { "type": "string" } }
623 }
624 }),
625 })
626 }
627
628 pub fn memory_tool_schema(&self) -> Option<ToolSchema> {
629 if !self.memory_enabled { return None; }
630 Some(ToolSchema {
631 name: CompactString::new(MEMORY_TOOL_NAME),
632 description: "Search your long-term memory for relevant past experiences and knowledge.".to_string(),
633 parameters: serde_json::json!({
634 "type": "object",
635 "properties": {
636 "query": { "type": "string" },
637 "top_k": { "type": "integer" }
638 },
639 "required": ["query"]
640 }),
641 })
642 }
643
644 pub fn knowledge_tool_schema(&self) -> Option<ToolSchema> {
645 if !self.knowledge_enabled { return None; }
646 Some(ToolSchema {
647 name: CompactString::new(KNOWLEDGE_TOOL_NAME),
648 description: "Search the external knowledge base for facts, documentation, or reference data.".to_string(),
649 parameters: serde_json::json!({
650 "type": "object",
651 "properties": {
652 "query": { "type": "string" },
653 "top_k": { "type": "integer" }
654 },
655 "required": ["query"]
656 }),
657 })
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664 use crate::context::task_state::PlanStep;
665 use crate::types::message::Message;
666 use crate::types::skill::SkillMetadata;
667
668 #[test]
669 fn manager_renew_uses_task_state_goal() {
670 let mut mgr = ContextManager::new(1_000);
671 mgr.init_task("test goal".to_string(), vec![]);
672 mgr.partitions.system.push(Message::system("rules"), 10);
673 for i in 0..10 { mgr.push_history(Message::user(format!("msg {i}")), 50); }
674 mgr.renew();
675 let artifact = mgr.last_handoff.as_ref().unwrap();
676 assert_eq!(artifact.goal, "test goal");
677 assert_eq!(mgr.sprint, 1);
678 }
679
680 #[test]
681 fn compress_only_touches_history() {
682 let mut mgr = ContextManager::new(1_000);
683 mgr.push_knowledge(Message::system("knowledge content"), 100);
684 for _ in 0..30 { mgr.push_history(Message::user("history msg"), 50); }
685 let knowledge_before = mgr.partitions.knowledge.token_count;
686 let history_before = mgr.partitions.history.token_count;
687 mgr.compress(PressureAction::AutoCompact);
688 assert_eq!(mgr.partitions.knowledge.token_count, knowledge_before);
689 assert!(mgr.partitions.history.token_count < history_before);
690 }
691
692 #[test]
693 fn init_task_sets_goal_and_criteria() {
694 let mut mgr = ContextManager::new(1_000);
695 mgr.init_task("analyse data".to_string(), vec!["criterion A".to_string()]);
696 assert_eq!(mgr.partitions.task_state.goal, "analyse data");
697 assert_eq!(mgr.partitions.task_state.criteria, ["criterion A"]);
698 }
699
700 #[test]
701 fn update_task_applies_plan() {
702 let mut mgr = ContextManager::new(1_000);
703 mgr.init_task("g".to_string(), vec![]);
704 mgr.update_task(TaskUpdate {
705 plan: Some(vec!["step 1".to_string(), "step 2".to_string()]),
706 current_step: Some(0),
707 ..Default::default()
708 });
709 assert_eq!(mgr.partitions.task_state.plan.len(), 2);
710 assert_eq!(mgr.partitions.task_state.current_step, Some(0));
711 }
712
713 #[test]
714 fn task_state_survives_autocompact() {
715 let mut mgr = ContextManager::new(1_000);
716 mgr.init_task("survive compression".to_string(), vec![]);
717 mgr.update_task(TaskUpdate {
718 plan: Some(vec!["fetch data".to_string(), "analyse".to_string()]),
719 ..Default::default()
720 });
721 for _ in 0..10 { mgr.push_history(Message::user("filler"), 50); }
722 mgr.compress(PressureAction::AutoCompact);
723 assert_eq!(mgr.partitions.task_state.goal, "survive compression");
724 assert_eq!(mgr.partitions.task_state.plan.len(), 2);
725 }
726
727 #[test]
728 fn render_includes_task_state_in_state_turn_not_system() {
729 let mut mgr = ContextManager::new(10_000);
730 mgr.init_task("find anomalies".to_string(), vec![]);
731 let rc = mgr.render();
732 assert!(!rc.system_text.contains("[TASK STATE]"), "task_state must not be in system_text");
733 let state = rc.state_turn.as_ref().expect("should have a state turn");
735 assert!(state.content.as_text().unwrap().contains("[TASK STATE] goal: find anomalies"));
736 }
737
738 #[test]
739 fn renewal_open_tasks_from_task_state() {
740 let mut mgr = ContextManager::new(1_000);
741 mgr.init_task("g".to_string(), vec![]);
742 mgr.partitions.task_state.plan = vec![
743 PlanStep { label: "done".to_string(), done: true },
744 PlanStep { label: "pending".to_string(), done: false },
745 ];
746 mgr.renew();
747 let artifact = mgr.last_handoff.as_ref().unwrap();
748 assert_eq!(artifact.open_tasks, vec!["pending"]);
749 }
750
751 #[test]
752 fn pinned_history_section_skips_compression() {
753 let mut mgr = ContextManager::new(1_000);
754 for _ in 0..30 { mgr.push_history(Message::user("filler message for pinning test"), 50); }
755 let tokens_before = mgr.partitions.history.token_count;
756 mgr.pin_section("history.rolling");
757 let (saved, _, _, _) = mgr.compress(PressureAction::AutoCompact);
758 assert_eq!(saved, 0);
759 assert_eq!(mgr.partitions.history.token_count, tokens_before);
760 }
761
762 #[test]
763 fn unpinned_history_section_allows_compression() {
764 let mut mgr = ContextManager::new(1_000);
765 for _ in 0..30 { mgr.push_history(Message::user("filler"), 50); }
766 mgr.pin_section("history.rolling");
767 mgr.unpin_section("history.rolling");
768 let (saved, _, _, _) = mgr.compress(PressureAction::AutoCompact);
769 assert!(saved > 0);
770 }
771
772 #[test]
773 fn force_compress_also_skips_when_history_pinned() {
774 let mut mgr = ContextManager::new(1_000);
775 for _ in 0..10 { mgr.push_history(Message::user("filler"), 50); }
776 mgr.pin_section("history.rolling");
777 let (saved, _, _, _) = mgr.force_compress();
778 assert_eq!(saved, 0);
779 }
780
781 #[test]
784 fn auto_compact_entry_logs_auto_compact_action() {
785 let mut mgr = ContextManager::new(1_000);
793 for i in 0..40 {
794 mgr.push_history(Message::user(format!("turn {i}: {}", "ctx ".repeat(40))), 200);
795 }
796 let (saved, summary, _, _) = mgr.force_compress();
797 assert!(saved > 0, "force_compress should compact a large history");
798 assert!(summary.is_some(), "auto-compact summarizes the archived turns");
799 let actions: Vec<&str> = mgr
800 .partitions
801 .task_state
802 .compression_log
803 .iter()
804 .map(|e| e.action.as_str())
805 .collect();
806 assert!(
807 actions.last() == Some(&"auto_compact"),
808 "auto-compact entry must log an auto_compact action; got {actions:?}"
809 );
810 }
811
812 #[test]
813 fn skill_tool_schema_empty_when_no_skills() {
814 let mgr = ContextManager::new(10_000);
815 assert!(mgr.skill_tool_schema().is_none());
816 }
817
818 #[test]
819 fn skill_tool_schema_present_when_registered() {
820 let mut mgr = ContextManager::new(10_000);
821 mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
822 assert!(mgr.skill_tool_schema().unwrap().description.contains("debug"));
823 }
824
825 #[test]
826 fn available_skills_are_reflected_in_capability_manifest() {
827 let mut mgr = ContextManager::new(1_000);
828 mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
829 let inventory = mgr.capability_inventory();
830 assert!(inventory.contains("debug"));
831 assert!(inventory.contains("Debug helper"));
832 }
833
834 #[test]
835 fn toggled_meta_tools_are_reflected_in_capability_manifest() {
836 let mut mgr = ContextManager::new(1_000);
837 mgr.set_memory_enabled(true);
838 assert!(mgr.capability_inventory().contains(MEMORY_TOOL_NAME));
839 mgr.set_memory_enabled(false);
840 assert!(!mgr.capability_inventory().contains(MEMORY_TOOL_NAME));
841 }
842
843 #[test]
844 fn meta_tool_schemas_are_sorted() {
845 let mut mgr = ContextManager::new(1_000);
846 mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
847 mgr.set_memory_enabled(true);
848 mgr.set_knowledge_enabled(true);
849 let names = mgr.meta_tool_schemas().into_iter().map(|s| s.name.to_string()).collect::<Vec<_>>();
850 assert_eq!(names, ["knowledge", "memory", "skill"]);
851 }
852
853 #[test]
854 fn section_registry_is_available_on_manager() {
855 let mgr = ContextManager::new(1_000);
856 assert!(mgr.sections.get("capabilities.inventory").is_some());
857 }
858
859 #[test]
860 fn b1_active_skill_state_and_tool_filter() {
861 let mut mgr = ContextManager::new(1_000);
862 let mut debug = SkillMetadata::new("debug", "Debug helper");
863 debug.allowed_tools = vec![CompactString::new("read"), CompactString::new("grep")];
864 let mut review = SkillMetadata::new("review", "Reviewer");
865 review.allowed_tools = vec![CompactString::new("git_diff")];
866 let plain = SkillMetadata::new("plain", "No tools declared"); mgr.set_available_skills(vec![debug, review, plain]);
868
869 assert!(mgr.active_skill_tool_filter().is_none());
871
872 assert!(mgr.activate_skill("debug"));
874 assert!(!mgr.activate_skill("debug")); let f = mgr.active_skill_tool_filter().unwrap();
878 assert_eq!(f.len(), 2);
879 assert!(f.contains(&CompactString::new("read")) && f.contains(&CompactString::new("grep")));
880
881 mgr.activate_skill("review");
883 let f = mgr.active_skill_tool_filter().unwrap();
884 assert_eq!(f.len(), 3);
885 assert!(f.contains(&CompactString::new("git_diff")));
886
887 mgr.activate_skill("plain");
889 assert!(mgr.active_skill_tool_filter().is_none());
890 }
891
892 #[test]
893 fn snapshot_hint_changes_when_capabilities_change() {
894 let mut mgr = ContextManager::new(1_000);
895 let before = mgr.snapshot_hint();
896 mgr.set_memory_enabled(true);
897 let after = mgr.snapshot_hint();
898 assert_ne!(before.capability_manifest_hash, after.capability_manifest_hash);
899 }
900
901 #[test]
902 fn update_collapse_mode_collapses_old_tool_results_under_pressure() {
903 let mut mgr = ContextManager::new(1_000);
904 for i in 0..10 {
905 let m = Message::tool(vec![ContentPart::ToolResult {
906 call_id: format!("c{i}").into(),
907 output: "x".repeat(40),
908 is_error: false,
909 }]);
910 mgr.push_history(m, 40);
911 }
912 mgr.set_observed_prompt_tokens(950); assert!(mgr.rho() >= mgr.config.collapse_threshold);
915
916 mgr.recompute_handle_residency();
917 assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Collapsed));
919 assert_eq!(mgr.handles.residency_for_source("c9"), Some(&Residency::Resident));
920
921 mgr.set_observed_prompt_tokens(100); mgr.recompute_handle_residency();
925 assert_eq!(
926 mgr.handles.residency_for_source("c0"),
927 Some(&Residency::Collapsed),
928 "collapse is sticky until a compaction boundary"
929 );
930
931 mgr.reset_collapse_generation();
933 assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Resident));
934 }
935
936 #[test]
937 fn frozen_prefix_len_anchors_at_compaction_and_holds_across_appends() {
938 let mut mgr = ContextManager::new(1_000);
939 for i in 0..30 {
941 mgr.push_history(Message::user(format!("turn {i}: {}", "ctx ".repeat(30))), 150);
942 }
943 assert!(mgr.render().frozen_prefix_len.is_none(), "no frozen region before any compaction");
944
945 let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
946 assert!(saved > 0 && !archived.is_empty(), "expected archival");
947
948 assert!(mgr.render().frozen_prefix_len.is_none(), "deep == tail right after compaction");
950
951 mgr.push_history(Message::user("new 1"), 5);
953 let f1 = mgr.render().frozen_prefix_len.expect("frozen region exists once the tail grows");
954 mgr.push_history(Message::assistant("reply 1"), 5);
955 mgr.push_history(Message::user("new 2"), 5);
956 let rc = mgr.render();
957 let f2 = rc.frozen_prefix_len.expect("frozen region holds");
958 assert_eq!(f1, f2, "the deep boundary is fixed between compactions; only the tail grows");
959 assert!(f2 < rc.turns.len(), "deep boundary is distinct from the rolling tail");
960 }
961
962 #[test]
963 fn frozen_boundary_holds_through_a_prefix_safe_compaction() {
964 let mut mgr = ContextManager::new(10_000);
967 for i in 0..5 {
968 mgr.push_history(Message::user(format!("m{i}")), 5);
969 }
970 mgr.frozen_history_len = 3; let (_, _, _, cache_at) = mgr.compress(PressureAction::None);
975 assert!(cache_at.is_none(), "no-op compaction is prefix-safe");
976 assert_eq!(mgr.frozen_history_len, 3, "prefix-safe compaction preserves the deep-cache anchor");
977 }
978
979 #[test]
980 fn collapse_generation_resets_on_autocompact() {
981 let mut mgr = ContextManager::new(1_000);
982 for i in 0..20 {
985 mgr.push_history(tool_result_msg(&format!("c{i}"), &"x".repeat(120)), 60);
986 }
987 mgr.set_observed_prompt_tokens(980); mgr.recompute_handle_residency();
989 assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Collapsed));
990
991 let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
992 assert!(saved > 0 && !archived.is_empty(), "expected archival");
993
994 for h in mgr.handles.all() {
997 if matches!(h.kind, HandleKind::ToolResult) {
998 assert_eq!(h.residency, Residency::Resident, "generation reset un-collapses survivors");
999 }
1000 }
1001 }
1002
1003 #[test]
1004 fn mark_spooled_sets_residency_and_survives_residency_recompute() {
1005 let mut mgr = ContextManager::new(1_000);
1006 mgr.push_history(
1007 Message::tool(vec![ContentPart::ToolResult {
1008 call_id: "big".into(),
1009 output: "preview only".to_string(),
1010 is_error: false,
1011 }]),
1012 10,
1013 );
1014 mgr.mark_spooled("big", "disk://big");
1015 assert_eq!(
1016 mgr.handles.residency_for_source("big"),
1017 Some(&Residency::SpooledOut { r: "disk://big".to_string() })
1018 );
1019
1020 mgr.set_observed_prompt_tokens(990);
1023 mgr.recompute_handle_residency();
1024 assert_eq!(
1025 mgr.handles.residency_for_source("big"),
1026 Some(&Residency::SpooledOut { r: "disk://big".to_string() })
1027 );
1028 }
1029
1030 #[test]
1031 fn push_history_indexes_tool_results_as_resident_handles() {
1032 let mut mgr = ContextManager::new(10_000);
1033 let msg = Message::tool(vec![ContentPart::ToolResult {
1034 call_id: "call_1".into(),
1035 output: "the tool output".to_string(),
1036 is_error: false,
1037 }]);
1038 mgr.push_history(msg, 20);
1039 assert_eq!(mgr.handles.all().len(), 1);
1041 assert_eq!(
1042 mgr.handles.residency_for_source("call_1"),
1043 Some(&Residency::Resident)
1044 );
1045 mgr.push_history(Message::user("hello"), 5);
1047 assert_eq!(mgr.handles.all().len(), 1);
1048 }
1049
1050 fn tool_result_msg(call_id: &str, output: &str) -> Message {
1053 Message::tool(vec![ContentPart::ToolResult {
1054 call_id: call_id.into(),
1055 output: output.to_string(),
1056 is_error: false,
1057 }])
1058 }
1059
1060 #[test]
1061 fn effective_rho_discounts_paged_out_handles() {
1062 let mut mgr = ContextManager::new(1_000);
1063 let big = "data ".repeat(200);
1065 let tok = mgr.engine.count(&big);
1066 mgr.push_history(tool_result_msg("c0", &big), tok);
1067 mgr.push_history(Message::user("u"), 50);
1068
1069 let raw = mgr.rho();
1070 assert_eq!(mgr.handles.non_resident_tokens(), 0);
1072 assert!((mgr.effective_rho() - raw).abs() < f64::EPSILON);
1073
1074 mgr.mark_spooled("c0", "disk://c0");
1076 let paged = mgr.handles.non_resident_tokens();
1077 assert!(paged > 0, "handle is now non-resident with a real token weight");
1078
1079 assert!((mgr.rho() - raw).abs() < f64::EPSILON, "raw rho unchanged by paging");
1081 let total = mgr.partitions.total_tokens(&mgr.engine);
1083 let expected = total.saturating_sub(paged) as f64 / 1_000.0;
1084 assert!((mgr.effective_rho() - expected).abs() < f64::EPSILON);
1085 assert!(mgr.effective_rho() < raw, "effective pressure relieved by paging");
1086
1087 mgr.set_observed_prompt_tokens(900);
1090 assert!((mgr.effective_rho() - mgr.rho()).abs() < f64::EPSILON);
1091 }
1092
1093 #[test]
1094 fn prune_orphaned_handles_drops_handles_whose_message_left_history() {
1095 let mut mgr = ContextManager::new(10_000);
1096 mgr.push_history(tool_result_msg("c0", "out 0"), 20);
1097 mgr.push_history(tool_result_msg("c1", "out 1"), 20);
1098 assert_eq!(mgr.handles.all().len(), 2);
1099
1100 mgr.partitions.history.messages.remove(0);
1102 mgr.prune_orphaned_handles();
1103
1104 assert_eq!(mgr.handles.all().len(), 1);
1106 assert!(mgr.handles.residency_for_source("c0").is_none());
1107 assert_eq!(
1108 mgr.handles.residency_for_source("c1"),
1109 Some(&Residency::Resident)
1110 );
1111 }
1112
1113 #[test]
1114 fn autocompact_prunes_handles_for_archived_tool_results() {
1115 let mut mgr = ContextManager::new(1_000);
1116 for i in 0..30 {
1118 mgr.push_history(tool_result_msg(&format!("c{i}"), &"x".repeat(200)), 80);
1119 }
1120 assert_eq!(mgr.handles.all().len(), 30);
1121
1122 let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
1123 assert!(saved > 0 && !archived.is_empty(), "expected archival");
1124
1125 let live_tool_results = mgr
1128 .partitions
1129 .history
1130 .messages
1131 .iter()
1132 .filter(|m| matches!(&m.content, Content::Parts(p)
1133 if p.iter().any(|x| matches!(x, ContentPart::ToolResult { .. }))))
1134 .count();
1135 assert_eq!(mgr.handles.all().len(), live_tool_results);
1136 assert!(mgr.handles.all().len() < 30, "table must shrink with archival");
1137 }
1138
1139 #[test]
1140 fn renew_prunes_handles_for_dropped_history() {
1141 let mut mgr = ContextManager::new(1_000);
1142 mgr.init_task("g".to_string(), vec![]);
1143 for i in 0..20 {
1144 mgr.push_history(tool_result_msg(&format!("c{i}"), "data"), 60);
1145 }
1146 mgr.renew();
1147 for h in mgr.handles.all() {
1149 if let Some(src) = h.source.as_ref() {
1150 assert!(
1151 mgr.handles.residency_for_source(src).is_some(),
1152 "no dangling handle survives renewal"
1153 );
1154 }
1155 }
1156 assert!(mgr.handles.all().len() <= 20);
1157 }
1158
1159 #[test]
1160 fn recompute_residency_index_semantics_with_spooled_in_the_middle() {
1161 let mut mgr = ContextManager::new(1_000);
1164 for i in 0..6 {
1165 mgr.push_history(tool_result_msg(&format!("c{i}"), &"y".repeat(40)), 40);
1166 }
1167 mgr.mark_spooled("c2", "disk://c2");
1168
1169 mgr.set_observed_prompt_tokens(950); mgr.recompute_handle_residency();
1171
1172 assert_eq!(
1174 mgr.handles.residency_for_source("c2"),
1175 Some(&Residency::SpooledOut { r: "disk://c2".to_string() })
1176 );
1177 assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Collapsed));
1178 assert_eq!(mgr.handles.residency_for_source("c5"), Some(&Residency::Resident));
1179 }
1180}