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, calls: &[(String, String)]) {
511 let summary = calls
512 .iter()
513 .filter(|(name, _)| !is_meta_tool(name))
514 .map(|(name, args)| {
515 if args.is_empty() { name.clone() } else { format!("{name}({args})") }
516 })
517 .collect::<Vec<_>>()
518 .join(", ");
519 self.partitions.task_state.note_actions(summary);
520 }
521
522 pub fn pin_section(&mut self, id: &str) -> bool { self.sections.pin(id) }
525 pub fn unpin_section(&mut self, id: &str) -> bool { self.sections.unpin(id) }
526
527 pub fn set_available_skills(&mut self, skills: Vec<SkillMetadata>) {
530 self.capabilities.remove_kind(CapabilityKind::Skill);
531 for skill in &skills { self.capabilities.add_skill(skill.clone()); }
532 self.skills.set_available(skills);
533 }
534
535 pub fn set_stable_core_tools(&mut self, ids: impl IntoIterator<Item = CompactString>) {
537 self.stable_core_tools = ids.into_iter().collect();
538 }
539
540 pub fn activate_skill(&mut self, name: impl Into<CompactString>) -> bool {
544 self.active_skills.insert(name.into())
545 }
546
547 pub fn active_skill_tool_filter(&self) -> Option<std::collections::HashSet<CompactString>> {
553 if self.active_skills.is_empty() {
554 return None;
555 }
556 let mut union = std::collections::HashSet::new();
557 for name in &self.active_skills {
558 let declared = self.skills.allowed_tools(name);
559 if declared.is_empty() {
560 return None; }
562 union.extend(declared.iter().cloned());
563 }
564 Some(union)
565 }
566
567 pub fn skill_tool_schema(&self) -> Option<ToolSchema> {
568 self.skills.build_tool_schema()
569 }
570
571 pub fn set_memory_enabled(&mut self, enabled: bool) {
574 self.memory_enabled = enabled;
575 if enabled {
576 self.capabilities.add_marker(CapabilityKind::Memory, MEMORY_TOOL_NAME,
577 "Search long-term memory through the memory meta-tool.");
578 } else {
579 self.capabilities.remove(CapabilityKind::Memory, MEMORY_TOOL_NAME);
580 }
581 }
582
583 pub fn set_knowledge_enabled(&mut self, enabled: bool) {
584 self.knowledge_enabled = enabled;
585 if enabled {
586 self.capabilities.add_marker(CapabilityKind::Knowledge, KNOWLEDGE_TOOL_NAME,
587 "Search external knowledge through the knowledge meta-tool.");
588 } else {
589 self.capabilities.remove(CapabilityKind::Knowledge, KNOWLEDGE_TOOL_NAME);
590 }
591 }
592
593 pub fn set_plan_tool_enabled(&mut self, enabled: bool) {
594 self.plan_tool_enabled = enabled;
595 if enabled {
596 self.capabilities.add_marker(CapabilityKind::Tool, "update_plan",
597 "Update task plan and progress through the planning meta-tool.");
598 } else {
599 self.capabilities.remove(CapabilityKind::Tool, "update_plan");
600 }
601 }
602
603 pub fn capability_inventory(&self) -> String { self.capabilities.format_inventory() }
604
605 pub fn meta_tool_schemas(&self) -> Vec<ToolSchema> {
606 let mut tools = Vec::new();
607 if let Some(t) = self.skill_tool_schema() { tools.push(t); }
608 if let Some(t) = self.memory_tool_schema() { tools.push(t); }
609 if let Some(t) = self.knowledge_tool_schema() { tools.push(t); }
610 if let Some(t) = self.plan_tool_schema() { tools.push(t); }
611 tools.sort_by(|a, b| a.name.cmp(&b.name));
612 tools
613 }
614
615 pub fn plan_tool_schema(&self) -> Option<ToolSchema> {
616 if !self.plan_tool_enabled { return None; }
617 Some(ToolSchema {
618 name: CompactString::new("update_plan"),
619 description: "Update your task plan and progress. Call this after completing a step or when the plan changes.".to_string(),
620 parameters: serde_json::json!({
621 "type": "object",
622 "properties": {
623 "plan": { "type": "array", "items": { "type": "string" } },
624 "current_step": { "type": "integer" },
625 "progress": { "type": "string" },
626 "blocked_on": { "type": "array", "items": { "type": "string" } }
627 }
628 }),
629 })
630 }
631
632 pub fn memory_tool_schema(&self) -> Option<ToolSchema> {
633 if !self.memory_enabled { return None; }
634 Some(ToolSchema {
635 name: CompactString::new(MEMORY_TOOL_NAME),
636 description: "Search your long-term memory for relevant past experiences and knowledge.".to_string(),
637 parameters: serde_json::json!({
638 "type": "object",
639 "properties": {
640 "query": { "type": "string" },
641 "top_k": { "type": "integer" }
642 },
643 "required": ["query"]
644 }),
645 })
646 }
647
648 pub fn knowledge_tool_schema(&self) -> Option<ToolSchema> {
649 if !self.knowledge_enabled { return None; }
650 Some(ToolSchema {
651 name: CompactString::new(KNOWLEDGE_TOOL_NAME),
652 description: "Search the external knowledge base for facts, documentation, or reference data.".to_string(),
653 parameters: serde_json::json!({
654 "type": "object",
655 "properties": {
656 "query": { "type": "string" },
657 "top_k": { "type": "integer" }
658 },
659 "required": ["query"]
660 }),
661 })
662 }
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668 use crate::context::task_state::PlanStep;
669 use crate::types::message::Message;
670 use crate::types::skill::SkillMetadata;
671
672 #[test]
673 fn note_tool_actions_keys_on_name_and_args_so_legit_loops_dont_false_stop() {
674 let mut mgr = ContextManager::new(100_000);
677 mgr.init_task("process items".to_string(), vec![]);
678 mgr.note_tool_actions(&[("step".to_string(), "{\"n\":1}".to_string())]);
679 mgr.note_tool_actions(&[("step".to_string(), "{\"n\":2}".to_string())]);
680 mgr.note_tool_actions(&[("step".to_string(), "{\"n\":3}".to_string())]);
681 assert_eq!(
682 mgr.partitions.task_state.recent_actions,
683 ["step({\"n\":1})", "step({\"n\":2})", "step({\"n\":3})"]
684 );
685 let txt = mgr.render().state_turn.unwrap().content.as_text().unwrap().to_string();
686 assert!(!txt.contains("STOP:"), "same-tool/diff-args loop must not trip STOP: {txt}");
687
688 let mut mgr2 = ContextManager::new(100_000);
690 mgr2.init_task("g".to_string(), vec![]);
691 for _ in 0..3 {
692 mgr2.note_tool_actions(&[("document_read".to_string(), "{\"id\":\"x\"}".to_string())]);
693 }
694 let txt2 = mgr2.render().state_turn.unwrap().content.as_text().unwrap().to_string();
695 assert!(txt2.contains("STOP:"), "identical repeated call must trip STOP: {txt2}");
696
697 let mut mgr3 = ContextManager::new(100_000);
699 mgr3.init_task("g".to_string(), vec![]);
700 mgr3.note_tool_actions(&[("update_plan".to_string(), "{\"current_step\":1}".to_string())]);
701 assert!(mgr3.partitions.task_state.recent_actions.is_empty());
702 }
703
704 #[test]
705 fn manager_renew_uses_task_state_goal() {
706 let mut mgr = ContextManager::new(1_000);
707 mgr.init_task("test goal".to_string(), vec![]);
708 mgr.partitions.system.push(Message::system("rules"), 10);
709 for i in 0..10 { mgr.push_history(Message::user(format!("msg {i}")), 50); }
710 mgr.renew();
711 let artifact = mgr.last_handoff.as_ref().unwrap();
712 assert_eq!(artifact.goal, "test goal");
713 assert_eq!(mgr.sprint, 1);
714 }
715
716 #[test]
717 fn compress_only_touches_history() {
718 let mut mgr = ContextManager::new(1_000);
719 mgr.push_knowledge(Message::system("knowledge content"), 100);
720 for _ in 0..30 { mgr.push_history(Message::user("history msg"), 50); }
721 let knowledge_before = mgr.partitions.knowledge.token_count;
722 let history_before = mgr.partitions.history.token_count;
723 mgr.compress(PressureAction::AutoCompact);
724 assert_eq!(mgr.partitions.knowledge.token_count, knowledge_before);
725 assert!(mgr.partitions.history.token_count < history_before);
726 }
727
728 #[test]
729 fn init_task_sets_goal_and_criteria() {
730 let mut mgr = ContextManager::new(1_000);
731 mgr.init_task("analyse data".to_string(), vec!["criterion A".to_string()]);
732 assert_eq!(mgr.partitions.task_state.goal, "analyse data");
733 assert_eq!(mgr.partitions.task_state.criteria, ["criterion A"]);
734 }
735
736 #[test]
737 fn update_task_applies_plan() {
738 let mut mgr = ContextManager::new(1_000);
739 mgr.init_task("g".to_string(), vec![]);
740 mgr.update_task(TaskUpdate {
741 plan: Some(vec!["step 1".to_string(), "step 2".to_string()]),
742 current_step: Some(0),
743 ..Default::default()
744 });
745 assert_eq!(mgr.partitions.task_state.plan.len(), 2);
746 assert_eq!(mgr.partitions.task_state.current_step, Some(0));
747 }
748
749 #[test]
750 fn task_state_survives_autocompact() {
751 let mut mgr = ContextManager::new(1_000);
752 mgr.init_task("survive compression".to_string(), vec![]);
753 mgr.update_task(TaskUpdate {
754 plan: Some(vec!["fetch data".to_string(), "analyse".to_string()]),
755 ..Default::default()
756 });
757 for _ in 0..10 { mgr.push_history(Message::user("filler"), 50); }
758 mgr.compress(PressureAction::AutoCompact);
759 assert_eq!(mgr.partitions.task_state.goal, "survive compression");
760 assert_eq!(mgr.partitions.task_state.plan.len(), 2);
761 }
762
763 #[test]
764 fn render_includes_task_state_in_state_turn_not_system() {
765 let mut mgr = ContextManager::new(10_000);
766 mgr.init_task("find anomalies".to_string(), vec![]);
767 let rc = mgr.render();
768 assert!(!rc.system_text.contains("[TASK STATE]"), "task_state must not be in system_text");
769 let state = rc.state_turn.as_ref().expect("should have a state turn");
771 assert!(state.content.as_text().unwrap().contains("[TASK STATE] goal: find anomalies"));
772 }
773
774 #[test]
775 fn renewal_open_tasks_from_task_state() {
776 let mut mgr = ContextManager::new(1_000);
777 mgr.init_task("g".to_string(), vec![]);
778 mgr.partitions.task_state.plan = vec![
779 PlanStep { label: "done".to_string(), done: true },
780 PlanStep { label: "pending".to_string(), done: false },
781 ];
782 mgr.renew();
783 let artifact = mgr.last_handoff.as_ref().unwrap();
784 assert_eq!(artifact.open_tasks, vec!["pending"]);
785 }
786
787 #[test]
788 fn pinned_history_section_skips_compression() {
789 let mut mgr = ContextManager::new(1_000);
790 for _ in 0..30 { mgr.push_history(Message::user("filler message for pinning test"), 50); }
791 let tokens_before = mgr.partitions.history.token_count;
792 mgr.pin_section("history.rolling");
793 let (saved, _, _, _) = mgr.compress(PressureAction::AutoCompact);
794 assert_eq!(saved, 0);
795 assert_eq!(mgr.partitions.history.token_count, tokens_before);
796 }
797
798 #[test]
799 fn unpinned_history_section_allows_compression() {
800 let mut mgr = ContextManager::new(1_000);
801 for _ in 0..30 { mgr.push_history(Message::user("filler"), 50); }
802 mgr.pin_section("history.rolling");
803 mgr.unpin_section("history.rolling");
804 let (saved, _, _, _) = mgr.compress(PressureAction::AutoCompact);
805 assert!(saved > 0);
806 }
807
808 #[test]
809 fn force_compress_also_skips_when_history_pinned() {
810 let mut mgr = ContextManager::new(1_000);
811 for _ in 0..10 { mgr.push_history(Message::user("filler"), 50); }
812 mgr.pin_section("history.rolling");
813 let (saved, _, _, _) = mgr.force_compress();
814 assert_eq!(saved, 0);
815 }
816
817 #[test]
820 fn auto_compact_entry_logs_auto_compact_action() {
821 let mut mgr = ContextManager::new(1_000);
829 for i in 0..40 {
830 mgr.push_history(Message::user(format!("turn {i}: {}", "ctx ".repeat(40))), 200);
831 }
832 let (saved, summary, _, _) = mgr.force_compress();
833 assert!(saved > 0, "force_compress should compact a large history");
834 assert!(summary.is_some(), "auto-compact summarizes the archived turns");
835 let actions: Vec<&str> = mgr
836 .partitions
837 .task_state
838 .compression_log
839 .iter()
840 .map(|e| e.action.as_str())
841 .collect();
842 assert!(
843 actions.last() == Some(&"auto_compact"),
844 "auto-compact entry must log an auto_compact action; got {actions:?}"
845 );
846 }
847
848 #[test]
849 fn skill_tool_schema_empty_when_no_skills() {
850 let mgr = ContextManager::new(10_000);
851 assert!(mgr.skill_tool_schema().is_none());
852 }
853
854 #[test]
855 fn skill_tool_schema_present_when_registered() {
856 let mut mgr = ContextManager::new(10_000);
857 mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
858 assert!(mgr.skill_tool_schema().unwrap().description.contains("debug"));
859 }
860
861 #[test]
862 fn available_skills_are_reflected_in_capability_manifest() {
863 let mut mgr = ContextManager::new(1_000);
864 mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
865 let inventory = mgr.capability_inventory();
866 assert!(inventory.contains("debug"));
867 assert!(inventory.contains("Debug helper"));
868 }
869
870 #[test]
871 fn toggled_meta_tools_are_reflected_in_capability_manifest() {
872 let mut mgr = ContextManager::new(1_000);
873 mgr.set_memory_enabled(true);
874 assert!(mgr.capability_inventory().contains(MEMORY_TOOL_NAME));
875 mgr.set_memory_enabled(false);
876 assert!(!mgr.capability_inventory().contains(MEMORY_TOOL_NAME));
877 }
878
879 #[test]
880 fn meta_tool_schemas_are_sorted() {
881 let mut mgr = ContextManager::new(1_000);
882 mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
883 mgr.set_memory_enabled(true);
884 mgr.set_knowledge_enabled(true);
885 let names = mgr.meta_tool_schemas().into_iter().map(|s| s.name.to_string()).collect::<Vec<_>>();
886 assert_eq!(names, ["knowledge", "memory", "skill"]);
887 }
888
889 #[test]
890 fn section_registry_is_available_on_manager() {
891 let mgr = ContextManager::new(1_000);
892 assert!(mgr.sections.get("capabilities.inventory").is_some());
893 }
894
895 #[test]
896 fn b1_active_skill_state_and_tool_filter() {
897 let mut mgr = ContextManager::new(1_000);
898 let mut debug = SkillMetadata::new("debug", "Debug helper");
899 debug.allowed_tools = vec![CompactString::new("read"), CompactString::new("grep")];
900 let mut review = SkillMetadata::new("review", "Reviewer");
901 review.allowed_tools = vec![CompactString::new("git_diff")];
902 let plain = SkillMetadata::new("plain", "No tools declared"); mgr.set_available_skills(vec![debug, review, plain]);
904
905 assert!(mgr.active_skill_tool_filter().is_none());
907
908 assert!(mgr.activate_skill("debug"));
910 assert!(!mgr.activate_skill("debug")); let f = mgr.active_skill_tool_filter().unwrap();
914 assert_eq!(f.len(), 2);
915 assert!(f.contains(&CompactString::new("read")) && f.contains(&CompactString::new("grep")));
916
917 mgr.activate_skill("review");
919 let f = mgr.active_skill_tool_filter().unwrap();
920 assert_eq!(f.len(), 3);
921 assert!(f.contains(&CompactString::new("git_diff")));
922
923 mgr.activate_skill("plain");
925 assert!(mgr.active_skill_tool_filter().is_none());
926 }
927
928 #[test]
929 fn snapshot_hint_changes_when_capabilities_change() {
930 let mut mgr = ContextManager::new(1_000);
931 let before = mgr.snapshot_hint();
932 mgr.set_memory_enabled(true);
933 let after = mgr.snapshot_hint();
934 assert_ne!(before.capability_manifest_hash, after.capability_manifest_hash);
935 }
936
937 #[test]
938 fn update_collapse_mode_collapses_old_tool_results_under_pressure() {
939 let mut mgr = ContextManager::new(1_000);
940 for i in 0..10 {
941 let m = Message::tool(vec![ContentPart::ToolResult {
942 call_id: format!("c{i}").into(),
943 output: "x".repeat(40),
944 is_error: false,
945 }]);
946 mgr.push_history(m, 40);
947 }
948 mgr.set_observed_prompt_tokens(950); assert!(mgr.rho() >= mgr.config.collapse_threshold);
951
952 mgr.recompute_handle_residency();
953 assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Collapsed));
955 assert_eq!(mgr.handles.residency_for_source("c9"), Some(&Residency::Resident));
956
957 mgr.set_observed_prompt_tokens(100); mgr.recompute_handle_residency();
961 assert_eq!(
962 mgr.handles.residency_for_source("c0"),
963 Some(&Residency::Collapsed),
964 "collapse is sticky until a compaction boundary"
965 );
966
967 mgr.reset_collapse_generation();
969 assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Resident));
970 }
971
972 #[test]
973 fn frozen_prefix_len_anchors_at_compaction_and_holds_across_appends() {
974 let mut mgr = ContextManager::new(1_000);
975 for i in 0..30 {
977 mgr.push_history(Message::user(format!("turn {i}: {}", "ctx ".repeat(30))), 150);
978 }
979 assert!(mgr.render().frozen_prefix_len.is_none(), "no frozen region before any compaction");
980
981 let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
982 assert!(saved > 0 && !archived.is_empty(), "expected archival");
983
984 assert!(mgr.render().frozen_prefix_len.is_none(), "deep == tail right after compaction");
986
987 mgr.push_history(Message::user("new 1"), 5);
989 let f1 = mgr.render().frozen_prefix_len.expect("frozen region exists once the tail grows");
990 mgr.push_history(Message::assistant("reply 1"), 5);
991 mgr.push_history(Message::user("new 2"), 5);
992 let rc = mgr.render();
993 let f2 = rc.frozen_prefix_len.expect("frozen region holds");
994 assert_eq!(f1, f2, "the deep boundary is fixed between compactions; only the tail grows");
995 assert!(f2 < rc.turns.len(), "deep boundary is distinct from the rolling tail");
996 }
997
998 #[test]
999 fn frozen_boundary_holds_through_a_prefix_safe_compaction() {
1000 let mut mgr = ContextManager::new(10_000);
1003 for i in 0..5 {
1004 mgr.push_history(Message::user(format!("m{i}")), 5);
1005 }
1006 mgr.frozen_history_len = 3; let (_, _, _, cache_at) = mgr.compress(PressureAction::None);
1011 assert!(cache_at.is_none(), "no-op compaction is prefix-safe");
1012 assert_eq!(mgr.frozen_history_len, 3, "prefix-safe compaction preserves the deep-cache anchor");
1013 }
1014
1015 #[test]
1016 fn collapse_generation_resets_on_autocompact() {
1017 let mut mgr = ContextManager::new(1_000);
1018 for i in 0..20 {
1021 mgr.push_history(tool_result_msg(&format!("c{i}"), &"x".repeat(120)), 60);
1022 }
1023 mgr.set_observed_prompt_tokens(980); mgr.recompute_handle_residency();
1025 assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Collapsed));
1026
1027 let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
1028 assert!(saved > 0 && !archived.is_empty(), "expected archival");
1029
1030 for h in mgr.handles.all() {
1033 if matches!(h.kind, HandleKind::ToolResult) {
1034 assert_eq!(h.residency, Residency::Resident, "generation reset un-collapses survivors");
1035 }
1036 }
1037 }
1038
1039 #[test]
1040 fn mark_spooled_sets_residency_and_survives_residency_recompute() {
1041 let mut mgr = ContextManager::new(1_000);
1042 mgr.push_history(
1043 Message::tool(vec![ContentPart::ToolResult {
1044 call_id: "big".into(),
1045 output: "preview only".to_string(),
1046 is_error: false,
1047 }]),
1048 10,
1049 );
1050 mgr.mark_spooled("big", "disk://big");
1051 assert_eq!(
1052 mgr.handles.residency_for_source("big"),
1053 Some(&Residency::SpooledOut { r: "disk://big".to_string() })
1054 );
1055
1056 mgr.set_observed_prompt_tokens(990);
1059 mgr.recompute_handle_residency();
1060 assert_eq!(
1061 mgr.handles.residency_for_source("big"),
1062 Some(&Residency::SpooledOut { r: "disk://big".to_string() })
1063 );
1064 }
1065
1066 #[test]
1067 fn push_history_indexes_tool_results_as_resident_handles() {
1068 let mut mgr = ContextManager::new(10_000);
1069 let msg = Message::tool(vec![ContentPart::ToolResult {
1070 call_id: "call_1".into(),
1071 output: "the tool output".to_string(),
1072 is_error: false,
1073 }]);
1074 mgr.push_history(msg, 20);
1075 assert_eq!(mgr.handles.all().len(), 1);
1077 assert_eq!(
1078 mgr.handles.residency_for_source("call_1"),
1079 Some(&Residency::Resident)
1080 );
1081 mgr.push_history(Message::user("hello"), 5);
1083 assert_eq!(mgr.handles.all().len(), 1);
1084 }
1085
1086 fn tool_result_msg(call_id: &str, output: &str) -> Message {
1089 Message::tool(vec![ContentPart::ToolResult {
1090 call_id: call_id.into(),
1091 output: output.to_string(),
1092 is_error: false,
1093 }])
1094 }
1095
1096 #[test]
1097 fn effective_rho_discounts_paged_out_handles() {
1098 let mut mgr = ContextManager::new(1_000);
1099 let big = "data ".repeat(200);
1101 let tok = mgr.engine.count(&big);
1102 mgr.push_history(tool_result_msg("c0", &big), tok);
1103 mgr.push_history(Message::user("u"), 50);
1104
1105 let raw = mgr.rho();
1106 assert_eq!(mgr.handles.non_resident_tokens(), 0);
1108 assert!((mgr.effective_rho() - raw).abs() < f64::EPSILON);
1109
1110 mgr.mark_spooled("c0", "disk://c0");
1112 let paged = mgr.handles.non_resident_tokens();
1113 assert!(paged > 0, "handle is now non-resident with a real token weight");
1114
1115 assert!((mgr.rho() - raw).abs() < f64::EPSILON, "raw rho unchanged by paging");
1117 let total = mgr.partitions.total_tokens(&mgr.engine);
1119 let expected = total.saturating_sub(paged) as f64 / 1_000.0;
1120 assert!((mgr.effective_rho() - expected).abs() < f64::EPSILON);
1121 assert!(mgr.effective_rho() < raw, "effective pressure relieved by paging");
1122
1123 mgr.set_observed_prompt_tokens(900);
1126 assert!((mgr.effective_rho() - mgr.rho()).abs() < f64::EPSILON);
1127 }
1128
1129 #[test]
1130 fn prune_orphaned_handles_drops_handles_whose_message_left_history() {
1131 let mut mgr = ContextManager::new(10_000);
1132 mgr.push_history(tool_result_msg("c0", "out 0"), 20);
1133 mgr.push_history(tool_result_msg("c1", "out 1"), 20);
1134 assert_eq!(mgr.handles.all().len(), 2);
1135
1136 mgr.partitions.history.messages.remove(0);
1138 mgr.prune_orphaned_handles();
1139
1140 assert_eq!(mgr.handles.all().len(), 1);
1142 assert!(mgr.handles.residency_for_source("c0").is_none());
1143 assert_eq!(
1144 mgr.handles.residency_for_source("c1"),
1145 Some(&Residency::Resident)
1146 );
1147 }
1148
1149 #[test]
1150 fn autocompact_prunes_handles_for_archived_tool_results() {
1151 let mut mgr = ContextManager::new(1_000);
1152 for i in 0..30 {
1154 mgr.push_history(tool_result_msg(&format!("c{i}"), &"x".repeat(200)), 80);
1155 }
1156 assert_eq!(mgr.handles.all().len(), 30);
1157
1158 let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
1159 assert!(saved > 0 && !archived.is_empty(), "expected archival");
1160
1161 let live_tool_results = mgr
1164 .partitions
1165 .history
1166 .messages
1167 .iter()
1168 .filter(|m| matches!(&m.content, Content::Parts(p)
1169 if p.iter().any(|x| matches!(x, ContentPart::ToolResult { .. }))))
1170 .count();
1171 assert_eq!(mgr.handles.all().len(), live_tool_results);
1172 assert!(mgr.handles.all().len() < 30, "table must shrink with archival");
1173 }
1174
1175 #[test]
1176 fn renew_prunes_handles_for_dropped_history() {
1177 let mut mgr = ContextManager::new(1_000);
1178 mgr.init_task("g".to_string(), vec![]);
1179 for i in 0..20 {
1180 mgr.push_history(tool_result_msg(&format!("c{i}"), "data"), 60);
1181 }
1182 mgr.renew();
1183 for h in mgr.handles.all() {
1185 if let Some(src) = h.source.as_ref() {
1186 assert!(
1187 mgr.handles.residency_for_source(src).is_some(),
1188 "no dangling handle survives renewal"
1189 );
1190 }
1191 }
1192 assert!(mgr.handles.all().len() <= 20);
1193 }
1194
1195 #[test]
1196 fn recompute_residency_index_semantics_with_spooled_in_the_middle() {
1197 let mut mgr = ContextManager::new(1_000);
1200 for i in 0..6 {
1201 mgr.push_history(tool_result_msg(&format!("c{i}"), &"y".repeat(40)), 40);
1202 }
1203 mgr.mark_spooled("c2", "disk://c2");
1204
1205 mgr.set_observed_prompt_tokens(950); mgr.recompute_handle_residency();
1207
1208 assert_eq!(
1210 mgr.handles.residency_for_source("c2"),
1211 Some(&Residency::SpooledOut { r: "disk://c2".to_string() })
1212 );
1213 assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Collapsed));
1214 assert_eq!(mgr.handles.residency_for_source("c5"), Some(&Residency::Resident));
1215 }
1216}