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";
20pub const READ_RESULT_TOOL_NAME: &str = "read_result";
22
23const META_TOOL_NAMES: &[&str] = &[
26 "update_plan",
27 "skill",
28 MEMORY_TOOL_NAME,
29 KNOWLEDGE_TOOL_NAME,
30 READ_RESULT_TOOL_NAME,
31 "submit_workflow_nodes",
32 "start_workflow",
33];
34
35pub(crate) fn is_meta_tool(name: &str) -> bool {
38 META_TOOL_NAMES.contains(&name)
39}
40
41#[doc(hidden)]
46pub struct ContextManager {
47 pub partitions: ContextPartitions,
48 pub max_tokens: u32,
49 pub config: ContextConfig,
50 pub engine: ContextTokenEngine,
51 pub sprint: u32,
52 pub last_handoff: Option<HandoffArtifact>,
53 pub skills: SkillCatalog,
54 pub active_skills: std::collections::BTreeMap<CompactString, Option<u32>>,
62 pub stable_core_tools: std::collections::HashSet<CompactString>,
66 pub capabilities: CapabilityManifest,
67 pub sections: ContextSectionRegistry,
68 pub memory_enabled: bool,
69 pub knowledge_enabled: bool,
70 pub plan_tool_enabled: bool,
71 last_observed_prompt_tokens: Option<u32>,
72 compression: CompressionPipeline,
73 pressure: PressureMonitor,
74 renewal: RenewalPolicy,
75
76 pub last_activity_ms: u64,
81
82 pub last_compact_ms: Option<u64>,
85
86 pub handles: HandleTable,
92 next_handle_id: HandleId,
94
95 frozen_history_len: usize,
101
102 pending_knowledge_sweeps: Vec<crate::context::partitions::KnowledgeSweep>,
105
106 knowledge_budget_warned: bool,
109}
110
111impl ContextManager {
112 pub fn new(max_tokens: u32) -> Self {
113 Self::with_config(max_tokens, ContextConfig::default(), ContextTokenEngine::char_approx())
114 }
115
116 pub fn with_config(max_tokens: u32, config: ContextConfig, engine: ContextTokenEngine) -> Self {
117 let compression = CompressionPipeline::new(&config);
118 let pressure = PressureMonitor::new(max_tokens, config.clone());
119 let renewal = RenewalPolicy::from_config(&config);
120 let partitions = ContextPartitions::new(&config);
121 Self {
122 partitions, max_tokens, config, engine,
123 sprint: 0, last_handoff: None,
124 skills: SkillCatalog::new(),
125 active_skills: std::collections::BTreeMap::new(),
126 stable_core_tools: std::collections::HashSet::new(),
127 capabilities: CapabilityManifest::new(),
128 sections: ContextSectionRegistry::default_agent_sections(),
129 memory_enabled: false, knowledge_enabled: false, plan_tool_enabled: false,
130 last_observed_prompt_tokens: None,
131 compression, pressure, renewal,
132 last_activity_ms: 0,
133 last_compact_ms: None,
134 handles: HandleTable::new(),
135 next_handle_id: 0,
136 frozen_history_len: 0,
137 pending_knowledge_sweeps: Vec::new(),
138 knowledge_budget_warned: false,
139 }
140 }
141
142 pub fn record_activity(&mut self, now_ms: u64) {
146 self.last_activity_ms = now_ms;
147 }
148
149 pub fn should_time_decay_compact(&self, now_ms: u64) -> bool {
152 let idle_ms = if let Some(last_compact) = self.last_compact_ms {
153 now_ms.saturating_sub(last_compact)
155 } else {
156 now_ms.saturating_sub(self.last_activity_ms)
158 };
159
160 let idle_minutes = idle_ms / 60_000;
161 idle_minutes >= self.config.micro_compact_idle_minutes as u64
162 }
163
164 pub fn recompute_handle_residency(&mut self) {
179 if self.rho() < self.config.collapse_threshold {
181 return;
182 }
183 let keep = self.config.preserve_recent_msgs;
184 let total = self
187 .handles
188 .all()
189 .iter()
190 .filter(|h| matches!(h.kind, HandleKind::ToolResult))
191 .count();
192 let cutoff = total.saturating_sub(keep);
193 for (i, handle) in self.handles.tool_result_handles_mut().enumerate() {
194 if i < cutoff && matches!(handle.residency, Residency::Resident) {
197 handle.residency = Residency::Collapsed;
198 }
199 }
200 }
201
202 pub fn reset_collapse_generation(&mut self) {
208 for handle in self.handles.all_mut() {
209 if matches!(handle.residency, Residency::Collapsed) {
210 handle.residency = Residency::Resident;
211 }
212 }
213 }
214
215 pub fn prune_orphaned_handles(&mut self) {
222 let live: std::collections::HashSet<CompactString> = self
223 .partitions
224 .history
225 .messages
226 .iter()
227 .flat_map(|m| match &m.content {
228 Content::Parts(parts) => parts
229 .iter()
230 .filter_map(|p| match p {
231 ContentPart::ToolResult { call_id, .. } => Some(call_id.clone()),
232 _ => None,
233 })
234 .collect::<Vec<_>>(),
235 _ => Vec::new(),
236 })
237 .collect();
238 self.handles
239 .retain(|h| h.source.as_ref().is_none_or(|s| live.contains(s)));
240 }
241
242 pub fn mark_spooled(&mut self, call_id: &str, spool_ref: impl Into<String>) {
246 let spool_ref = spool_ref.into();
247 if let Some(handle) = self
248 .handles
249 .all_mut()
250 .iter_mut()
251 .find(|h| h.source.as_deref() == Some(call_id))
252 {
253 handle.residency = Residency::SpooledOut { r: spool_ref };
254 }
255 }
256
257 pub fn rho(&self) -> f64 {
264 self.pressure
265 .pressure(&self.partitions, &self.engine, self.last_observed_prompt_tokens)
266 }
267
268 pub fn effective_rho(&self) -> f64 {
279 if self.max_tokens == 0 || self.last_observed_prompt_tokens.is_some() {
280 return self.rho();
281 }
282 let total = self.partitions.total_tokens(&self.engine);
283 let effective = total.saturating_sub(self.handles.non_resident_tokens());
284 effective as f64 / self.max_tokens as f64
285 }
286
287 pub fn set_observed_prompt_tokens(&mut self, tokens: u32) {
288 self.last_observed_prompt_tokens = Some(tokens);
289 }
290
291 pub fn should_compress(&self) -> PressureAction {
292 self.pressure.recommend(self.rho())
301 }
302
303 pub fn compress(&mut self, action: PressureAction) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
304 self.compress_with_time(action, None)
305 }
306
307 pub fn compress_with_time(
308 &mut self,
309 action: PressureAction,
310 now_ms: Option<u64>,
311 ) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
312 if self.sections.is_partition_pinned(ContextSectionPartition::History) {
313 return (0, None, vec![], None);
314 }
315
316 let result = {
317 let target = self.config.target_tokens(self.max_tokens);
318 self.compression.compress(&mut self.partitions, action, self.max_tokens, target, &self.engine)
319 };
320
321 if let Some(ts) = now_ms {
323 self.last_compact_ms = Some(ts);
324 }
325
326 if !result.2.is_empty() {
328 self.prune_orphaned_handles();
329 self.reset_collapse_generation();
332 self.sweep_knowledge_at_boundary();
335 }
336 if result.3.is_some() {
343 self.frozen_history_len = self.partitions.history.messages.len();
344 }
345
346 result
347 }
348
349 pub fn force_compress(&mut self) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
350 if self.sections.is_partition_pinned(ContextSectionPartition::History) {
351 return (0, None, vec![], None);
352 }
353 let result = self.compression.compress(&mut self.partitions, PressureAction::AutoCompact, self.max_tokens, 0, &self.engine);
354 if !result.2.is_empty() {
355 self.prune_orphaned_handles();
356 self.reset_collapse_generation();
359 self.sweep_knowledge_at_boundary();
362 }
363 if result.3.is_some() {
370 self.frozen_history_len = self.partitions.history.messages.len();
371 }
372 result
373 }
374
375 pub fn compress_with_target(
381 &mut self,
382 action: PressureAction,
383 target_tokens: u32,
384 now_ms: Option<u64>,
385 ) -> (u32, Option<String>, Vec<Message>, Option<usize>) {
386 if self.sections.is_partition_pinned(ContextSectionPartition::History) {
387 return (0, None, vec![], None);
388 }
389 let result =
390 self.compression
391 .compress(&mut self.partitions, action, self.max_tokens, target_tokens, &self.engine);
392 if let Some(ts) = now_ms {
393 self.last_compact_ms = Some(ts);
394 }
395 if !result.2.is_empty() {
396 self.prune_orphaned_handles();
397 self.reset_collapse_generation();
400 self.sweep_knowledge_at_boundary();
403 }
404 if result.3.is_some() {
411 self.frozen_history_len = self.partitions.history.messages.len();
412 }
413 result
414 }
415
416 pub fn plan_compaction_params(&self) -> (u32, usize) {
420 (
421 self.config.target_tokens(self.max_tokens),
422 self.config.preserve_recent_turns,
423 )
424 }
425
426 pub fn should_renew(&self) -> bool {
429 self.renewal.should_renew(&self.pressure, &self.partitions, &self.engine)
430 }
431
432 pub fn renew(&mut self) {
433 let goal = self.partitions.task_state.goal.clone();
434 let (renewed, artifact) = self.renewal.renew(&self.partitions, &goal, self.sprint, self.max_tokens);
435 self.partitions = renewed;
436 self.last_handoff = Some(artifact);
437 self.sprint += 1;
438 self.prune_orphaned_handles();
441 self.reset_collapse_generation();
442 self.sweep_knowledge_at_boundary();
444 self.frozen_history_len = self.partitions.history.messages.len();
446 }
447
448 pub fn render(&self) -> RenderedContext {
451 super::renderer::render_projected(
452 &self.partitions,
453 self.max_tokens,
454 &self.engine,
455 self.config.preserve_recent_msgs,
456 &self.handles,
457 self.frozen_history_len,
458 self.config.collapse_assistant_narration,
459 )
460 }
461
462 pub fn snapshot_hint(&self) -> ContextSnapshotHint {
463 ContextSnapshotHint::from_parts(&self.sections, &self.capabilities)
464 }
465
466 pub fn take_snapshot(&self, turn: u32) -> ContextSnapshot {
467 ContextSnapshot {
468 turn,
469 system_messages: self.partitions.system.messages.clone(),
470 knowledge_messages: self.partitions.knowledge.messages().cloned().collect(),
471 history_messages: self.partitions.history.messages.clone(),
472 task_state: self.partitions.task_state.clone(),
473 }
474 }
475
476 pub fn push_history(&mut self, msg: Message, tokens: u32) {
479 if let Content::Parts(parts) = &msg.content {
483 for part in parts {
484 if let ContentPart::ToolResult { call_id, output, .. } = part {
485 let id = self.alloc_handle_id();
486 let tok = self.engine.count(output).max(1);
487 self.handles.insert(Handle::resident_for(
488 id,
489 HandleKind::ToolResult,
490 tok,
491 call_id.clone(),
492 ));
493 }
494 }
495 }
496 self.partitions.history.push(msg, tokens);
497 }
498
499 fn alloc_handle_id(&mut self) -> HandleId {
500 let id = self.next_handle_id;
501 self.next_handle_id = self.next_handle_id.wrapping_add(1);
502 id
503 }
504
505 pub fn push_knowledge(&mut self, msg: Message, tokens: u32) {
507 self.partitions.knowledge.push(msg, tokens);
508 }
509
510 pub fn push_knowledge_entry(
514 &mut self,
515 key: Option<CompactString>,
516 msg: Message,
517 tokens: u32,
518 pinned: bool,
519 ) {
520 self.partitions.knowledge.push_entry(key, msg, tokens, pinned);
521 }
522
523 pub fn remove_knowledge(&mut self, key: &str) -> bool {
526 self.partitions.knowledge.remove(key)
527 }
528
529 fn sweep_knowledge_at_boundary(&mut self) {
533 let sweep = self.partitions.knowledge.sweep_at_boundary();
534 if sweep.changed {
535 self.pending_knowledge_sweeps.push(sweep);
536 }
537 self.knowledge_budget_warned = false;
539 }
540
541 pub fn enforce_knowledge_budget(&mut self) -> Option<(u32, u32)> {
550 let ratio = self.config.knowledge_budget_ratio;
551 if ratio <= 0.0 {
552 return None;
553 }
554 let budget = (self.max_tokens as f64 * ratio) as u32;
555 let used = self.partitions.knowledge.token_count;
556 if used <= budget {
557 return None;
558 }
559 let entries = &mut self.partitions.knowledge.entries;
560 let marked: u32 = entries.iter().filter(|e| e.evict_at_boundary).map(|e| e.tokens).sum();
561 let mut projected = used.saturating_sub(marked);
562 for entry in entries.iter_mut() {
563 if projected <= budget {
564 break;
565 }
566 if entry.evict_at_boundary || entry.pinned {
567 continue;
568 }
569 if entry.key.as_deref().is_some_and(|k| k.starts_with("skill:")) {
570 continue;
571 }
572 entry.evict_at_boundary = true;
573 projected = projected.saturating_sub(entry.tokens);
574 }
575 if self.knowledge_budget_warned {
576 return None;
577 }
578 self.knowledge_budget_warned = true;
579 Some((used, budget))
580 }
581
582 pub fn take_knowledge_sweeps(&mut self) -> Vec<crate::context::partitions::KnowledgeSweep> {
584 std::mem::take(&mut self.pending_knowledge_sweeps)
585 }
586
587 pub fn push_signal(&mut self, text: String) {
590 self.partitions.signals.push(text);
591 }
592
593 pub fn record_directive(&mut self, text: impl Into<String>) {
597 self.partitions.task_state.record_directive(text);
598 }
599
600 pub fn init_task(&mut self, goal: String, criteria: Vec<String>) {
603 self.partitions.task_state = TaskState { goal, criteria, ..Default::default() };
604 }
605
606 pub fn update_task(&mut self, update: TaskUpdate) {
607 self.partitions.task_state.apply(update);
608 }
609
610 pub fn note_tool_actions(&mut self, calls: &[(String, String)]) {
617 let summary = calls
618 .iter()
619 .filter(|(name, _)| !is_meta_tool(name))
620 .map(|(name, args)| {
621 if args.is_empty() { name.clone() } else { format!("{name}({args})") }
622 })
623 .collect::<Vec<_>>()
624 .join(", ");
625 self.partitions.task_state.note_actions(summary);
626 }
627
628 pub fn pin_section(&mut self, id: &str) -> bool { self.sections.pin(id) }
631 pub fn unpin_section(&mut self, id: &str) -> bool { self.sections.unpin(id) }
632
633 pub fn set_available_skills(&mut self, skills: Vec<SkillMetadata>) {
636 self.capabilities.remove_kind(CapabilityKind::Skill);
637 for skill in &skills { self.capabilities.add_skill(skill.clone()); }
638 self.skills.set_available(skills);
639 }
640
641 pub fn set_stable_core_tools(&mut self, ids: impl IntoIterator<Item = CompactString>) {
643 self.stable_core_tools = ids.into_iter().collect();
644 }
645
646 pub fn activate_skill(&mut self, name: impl Into<CompactString>) -> bool {
651 self.activate_skill_leased(name, None)
652 }
653
654 pub fn activate_skill_leased(
657 &mut self,
658 name: impl Into<CompactString>,
659 expires_at_turn: Option<u32>,
660 ) -> bool {
661 self.active_skills.insert(name.into(), expires_at_turn).is_none()
662 }
663
664 pub fn deactivate_skill(&mut self, name: &str) -> bool {
668 if self.active_skills.remove(name).is_none() {
669 return false;
670 }
671 self.partitions.knowledge.remove(&format!("skill:{name}"));
672 true
673 }
674
675 pub fn sweep_expired_skill_leases(&mut self, current_turn: u32) {
678 let expired: Vec<CompactString> = self
679 .active_skills
680 .iter()
681 .filter(|(_, lease)| lease.is_some_and(|t| current_turn >= t))
682 .map(|(name, _)| name.clone())
683 .collect();
684 for name in expired {
685 self.deactivate_skill(&name);
686 }
687 }
688
689 pub fn active_skill_tool_filter(&self) -> Option<std::collections::HashSet<CompactString>> {
695 if self.active_skills.is_empty() {
696 return None;
697 }
698 let mut union = std::collections::HashSet::new();
699 for name in self.active_skills.keys() {
700 let declared = self.skills.allowed_tools(name);
701 if declared.is_empty() {
702 return None; }
704 union.extend(declared.iter().cloned());
705 }
706 Some(union)
707 }
708
709 pub fn skill_tool_schema(&self) -> Option<ToolSchema> {
710 self.skills.build_tool_schema()
711 }
712
713 pub fn set_memory_enabled(&mut self, enabled: bool) {
716 self.memory_enabled = enabled;
717 if enabled {
718 self.capabilities.add_marker(CapabilityKind::Memory, MEMORY_TOOL_NAME,
719 "Search long-term memory through the memory meta-tool.");
720 } else {
721 self.capabilities.remove(CapabilityKind::Memory, MEMORY_TOOL_NAME);
722 }
723 }
724
725 pub fn set_knowledge_enabled(&mut self, enabled: bool) {
726 self.knowledge_enabled = enabled;
727 if enabled {
728 self.capabilities.add_marker(CapabilityKind::Knowledge, KNOWLEDGE_TOOL_NAME,
729 "Search external knowledge through the knowledge meta-tool.");
730 } else {
731 self.capabilities.remove(CapabilityKind::Knowledge, KNOWLEDGE_TOOL_NAME);
732 }
733 }
734
735 pub fn set_plan_tool_enabled(&mut self, enabled: bool) {
736 self.plan_tool_enabled = enabled;
737 if enabled {
738 self.capabilities.add_marker(CapabilityKind::Tool, "update_plan",
739 "Update task plan and progress through the planning meta-tool.");
740 } else {
741 self.capabilities.remove(CapabilityKind::Tool, "update_plan");
742 }
743 }
744
745 pub fn capability_inventory(&self) -> String { self.capabilities.format_inventory() }
746
747 pub fn meta_tool_schemas(&self) -> Vec<ToolSchema> {
748 let mut tools = Vec::new();
749 if let Some(t) = self.skill_tool_schema() { tools.push(t); }
750 if let Some(t) = self.memory_tool_schema() { tools.push(t); }
751 if let Some(t) = self.knowledge_tool_schema() { tools.push(t); }
752 if let Some(t) = self.plan_tool_schema() { tools.push(t); }
753 if let Some(t) = self.read_result_tool_schema() { tools.push(t); }
754 tools.sort_by(|a, b| a.name.cmp(&b.name));
755 tools
756 }
757
758 pub fn read_result_tool_schema(&self) -> Option<ToolSchema> {
764 let any_evicted = self
765 .handles
766 .all()
767 .iter()
768 .any(|h| !h.residency.occupies_context());
769 if !any_evicted { return None; }
770 Some(ToolSchema {
771 name: CompactString::new(READ_RESULT_TOOL_NAME),
772 description: "Re-read a tool result that was evicted from context (you see a \
773 placeholder like '[…tool result spooled…]' or a collapsed entry). \
774 Pass the tool call's call_id; use offset/max_bytes to page through \
775 large content."
776 .to_string(),
777 parameters: serde_json::json!({
778 "type": "object",
779 "properties": {
780 "call_id": { "type": "string" },
781 "offset": { "type": "integer", "description": "Byte offset to start from (default 0)." },
782 "max_bytes": { "type": "integer", "description": "Max bytes to return (default 4000)." }
783 },
784 "required": ["call_id"]
785 }),
786 })
787 }
788
789 pub fn plan_tool_schema(&self) -> Option<ToolSchema> {
790 if !self.plan_tool_enabled { return None; }
791 Some(ToolSchema {
792 name: CompactString::new("update_plan"),
793 description: "Update your task plan and progress. Call this after completing a step or when the plan changes.".to_string(),
794 parameters: serde_json::json!({
795 "type": "object",
796 "properties": {
797 "plan": { "type": "array", "items": { "type": "string" } },
798 "current_step": { "type": "integer" },
799 "progress": { "type": "string" },
800 "blocked_on": { "type": "array", "items": { "type": "string" } }
801 }
802 }),
803 })
804 }
805
806 pub fn memory_tool_schema(&self) -> Option<ToolSchema> {
807 if !self.memory_enabled { return None; }
808 Some(ToolSchema {
809 name: CompactString::new(MEMORY_TOOL_NAME),
810 description: "Search your long-term memory for relevant past experiences and knowledge.".to_string(),
811 parameters: serde_json::json!({
812 "type": "object",
813 "properties": {
814 "query": { "type": "string" },
815 "top_k": { "type": "integer" }
816 },
817 "required": ["query"]
818 }),
819 })
820 }
821
822 pub fn knowledge_tool_schema(&self) -> Option<ToolSchema> {
823 if !self.knowledge_enabled { return None; }
824 Some(ToolSchema {
825 name: CompactString::new(KNOWLEDGE_TOOL_NAME),
826 description: "Search the external knowledge base for facts, documentation, or reference data.".to_string(),
827 parameters: serde_json::json!({
828 "type": "object",
829 "properties": {
830 "query": { "type": "string" },
831 "top_k": { "type": "integer" }
832 },
833 "required": ["query"]
834 }),
835 })
836 }
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842 use crate::context::task_state::PlanStep;
843 use crate::types::message::Message;
844 use crate::types::skill::SkillMetadata;
845
846 #[test]
847 fn note_tool_actions_keys_on_name_and_args_so_legit_loops_dont_false_stop() {
848 let mut mgr = ContextManager::new(100_000);
851 mgr.init_task("process items".to_string(), vec![]);
852 mgr.note_tool_actions(&[("step".to_string(), "{\"n\":1}".to_string())]);
853 mgr.note_tool_actions(&[("step".to_string(), "{\"n\":2}".to_string())]);
854 mgr.note_tool_actions(&[("step".to_string(), "{\"n\":3}".to_string())]);
855 assert_eq!(
856 mgr.partitions.task_state.recent_actions,
857 ["step({\"n\":1})", "step({\"n\":2})", "step({\"n\":3})"]
858 );
859 let txt = mgr.render().state_turn.unwrap().content.as_text().unwrap().to_string();
860 assert!(!txt.contains("STOP:"), "same-tool/diff-args loop must not trip STOP: {txt}");
861
862 let mut mgr2 = ContextManager::new(100_000);
864 mgr2.init_task("g".to_string(), vec![]);
865 for _ in 0..3 {
866 mgr2.note_tool_actions(&[("document_read".to_string(), "{\"id\":\"x\"}".to_string())]);
867 }
868 let txt2 = mgr2.render().state_turn.unwrap().content.as_text().unwrap().to_string();
869 assert!(txt2.contains("STOP:"), "identical repeated call must trip STOP: {txt2}");
870
871 let mut mgr3 = ContextManager::new(100_000);
873 mgr3.init_task("g".to_string(), vec![]);
874 mgr3.note_tool_actions(&[("update_plan".to_string(), "{\"current_step\":1}".to_string())]);
875 assert!(mgr3.partitions.task_state.recent_actions.is_empty());
876 }
877
878 #[test]
879 fn manager_renew_uses_task_state_goal() {
880 let mut mgr = ContextManager::new(1_000);
881 mgr.init_task("test goal".to_string(), vec![]);
882 mgr.partitions.system.push(Message::system("rules"), 10);
883 for i in 0..10 { mgr.push_history(Message::user(format!("msg {i}")), 50); }
884 mgr.renew();
885 let artifact = mgr.last_handoff.as_ref().unwrap();
886 assert_eq!(artifact.goal, "test goal");
887 assert_eq!(mgr.sprint, 1);
888 }
889
890 #[test]
891 fn compress_only_touches_history() {
892 let mut mgr = ContextManager::new(1_000);
893 mgr.push_knowledge(Message::system("knowledge content"), 100);
894 for _ in 0..30 { mgr.push_history(Message::user("history msg"), 50); }
895 let knowledge_before = mgr.partitions.knowledge.token_count;
896 let history_before = mgr.partitions.history.token_count;
897 mgr.compress(PressureAction::AutoCompact);
898 assert_eq!(mgr.partitions.knowledge.token_count, knowledge_before);
899 assert!(mgr.partitions.history.token_count < history_before);
900 }
901
902 #[test]
903 fn init_task_sets_goal_and_criteria() {
904 let mut mgr = ContextManager::new(1_000);
905 mgr.init_task("analyse data".to_string(), vec!["criterion A".to_string()]);
906 assert_eq!(mgr.partitions.task_state.goal, "analyse data");
907 assert_eq!(mgr.partitions.task_state.criteria, ["criterion A"]);
908 }
909
910 #[test]
911 fn update_task_applies_plan() {
912 let mut mgr = ContextManager::new(1_000);
913 mgr.init_task("g".to_string(), vec![]);
914 mgr.update_task(TaskUpdate {
915 plan: Some(vec!["step 1".to_string(), "step 2".to_string()]),
916 current_step: Some(0),
917 ..Default::default()
918 });
919 assert_eq!(mgr.partitions.task_state.plan.len(), 2);
920 assert_eq!(mgr.partitions.task_state.current_step, Some(0));
921 }
922
923 #[test]
924 fn task_state_survives_autocompact() {
925 let mut mgr = ContextManager::new(1_000);
926 mgr.init_task("survive compression".to_string(), vec![]);
927 mgr.update_task(TaskUpdate {
928 plan: Some(vec!["fetch data".to_string(), "analyse".to_string()]),
929 ..Default::default()
930 });
931 for _ in 0..10 { mgr.push_history(Message::user("filler"), 50); }
932 mgr.compress(PressureAction::AutoCompact);
933 assert_eq!(mgr.partitions.task_state.goal, "survive compression");
934 assert_eq!(mgr.partitions.task_state.plan.len(), 2);
935 }
936
937 #[test]
938 fn render_includes_task_state_in_state_turn_not_system() {
939 let mut mgr = ContextManager::new(10_000);
940 mgr.init_task("find anomalies".to_string(), vec![]);
941 let rc = mgr.render();
942 assert!(!rc.system_text.contains("[TASK STATE]"), "task_state must not be in system_text");
943 let state = rc.state_turn.as_ref().expect("should have a state turn");
945 assert!(state.content.as_text().unwrap().contains("[TASK STATE] goal: find anomalies"));
946 }
947
948 #[test]
949 fn renewal_open_tasks_from_task_state() {
950 let mut mgr = ContextManager::new(1_000);
951 mgr.init_task("g".to_string(), vec![]);
952 mgr.partitions.task_state.plan = vec![
953 PlanStep { label: "done".to_string(), done: true },
954 PlanStep { label: "pending".to_string(), done: false },
955 ];
956 mgr.renew();
957 let artifact = mgr.last_handoff.as_ref().unwrap();
958 assert_eq!(artifact.open_tasks, vec!["pending"]);
959 }
960
961 #[test]
962 fn pinned_history_section_skips_compression() {
963 let mut mgr = ContextManager::new(1_000);
964 for _ in 0..30 { mgr.push_history(Message::user("filler message for pinning test"), 50); }
965 let tokens_before = mgr.partitions.history.token_count;
966 mgr.pin_section("history.rolling");
967 let (saved, _, _, _) = mgr.compress(PressureAction::AutoCompact);
968 assert_eq!(saved, 0);
969 assert_eq!(mgr.partitions.history.token_count, tokens_before);
970 }
971
972 #[test]
973 fn unpinned_history_section_allows_compression() {
974 let mut mgr = ContextManager::new(1_000);
975 for _ in 0..30 { mgr.push_history(Message::user("filler"), 50); }
976 mgr.pin_section("history.rolling");
977 mgr.unpin_section("history.rolling");
978 let (saved, _, _, _) = mgr.compress(PressureAction::AutoCompact);
979 assert!(saved > 0);
980 }
981
982 #[test]
983 fn force_compress_also_skips_when_history_pinned() {
984 let mut mgr = ContextManager::new(1_000);
985 for _ in 0..10 { mgr.push_history(Message::user("filler"), 50); }
986 mgr.pin_section("history.rolling");
987 let (saved, _, _, _) = mgr.force_compress();
988 assert_eq!(saved, 0);
989 }
990
991 #[test]
994 fn auto_compact_entry_logs_auto_compact_action() {
995 let mut mgr = ContextManager::new(1_000);
1003 for i in 0..40 {
1004 mgr.push_history(Message::user(format!("turn {i}: {}", "ctx ".repeat(40))), 200);
1005 }
1006 let (saved, summary, _, _) = mgr.force_compress();
1007 assert!(saved > 0, "force_compress should compact a large history");
1008 assert!(summary.is_some(), "auto-compact summarizes the archived turns");
1009 let actions: Vec<&str> = mgr
1010 .partitions
1011 .task_state
1012 .compression_log
1013 .iter()
1014 .map(|e| e.action.as_str())
1015 .collect();
1016 assert!(
1017 actions.last() == Some(&"auto_compact"),
1018 "auto-compact entry must log an auto_compact action; got {actions:?}"
1019 );
1020 }
1021
1022 #[test]
1023 fn skill_tool_schema_empty_when_no_skills() {
1024 let mgr = ContextManager::new(10_000);
1025 assert!(mgr.skill_tool_schema().is_none());
1026 }
1027
1028 #[test]
1029 fn skill_tool_schema_present_when_registered() {
1030 let mut mgr = ContextManager::new(10_000);
1031 mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
1032 assert!(mgr.skill_tool_schema().unwrap().description.contains("debug"));
1033 }
1034
1035 #[test]
1036 fn available_skills_are_reflected_in_capability_manifest() {
1037 let mut mgr = ContextManager::new(1_000);
1038 mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
1039 let inventory = mgr.capability_inventory();
1040 assert!(inventory.contains("debug"));
1041 assert!(inventory.contains("Debug helper"));
1042 }
1043
1044 #[test]
1045 fn toggled_meta_tools_are_reflected_in_capability_manifest() {
1046 let mut mgr = ContextManager::new(1_000);
1047 mgr.set_memory_enabled(true);
1048 assert!(mgr.capability_inventory().contains(MEMORY_TOOL_NAME));
1049 mgr.set_memory_enabled(false);
1050 assert!(!mgr.capability_inventory().contains(MEMORY_TOOL_NAME));
1051 }
1052
1053 #[test]
1054 fn meta_tool_schemas_are_sorted() {
1055 let mut mgr = ContextManager::new(1_000);
1056 mgr.set_available_skills(vec![SkillMetadata::new("debug", "Debug helper")]);
1057 mgr.set_memory_enabled(true);
1058 mgr.set_knowledge_enabled(true);
1059 let names = mgr.meta_tool_schemas().into_iter().map(|s| s.name.to_string()).collect::<Vec<_>>();
1060 assert_eq!(names, ["knowledge", "memory", "skill"]);
1061 }
1062
1063 #[test]
1064 fn section_registry_is_available_on_manager() {
1065 let mgr = ContextManager::new(1_000);
1066 assert!(mgr.sections.get("capabilities.inventory").is_some());
1067 }
1068
1069 #[test]
1070 fn b1_active_skill_state_and_tool_filter() {
1071 let mut mgr = ContextManager::new(1_000);
1072 let mut debug = SkillMetadata::new("debug", "Debug helper");
1073 debug.allowed_tools = vec![CompactString::new("read"), CompactString::new("grep")];
1074 let mut review = SkillMetadata::new("review", "Reviewer");
1075 review.allowed_tools = vec![CompactString::new("git_diff")];
1076 let plain = SkillMetadata::new("plain", "No tools declared"); mgr.set_available_skills(vec![debug, review, plain]);
1078
1079 assert!(mgr.active_skill_tool_filter().is_none());
1081
1082 assert!(mgr.activate_skill("debug"));
1084 assert!(!mgr.activate_skill("debug")); let f = mgr.active_skill_tool_filter().unwrap();
1088 assert_eq!(f.len(), 2);
1089 assert!(f.contains(&CompactString::new("read")) && f.contains(&CompactString::new("grep")));
1090
1091 mgr.activate_skill("review");
1093 let f = mgr.active_skill_tool_filter().unwrap();
1094 assert_eq!(f.len(), 3);
1095 assert!(f.contains(&CompactString::new("git_diff")));
1096
1097 mgr.activate_skill("plain");
1099 assert!(mgr.active_skill_tool_filter().is_none());
1100 }
1101
1102 #[test]
1103 fn snapshot_hint_changes_when_capabilities_change() {
1104 let mut mgr = ContextManager::new(1_000);
1105 let before = mgr.snapshot_hint();
1106 mgr.set_memory_enabled(true);
1107 let after = mgr.snapshot_hint();
1108 assert_ne!(before.capability_manifest_hash, after.capability_manifest_hash);
1109 }
1110
1111 #[test]
1112 fn update_collapse_mode_collapses_old_tool_results_under_pressure() {
1113 let mut mgr = ContextManager::new(1_000);
1114 for i in 0..10 {
1115 let m = Message::tool(vec![ContentPart::ToolResult {
1116 call_id: format!("c{i}").into(),
1117 output: "x".repeat(40),
1118 is_error: false,
1119 }]);
1120 mgr.push_history(m, 40);
1121 }
1122 mgr.set_observed_prompt_tokens(950); assert!(mgr.rho() >= mgr.config.collapse_threshold);
1125
1126 mgr.recompute_handle_residency();
1127 assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Collapsed));
1129 assert_eq!(mgr.handles.residency_for_source("c9"), Some(&Residency::Resident));
1130
1131 mgr.set_observed_prompt_tokens(100); mgr.recompute_handle_residency();
1135 assert_eq!(
1136 mgr.handles.residency_for_source("c0"),
1137 Some(&Residency::Collapsed),
1138 "collapse is sticky until a compaction boundary"
1139 );
1140
1141 mgr.reset_collapse_generation();
1143 assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Resident));
1144 }
1145
1146 #[test]
1147 fn frozen_prefix_len_anchors_at_compaction_and_holds_across_appends() {
1148 let mut mgr = ContextManager::new(1_000);
1149 for i in 0..30 {
1151 mgr.push_history(Message::user(format!("turn {i}: {}", "ctx ".repeat(30))), 150);
1152 }
1153 assert!(mgr.render().frozen_prefix_len.is_none(), "no frozen region before any compaction");
1154
1155 let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
1156 assert!(saved > 0 && !archived.is_empty(), "expected archival");
1157
1158 assert!(mgr.render().frozen_prefix_len.is_none(), "deep == tail right after compaction");
1160
1161 mgr.push_history(Message::user("new 1"), 5);
1163 let f1 = mgr.render().frozen_prefix_len.expect("frozen region exists once the tail grows");
1164 mgr.push_history(Message::assistant("reply 1"), 5);
1165 mgr.push_history(Message::user("new 2"), 5);
1166 let rc = mgr.render();
1167 let f2 = rc.frozen_prefix_len.expect("frozen region holds");
1168 assert_eq!(f1, f2, "the deep boundary is fixed between compactions; only the tail grows");
1169 assert!(f2 < rc.turns.len(), "deep boundary is distinct from the rolling tail");
1170 }
1171
1172 #[test]
1173 fn frozen_boundary_holds_through_a_prefix_safe_compaction() {
1174 let mut mgr = ContextManager::new(10_000);
1177 for i in 0..5 {
1178 mgr.push_history(Message::user(format!("m{i}")), 5);
1179 }
1180 mgr.frozen_history_len = 3; let (_, _, _, cache_at) = mgr.compress(PressureAction::None);
1185 assert!(cache_at.is_none(), "no-op compaction is prefix-safe");
1186 assert_eq!(mgr.frozen_history_len, 3, "prefix-safe compaction preserves the deep-cache anchor");
1187 }
1188
1189 #[test]
1190 fn collapse_generation_resets_on_autocompact() {
1191 let mut mgr = ContextManager::new(1_000);
1192 for i in 0..20 {
1195 mgr.push_history(tool_result_msg(&format!("c{i}"), &"x".repeat(120)), 60);
1196 }
1197 mgr.set_observed_prompt_tokens(980); mgr.recompute_handle_residency();
1199 assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Collapsed));
1200
1201 let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
1202 assert!(saved > 0 && !archived.is_empty(), "expected archival");
1203
1204 for h in mgr.handles.all() {
1207 if matches!(h.kind, HandleKind::ToolResult) {
1208 assert_eq!(h.residency, Residency::Resident, "generation reset un-collapses survivors");
1209 }
1210 }
1211 }
1212
1213 #[test]
1214 fn mark_spooled_sets_residency_and_survives_residency_recompute() {
1215 let mut mgr = ContextManager::new(1_000);
1216 mgr.push_history(
1217 Message::tool(vec![ContentPart::ToolResult {
1218 call_id: "big".into(),
1219 output: "preview only".to_string(),
1220 is_error: false,
1221 }]),
1222 10,
1223 );
1224 mgr.mark_spooled("big", "disk://big");
1225 assert_eq!(
1226 mgr.handles.residency_for_source("big"),
1227 Some(&Residency::SpooledOut { r: "disk://big".to_string() })
1228 );
1229
1230 mgr.set_observed_prompt_tokens(990);
1233 mgr.recompute_handle_residency();
1234 assert_eq!(
1235 mgr.handles.residency_for_source("big"),
1236 Some(&Residency::SpooledOut { r: "disk://big".to_string() })
1237 );
1238 }
1239
1240 #[test]
1241 fn push_history_indexes_tool_results_as_resident_handles() {
1242 let mut mgr = ContextManager::new(10_000);
1243 let msg = Message::tool(vec![ContentPart::ToolResult {
1244 call_id: "call_1".into(),
1245 output: "the tool output".to_string(),
1246 is_error: false,
1247 }]);
1248 mgr.push_history(msg, 20);
1249 assert_eq!(mgr.handles.all().len(), 1);
1251 assert_eq!(
1252 mgr.handles.residency_for_source("call_1"),
1253 Some(&Residency::Resident)
1254 );
1255 mgr.push_history(Message::user("hello"), 5);
1257 assert_eq!(mgr.handles.all().len(), 1);
1258 }
1259
1260 fn tool_result_msg(call_id: &str, output: &str) -> Message {
1263 Message::tool(vec![ContentPart::ToolResult {
1264 call_id: call_id.into(),
1265 output: output.to_string(),
1266 is_error: false,
1267 }])
1268 }
1269
1270 #[test]
1271 fn effective_rho_discounts_paged_out_handles() {
1272 let mut mgr = ContextManager::new(1_000);
1273 let big = "data ".repeat(200);
1275 let tok = mgr.engine.count(&big);
1276 mgr.push_history(tool_result_msg("c0", &big), tok);
1277 mgr.push_history(Message::user("u"), 50);
1278
1279 let raw = mgr.rho();
1280 assert_eq!(mgr.handles.non_resident_tokens(), 0);
1282 assert!((mgr.effective_rho() - raw).abs() < f64::EPSILON);
1283
1284 mgr.mark_spooled("c0", "disk://c0");
1286 let paged = mgr.handles.non_resident_tokens();
1287 assert!(paged > 0, "handle is now non-resident with a real token weight");
1288
1289 assert!((mgr.rho() - raw).abs() < f64::EPSILON, "raw rho unchanged by paging");
1291 let total = mgr.partitions.total_tokens(&mgr.engine);
1293 let expected = total.saturating_sub(paged) as f64 / 1_000.0;
1294 assert!((mgr.effective_rho() - expected).abs() < f64::EPSILON);
1295 assert!(mgr.effective_rho() < raw, "effective pressure relieved by paging");
1296
1297 mgr.set_observed_prompt_tokens(900);
1300 assert!((mgr.effective_rho() - mgr.rho()).abs() < f64::EPSILON);
1301 }
1302
1303 #[test]
1304 fn prune_orphaned_handles_drops_handles_whose_message_left_history() {
1305 let mut mgr = ContextManager::new(10_000);
1306 mgr.push_history(tool_result_msg("c0", "out 0"), 20);
1307 mgr.push_history(tool_result_msg("c1", "out 1"), 20);
1308 assert_eq!(mgr.handles.all().len(), 2);
1309
1310 mgr.partitions.history.messages.remove(0);
1312 mgr.prune_orphaned_handles();
1313
1314 assert_eq!(mgr.handles.all().len(), 1);
1316 assert!(mgr.handles.residency_for_source("c0").is_none());
1317 assert_eq!(
1318 mgr.handles.residency_for_source("c1"),
1319 Some(&Residency::Resident)
1320 );
1321 }
1322
1323 #[test]
1324 fn autocompact_prunes_handles_for_archived_tool_results() {
1325 let mut mgr = ContextManager::new(1_000);
1326 for i in 0..30 {
1328 mgr.push_history(tool_result_msg(&format!("c{i}"), &"x".repeat(200)), 80);
1329 }
1330 assert_eq!(mgr.handles.all().len(), 30);
1331
1332 let (saved, _, archived, _) = mgr.compress(PressureAction::AutoCompact);
1333 assert!(saved > 0 && !archived.is_empty(), "expected archival");
1334
1335 let live_tool_results = mgr
1338 .partitions
1339 .history
1340 .messages
1341 .iter()
1342 .filter(|m| matches!(&m.content, Content::Parts(p)
1343 if p.iter().any(|x| matches!(x, ContentPart::ToolResult { .. }))))
1344 .count();
1345 assert_eq!(mgr.handles.all().len(), live_tool_results);
1346 assert!(mgr.handles.all().len() < 30, "table must shrink with archival");
1347 }
1348
1349 #[test]
1350 fn renew_prunes_handles_for_dropped_history() {
1351 let mut mgr = ContextManager::new(1_000);
1352 mgr.init_task("g".to_string(), vec![]);
1353 for i in 0..20 {
1354 mgr.push_history(tool_result_msg(&format!("c{i}"), "data"), 60);
1355 }
1356 mgr.renew();
1357 for h in mgr.handles.all() {
1359 if let Some(src) = h.source.as_ref() {
1360 assert!(
1361 mgr.handles.residency_for_source(src).is_some(),
1362 "no dangling handle survives renewal"
1363 );
1364 }
1365 }
1366 assert!(mgr.handles.all().len() <= 20);
1367 }
1368
1369 #[test]
1370 fn recompute_residency_index_semantics_with_spooled_in_the_middle() {
1371 let mut mgr = ContextManager::new(1_000);
1374 for i in 0..6 {
1375 mgr.push_history(tool_result_msg(&format!("c{i}"), &"y".repeat(40)), 40);
1376 }
1377 mgr.mark_spooled("c2", "disk://c2");
1378
1379 mgr.set_observed_prompt_tokens(950); mgr.recompute_handle_residency();
1381
1382 assert_eq!(
1384 mgr.handles.residency_for_source("c2"),
1385 Some(&Residency::SpooledOut { r: "disk://c2".to_string() })
1386 );
1387 assert_eq!(mgr.handles.residency_for_source("c0"), Some(&Residency::Collapsed));
1388 assert_eq!(mgr.handles.residency_for_source("c5"), Some(&Residency::Resident));
1389 }
1390
1391 #[test]
1394 fn knowledge_budget_marks_oldest_unpinned_first_and_warns_once() {
1395 let mut mgr = ContextManager::new(100);
1398 mgr.push_knowledge(Message::system("oldest unkeyed"), 10);
1399 mgr.push_knowledge_entry(Some("a".into()), Message::system("keyed"), 10, false);
1400 mgr.push_knowledge_entry(Some("p".into()), Message::system("pinned"), 10, true);
1401 mgr.push_knowledge_entry(Some("skill:x".into()), Message::system("skill"), 10, false);
1402
1403 let warn = mgr.enforce_knowledge_budget();
1404 assert_eq!(warn, Some((40, 25)));
1405 let e = &mgr.partitions.knowledge.entries;
1407 assert!(e[0].evict_at_boundary);
1408 assert!(e[1].evict_at_boundary);
1409 assert!(!e[2].evict_at_boundary, "pinned exempt");
1410 assert!(!e[3].evict_at_boundary, "skill pin exempt");
1411
1412 assert_eq!(mgr.enforce_knowledge_budget(), None);
1414
1415 let sweep = mgr.partitions.knowledge.sweep_at_boundary();
1417 assert_eq!(sweep.tokens_freed, 20);
1418 assert_eq!(mgr.partitions.knowledge.token_count, 20);
1419 assert_eq!(mgr.enforce_knowledge_budget(), None);
1421 }
1422
1423 #[test]
1424 fn knowledge_budget_warning_stands_when_only_exempt_weight_remains() {
1425 let mut mgr = ContextManager::new(100);
1426 mgr.push_knowledge_entry(Some("p".into()), Message::system("pinned heavy"), 30, true);
1427 mgr.push_knowledge_entry(Some("skill:x".into()), Message::system("skill heavy"), 30, false);
1428
1429 assert_eq!(mgr.enforce_knowledge_budget(), Some((60, 25)));
1431 assert!(mgr.partitions.knowledge.entries.iter().all(|e| !e.evict_at_boundary));
1432 }
1433
1434 #[test]
1435 fn knowledge_budget_ratio_zero_disables() {
1436 let mut mgr = ContextManager::new(100);
1437 mgr.config.knowledge_budget_ratio = 0.0;
1438 mgr.push_knowledge(Message::system("huge"), 90);
1439 assert_eq!(mgr.enforce_knowledge_budget(), None);
1440 assert!(!mgr.partitions.knowledge.entries[0].evict_at_boundary);
1441 }
1442}