1use super::{
14 Capability, CapabilityLocalization, CapabilityStatus, ModelViewContext, ModelViewProvider,
15};
16use crate::events::TokenUsage;
17use crate::message::{ContentPart, Message, MessageRole};
18use crate::message_filter::MessageFilterProvider;
19use serde::{Deserialize, Serialize};
20use std::collections::{HashMap, HashSet};
21use std::sync::Arc;
22
23pub const COMPACTION_CAPABILITY_ID: &str = "compaction";
25const MAX_RELATED_RECENT_READ_RESULTS: usize = 4;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
29#[serde(rename_all = "snake_case")]
30pub enum CompactionStrategy {
31 #[default]
33 Auto,
34 Native,
36 ObservationMasking,
38 Summarization,
40}
41
42impl std::fmt::Display for CompactionStrategy {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 Self::Auto => write!(f, "auto"),
46 Self::Native => write!(f, "native"),
47 Self::ObservationMasking => write!(f, "observation_masking"),
48 Self::Summarization => write!(f, "summarization"),
49 }
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
55#[serde(rename_all = "snake_case")]
56pub enum MaskingSummaryFormat {
57 #[default]
59 OneLine,
60 HeadTail,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ObservationMaskingConfig {
67 #[serde(default = "default_keep_recent_tool_outputs")]
69 pub keep_recent_tool_outputs: usize,
70
71 #[serde(default)]
73 pub summary_format: MaskingSummaryFormat,
74}
75
76impl Default for ObservationMaskingConfig {
77 fn default() -> Self {
78 Self {
79 keep_recent_tool_outputs: default_keep_recent_tool_outputs(),
80 summary_format: MaskingSummaryFormat::default(),
81 }
82 }
83}
84
85fn default_keep_recent_tool_outputs() -> usize {
86 2
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct CostControlConfig {
99 #[serde(default = "default_cost_control_enabled")]
101 pub enabled: bool,
102
103 #[serde(default = "default_cost_control_keep_recent_tool_results")]
105 pub keep_recent_tool_results: usize,
106
107 #[serde(default = "default_cost_control_mask_after_tool_results")]
109 pub mask_after_tool_results: usize,
110
111 #[serde(default = "default_cost_control_max_live_tool_result_bytes")]
113 pub max_live_tool_result_bytes: usize,
114
115 #[serde(default = "default_cost_control_max_uncached_input_tokens")]
117 pub max_uncached_input_tokens: u32,
118
119 #[serde(default = "default_cost_control_min_cache_read_ratio")]
121 pub min_cache_read_ratio: f32,
122}
123
124impl Default for CostControlConfig {
125 fn default() -> Self {
126 Self {
127 enabled: default_cost_control_enabled(),
128 keep_recent_tool_results: default_cost_control_keep_recent_tool_results(),
129 mask_after_tool_results: default_cost_control_mask_after_tool_results(),
130 max_live_tool_result_bytes: default_cost_control_max_live_tool_result_bytes(),
131 max_uncached_input_tokens: default_cost_control_max_uncached_input_tokens(),
132 min_cache_read_ratio: default_cost_control_min_cache_read_ratio(),
133 }
134 }
135}
136
137fn default_cost_control_enabled() -> bool {
138 true
139}
140
141fn default_cost_control_keep_recent_tool_results() -> usize {
142 2
143}
144
145fn default_cost_control_mask_after_tool_results() -> usize {
146 4
147}
148
149fn default_cost_control_max_live_tool_result_bytes() -> usize {
150 24 * 1024
151}
152
153fn default_cost_control_max_uncached_input_tokens() -> u32 {
154 100_000
155}
156
157fn default_cost_control_min_cache_read_ratio() -> f32 {
158 0.35
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct SummarizationConfig {
164 #[serde(default)]
166 pub model: Option<String>,
167
168 #[serde(default = "default_preserve")]
170 pub preserve: Vec<String>,
171
172 #[serde(default)]
174 pub instructions: Option<String>,
175}
176
177impl Default for SummarizationConfig {
178 fn default() -> Self {
179 Self {
180 model: None,
181 preserve: default_preserve(),
182 instructions: None,
183 }
184 }
185}
186
187fn default_preserve() -> Vec<String> {
188 vec![
189 "decisions".to_string(),
190 "files_modified".to_string(),
191 "errors".to_string(),
192 "current_plan".to_string(),
193 "skill_instructions".to_string(),
194 ]
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct CompactionConfig {
205 #[serde(default)]
207 pub strategy: CompactionStrategy,
208
209 #[serde(default = "default_proactive")]
211 pub proactive: bool,
212
213 #[serde(default = "default_budget_percent")]
215 pub budget_percent: f32,
216
217 #[serde(default)]
219 pub observation_masking: ObservationMaskingConfig,
220
221 #[serde(default)]
223 pub summarization: SummarizationConfig,
224
225 #[serde(default)]
227 pub memory_tiers: HierarchicalMemoryConfig,
228
229 #[serde(default)]
231 pub cost_control: CostControlConfig,
232}
233
234impl Default for CompactionConfig {
235 fn default() -> Self {
236 Self {
237 strategy: CompactionStrategy::default(),
238 proactive: default_proactive(),
239 budget_percent: default_budget_percent(),
240 observation_masking: ObservationMaskingConfig::default(),
241 summarization: SummarizationConfig::default(),
242 memory_tiers: HierarchicalMemoryConfig::default(),
243 cost_control: CostControlConfig::default(),
244 }
245 }
246}
247
248fn default_proactive() -> bool {
249 true
250}
251
252fn default_budget_percent() -> f32 {
253 0.85
254}
255
256impl CompactionConfig {
257 pub fn from_json(value: &serde_json::Value) -> Self {
259 serde_json::from_value(value.clone()).unwrap_or_default()
260 }
261}
262
263pub struct CompactionCapability;
265
266impl Capability for CompactionCapability {
267 fn id(&self) -> &str {
268 COMPACTION_CAPABILITY_ID
269 }
270
271 fn name(&self) -> &str {
272 "Compaction"
273 }
274
275 fn description(&self) -> &str {
276 r#"Configurable context compaction when conversations exceed LLM context windows.
277
278Choose between native provider compaction (e.g., OpenAI /responses/compact), observation masking (strip old tool outputs), or LLM summarization. The `auto` strategy cascades through all available options."#
279 }
280
281 fn status(&self) -> CapabilityStatus {
282 CapabilityStatus::Available
283 }
284
285 fn icon(&self) -> Option<&str> {
286 Some("shrink")
287 }
288
289 fn category(&self) -> Option<&str> {
290 Some("Optimization")
291 }
292
293 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
294 Some(Arc::new(CompactionFilterProvider))
295 }
296
297 fn model_view_provider(&self) -> Option<Arc<dyn ModelViewProvider>> {
298 Some(Arc::new(CompactionModelViewProvider))
299 }
300
301 fn config_schema(&self) -> Option<serde_json::Value> {
308 Some(serde_json::json!({
309 "type": "object",
310 "properties": {
311 "strategy": {
312 "type": "string",
313 "title": "Strategy",
314 "description": "Compaction strategy used when the conversation approaches the context window.",
315 "oneOf": [
316 { "const": "auto", "title": "Automatic" },
317 { "const": "native", "title": "Provider-native" },
318 { "const": "observation_masking", "title": "Observation masking" },
319 { "const": "summarization", "title": "LLM summarization" }
320 ],
321 "default": "auto"
322 },
323 "proactive": {
324 "type": "boolean",
325 "title": "Proactive compaction",
326 "description": "Compact at the budget threshold instead of waiting for a request-too-large error.",
327 "default": true
328 },
329 "budget_percent": {
330 "type": "number",
331 "title": "Context budget threshold",
332 "description": "Fraction of the model context window at which proactive compaction triggers.",
333 "minimum": 0.1,
334 "maximum": 1.0,
335 "default": 0.85
339 }
340 }
341 }))
342 }
343
344 fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
345 if config.is_null() {
346 return Ok(());
347 }
348 let typed: CompactionConfig = serde_json::from_value(config.clone())
349 .map_err(|e| format!("invalid compaction config: {e}"))?;
350 if !(0.1..=1.0).contains(&typed.budget_percent) {
351 return Err(format!(
352 "budget_percent must be between 0.1 and 1.0, got {}",
353 typed.budget_percent
354 ));
355 }
356 Ok(())
357 }
358
359 fn localizations(&self) -> Vec<CapabilityLocalization> {
360 vec![
361 CapabilityLocalization {
362 locale: "en",
363 name: None,
364 description: None,
365 config_description: Some(
366 "Controls the compaction strategy, proactive triggering, and the \
367 context-budget threshold.",
368 ),
369 config_overlay: None,
370 },
371 CapabilityLocalization {
372 locale: "uk",
373 name: Some("Ущільнення контексту"),
374 description: Some(
375 "Налаштовуване ущільнення контексту, коли розмова перевищує контекстне \
376 вікно LLM. Доступні стратегії: нативне ущільнення провайдера, маскування \
377 результатів інструментів і підсумовування через LLM; стратегія auto \
378 перебирає всі доступні варіанти.",
379 ),
380 config_description: Some(
381 "Визначає стратегію ущільнення контексту, проактивний запуск і поріг \
382 бюджету контексту.",
383 ),
384 config_overlay: Some(serde_json::json!({
385 "properties": {
386 "strategy": {
387 "title": "Стратегія",
388 "description": "Стратегія ущільнення, коли розмова наближається до межі контекстного вікна.",
389 "enum_labels": {
390 "auto": "Автоматично",
391 "native": "Нативна (провайдер)",
392 "observation_masking": "Маскування результатів інструментів",
393 "summarization": "Підсумовування через LLM"
394 }
395 },
396 "proactive": {
397 "title": "Проактивне ущільнення",
398 "description": "Ущільнювати контекст при досягненні порогу бюджету, а не лише після помилки про завеликий запит."
399 },
400 "budget_percent": {
401 "title": "Поріг бюджету контексту",
402 "description": "Частка контекстного вікна моделі, після якої запускається проактивне ущільнення."
403 }
404 }
405 })),
406 },
407 ]
408 }
409}
410
411struct CompactionModelViewProvider;
412
413impl ModelViewProvider for CompactionModelViewProvider {
414 fn apply_model_view(
415 &self,
416 messages: Vec<Message>,
417 config: &serde_json::Value,
418 context: &ModelViewContext<'_>,
419 ) -> Vec<Message> {
420 let config = CompactionConfig::from_json(config);
421 let masking = build_model_view_messages_owned(messages, &config, context.prior_usage);
422 if masking.masked_count > 0 {
423 tracing::info!(
424 session_id = %context.session_id,
425 masked_count = masking.masked_count,
426 tool_result_bytes_before = masking.tool_result_bytes_before,
427 tool_result_bytes_after = masking.tool_result_bytes_after,
428 "CompactionCapability: masked stale tool results for model view"
429 );
430 }
431 masking.messages
432 }
433
434 fn priority(&self) -> i32 {
435 50
436 }
437}
438
439struct CompactionFilterProvider;
449
450impl MessageFilterProvider for CompactionFilterProvider {
451 fn apply_filters(
452 &self,
453 _query: &mut crate::message_filter::MessageQuery,
454 _config: &serde_json::Value,
455 ) {
456 }
464
465 fn priority(&self) -> i32 {
466 50 }
468}
469
470pub fn estimate_tokens(msg: &LlmMessage) -> usize {
479 let text_len = match &msg.content {
480 LlmMessageContent::Text(t) => t.len(),
481 LlmMessageContent::Parts(parts) => parts
482 .iter()
483 .map(|p| match p {
484 LlmContentPart::Text { text } => text.len(),
485 _ => 50, })
487 .sum(),
488 };
489
490 let tool_call_len = msg
492 .tool_calls
493 .as_ref()
494 .map(|calls| {
495 calls
496 .iter()
497 .map(|tc| tc.name.len() + tc.arguments.to_string().len() + 20)
498 .sum::<usize>()
499 })
500 .unwrap_or(0);
501
502 (text_len + tool_call_len) / 4
503}
504
505pub fn estimate_total_tokens(messages: &[LlmMessage]) -> usize {
507 messages.iter().map(estimate_tokens).sum()
508}
509
510pub fn should_compact_proactively(
515 messages: &[LlmMessage],
516 config: &CompactionConfig,
517 context_window_tokens: usize,
518) -> bool {
519 if !config.proactive {
520 return false;
521 }
522 let budget = (context_window_tokens as f32 * config.budget_percent) as usize;
523 let estimated = estimate_total_tokens(messages);
524 estimated > budget
525}
526
527pub fn aggressive_trim(
537 messages: &[LlmMessage],
538 target_tokens: usize,
539 has_system_prompt: bool,
540) -> Vec<LlmMessage> {
541 let mut result = Vec::new();
542 let mut token_budget = target_tokens;
543
544 let start_idx = if has_system_prompt && !messages.is_empty() {
546 let sys_tokens = estimate_tokens(&messages[0]);
547 if sys_tokens < token_budget {
548 result.push(messages[0].clone());
549 token_budget -= sys_tokens;
550 }
551 1
552 } else {
553 0
554 };
555
556 let conversation = &messages[start_idx..];
557
558 let mut protected_indices: std::collections::HashSet<usize> = conversation
561 .iter()
562 .enumerate()
563 .filter(|(_, m)| {
564 is_protected_tool_result(conversation, m) || is_protected_tool_call_message(m)
565 })
566 .map(|(i, _)| i)
567 .collect();
568
569 if !conversation.is_empty() {
577 protected_indices.insert(0);
578 }
579
580 let mut protected_budget: usize = 0;
581 for &idx in &protected_indices {
582 protected_budget += estimate_tokens(&conversation[idx]);
583 }
584
585 if protected_budget > token_budget {
588 let mut protected_with_indices: Vec<(usize, LlmMessage)> = protected_indices
589 .iter()
590 .map(|&idx| (idx, conversation[idx].clone()))
591 .collect();
592 protected_with_indices.sort_by_key(|(i, _)| *i);
593
594 let mut remaining = token_budget;
595 let mut kept: Vec<(usize, LlmMessage)> = Vec::new();
596 for (idx, msg) in protected_with_indices.into_iter().rev() {
597 let t = estimate_tokens(&msg);
598 if t <= remaining {
599 kept.push((idx, msg));
600 remaining -= t;
601 }
602 }
603 kept.sort_by_key(|(i, _)| *i);
604 result.extend(kept.into_iter().map(|(_, m)| m));
605 return result;
606 }
607
608 token_budget -= protected_budget;
609
610 let mut keep_from_end = Vec::new();
612 for (i, msg) in conversation.iter().enumerate().rev() {
613 if protected_indices.contains(&i) {
614 continue; }
616 let msg_tokens = estimate_tokens(msg);
617 if msg_tokens <= token_budget {
618 keep_from_end.push((i, msg.clone()));
619 token_budget -= msg_tokens;
620 } else {
621 break;
622 }
623 }
624
625 let mut all_kept: Vec<(usize, LlmMessage)> = Vec::new();
627 for &idx in &protected_indices {
628 all_kept.push((idx, conversation[idx].clone()));
629 }
630 all_kept.extend(keep_from_end);
631 all_kept.sort_by_key(|(i, _)| *i);
632
633 result.extend(all_kept.into_iter().map(|(_, m)| m));
634 result
635}
636
637#[derive(Debug, Clone, Default, Serialize, Deserialize)]
643pub struct SessionCompactionMetrics {
644 pub compaction_count: u32,
646 pub total_messages_saved: u64,
648 pub strategy_counts: HashMap<String, u32>,
650 pub total_duration_ms: u64,
652}
653
654impl SessionCompactionMetrics {
655 pub fn record(
657 &mut self,
658 strategy_used: &str,
659 messages_before: usize,
660 messages_after: usize,
661 duration_ms: u64,
662 ) {
663 self.compaction_count += 1;
664 self.total_messages_saved += (messages_before.saturating_sub(messages_after)) as u64;
665 self.total_duration_ms += duration_ms;
666
667 for strategy in strategy_used.split('+') {
668 *self
669 .strategy_counts
670 .entry(strategy.to_string())
671 .or_insert(0) += 1;
672 }
673 }
674}
675
676#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
682#[serde(rename_all = "snake_case")]
683pub enum MemoryTier {
684 Hot,
686 Warm,
688 Cold,
690}
691
692#[derive(Debug, Clone, Serialize, Deserialize)]
694pub struct HierarchicalMemoryConfig {
695 #[serde(default = "default_hot_messages")]
697 pub hot_messages: usize,
698 #[serde(default = "default_warm_messages")]
700 pub warm_messages: usize,
701 }
703
704impl Default for HierarchicalMemoryConfig {
705 fn default() -> Self {
706 Self {
707 hot_messages: default_hot_messages(),
708 warm_messages: default_warm_messages(),
709 }
710 }
711}
712
713fn default_hot_messages() -> usize {
714 20
715}
716
717fn default_warm_messages() -> usize {
718 100
719}
720
721pub fn classify_memory_tiers<'a>(
725 messages: &'a [LlmMessage],
726 config: &HierarchicalMemoryConfig,
727) -> Vec<(MemoryTier, &'a LlmMessage)> {
728 let len = messages.len();
729 messages
730 .iter()
731 .enumerate()
732 .map(|(i, msg)| {
733 let from_end = len - 1 - i;
734 let tier = if from_end < config.hot_messages {
735 MemoryTier::Hot
736 } else if from_end < config.hot_messages + config.warm_messages {
737 MemoryTier::Warm
738 } else {
739 MemoryTier::Cold
740 };
741 (tier, msg)
742 })
743 .collect()
744}
745
746pub fn apply_hierarchical_memory(
754 messages: &[LlmMessage],
755 config: &HierarchicalMemoryConfig,
756 masking_config: &ObservationMaskingConfig,
757 cold_summary: Option<&str>,
758) -> Vec<LlmMessage> {
759 let len = messages.len();
760 let hot_start = len.saturating_sub(config.hot_messages);
761 let warm_start = hot_start.saturating_sub(config.warm_messages);
762
763 let mut result = Vec::new();
764
765 if warm_start > 0 {
767 let cold_msgs = &messages[..warm_start];
769 let protected_cold: Vec<LlmMessage> = cold_msgs
770 .iter()
771 .filter(|m| is_protected_tool_result(cold_msgs, m) || is_protected_tool_call_message(m))
772 .cloned()
773 .collect();
774
775 if let Some(summary) = cold_summary {
776 result.push(build_summary_message(summary));
777 }
778
779 result.extend(protected_cold);
781 }
782
783 if warm_start < hot_start {
787 let warm_msgs = &messages[warm_start..hot_start];
788
789 let protected_call_ids: std::collections::HashSet<String> = warm_msgs
791 .iter()
792 .filter(|m| is_protected_tool_result(messages, m))
793 .filter_map(|m| m.tool_call_id.clone())
794 .collect();
795
796 let masked = apply_observation_masking_with_protected(
797 warm_msgs,
798 masking_config,
799 &protected_call_ids,
800 );
801 result.extend(masked.messages);
802 }
803
804 if hot_start < len {
806 result.extend_from_slice(&messages[hot_start..]);
807 }
808
809 result
810}
811
812use crate::driver_registry::{LlmContentPart, LlmMessage, LlmMessageContent, LlmMessageRole};
817
818const PROTECTED_TOOL_NAMES: &[&str] = &["activate_skill"];
826
827fn is_protected_tool_result(messages: &[LlmMessage], tool_msg: &LlmMessage) -> bool {
832 if tool_msg.role != LlmMessageRole::Tool {
833 return false;
834 }
835 let tool_name = find_tool_call_name(messages, tool_msg);
836 PROTECTED_TOOL_NAMES.contains(&tool_name.as_str())
837}
838
839fn is_protected_tool_call_message(msg: &LlmMessage) -> bool {
843 if msg.role != LlmMessageRole::Assistant {
844 return false;
845 }
846 msg.tool_calls.as_ref().is_some_and(|calls| {
847 calls
848 .iter()
849 .any(|tc| PROTECTED_TOOL_NAMES.contains(&tc.name.as_str()))
850 })
851}
852
853#[derive(Debug)]
859pub struct ObservationMaskingResult {
860 pub messages: Vec<LlmMessage>,
862 pub masked_count: usize,
864}
865
866pub fn apply_observation_masking(
874 messages: &[LlmMessage],
875 config: &ObservationMaskingConfig,
876) -> ObservationMaskingResult {
877 apply_observation_masking_with_protected(messages, config, &std::collections::HashSet::new())
878}
879
880#[derive(Debug)]
882pub struct CostControlMaskingResult {
883 pub messages: Vec<Message>,
885 pub masked_count: usize,
887 pub tool_result_bytes_before: usize,
889 pub tool_result_bytes_after: usize,
891}
892
893pub fn build_model_view_messages(
899 stored_messages: &[Message],
900 compaction_config: &CompactionConfig,
901 prior_usage: Option<&TokenUsage>,
902) -> CostControlMaskingResult {
903 apply_cost_control_masking(stored_messages, compaction_config, prior_usage)
904}
905
906pub fn build_model_view_messages_owned(
910 stored_messages: Vec<Message>,
911 compaction_config: &CompactionConfig,
912 prior_usage: Option<&TokenUsage>,
913) -> CostControlMaskingResult {
914 apply_cost_control_masking_owned(stored_messages, compaction_config, prior_usage)
915}
916
917pub fn apply_cost_control_masking(
926 messages: &[Message],
927 config: &CompactionConfig,
928 prior_usage: Option<&TokenUsage>,
929) -> CostControlMaskingResult {
930 apply_cost_control_masking_owned(messages.to_vec(), config, prior_usage)
931}
932
933fn apply_cost_control_masking_owned(
934 messages: Vec<Message>,
935 config: &CompactionConfig,
936 prior_usage: Option<&TokenUsage>,
937) -> CostControlMaskingResult {
938 let cost_config = &config.cost_control;
939 let tool_indices: Vec<usize> = messages
940 .iter()
941 .enumerate()
942 .filter(|(_, message)| {
943 message.role == MessageRole::ToolResult
944 && !is_protected_message_tool_result(&messages, message)
945 })
946 .map(|(index, _)| index)
947 .collect();
948 let tool_result_bytes_before = tool_indices
949 .iter()
950 .map(|index| message_tool_result_len(&messages[*index]))
951 .sum();
952
953 if !cost_config.enabled
954 || tool_indices.len() <= cost_config.keep_recent_tool_results
955 || !should_apply_cost_control_masking(
956 tool_indices.len(),
957 tool_result_bytes_before,
958 cost_config,
959 prior_usage,
960 )
961 {
962 return CostControlMaskingResult {
963 messages,
964 masked_count: 0,
965 tool_result_bytes_before,
966 tool_result_bytes_after: tool_result_bytes_before,
967 };
968 }
969
970 let keep_recent = cost_config.keep_recent_tool_results;
971 let to_mask_count = tool_indices.len().saturating_sub(keep_recent);
972 let related_recent_reads =
973 related_recent_paginated_read_results(&messages, &tool_indices, keep_recent);
974 let indices_to_mask: HashSet<usize> = tool_indices[..to_mask_count]
975 .iter()
976 .copied()
977 .filter(|index| !related_recent_reads.contains(index))
978 .collect();
979 let tool_names: std::collections::HashMap<usize, String> = indices_to_mask
980 .iter()
981 .map(|index| {
982 (
983 *index,
984 find_message_tool_call_name(&messages, &messages[*index]),
985 )
986 })
987 .collect();
988
989 let mut masked_count = 0;
990 let mut masked_messages = Vec::with_capacity(messages.len());
991 for (index, message) in messages.into_iter().enumerate() {
992 if let Some(tool_name) = tool_names.get(&index) {
993 masked_messages.push(mask_tool_result_message(&message, tool_name));
994 masked_count += 1;
995 } else {
996 masked_messages.push(message);
997 }
998 }
999
1000 let tool_result_bytes_after = masked_messages
1001 .iter()
1002 .filter(|message| message.role == MessageRole::ToolResult)
1003 .map(message_tool_result_len)
1004 .sum();
1005
1006 CostControlMaskingResult {
1007 messages: masked_messages,
1008 masked_count,
1009 tool_result_bytes_before,
1010 tool_result_bytes_after,
1011 }
1012}
1013
1014fn should_apply_cost_control_masking(
1015 tool_result_count: usize,
1016 tool_result_bytes: usize,
1017 config: &CostControlConfig,
1018 prior_usage: Option<&TokenUsage>,
1019) -> bool {
1020 if tool_result_count >= config.mask_after_tool_results {
1021 return true;
1022 }
1023 if tool_result_bytes >= config.max_live_tool_result_bytes {
1024 return true;
1025 }
1026 let Some(usage) = prior_usage else {
1027 return false;
1028 };
1029 let cache_read = usage.cache_read_tokens.unwrap_or(0);
1033 let cache_creation = usage.cache_creation_tokens.unwrap_or(0);
1034 if usage.input_tokens >= config.max_uncached_input_tokens {
1035 return true;
1036 }
1037 let total_prompt = usage.input_tokens + cache_read + cache_creation;
1038 total_prompt > 0 && (cache_read as f32 / total_prompt as f32) < config.min_cache_read_ratio
1039}
1040
1041fn is_protected_message_tool_result(messages: &[Message], tool_msg: &Message) -> bool {
1042 if tool_msg.role != MessageRole::ToolResult {
1043 return false;
1044 }
1045 let tool_name = find_message_tool_call_name(messages, tool_msg);
1046 PROTECTED_TOOL_NAMES.contains(&tool_name.as_str())
1047}
1048
1049#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1050struct ReadResultKey {
1051 tool_name: String,
1052 path: String,
1053 content_hash: String,
1054}
1055
1056fn related_recent_paginated_read_results(
1057 messages: &[Message],
1058 tool_indices: &[usize],
1059 keep_recent: usize,
1060) -> HashSet<usize> {
1061 if keep_recent == 0 || tool_indices.len() <= keep_recent {
1062 return HashSet::new();
1063 }
1064
1065 let keep_start = tool_indices.len().saturating_sub(keep_recent);
1066 let recent_keys: HashSet<ReadResultKey> = tool_indices[keep_start..]
1067 .iter()
1068 .filter_map(|index| paginated_read_result_key(messages, &messages[*index]))
1069 .collect();
1070 if recent_keys.is_empty() {
1071 return HashSet::new();
1072 }
1073
1074 let mut protected = HashSet::new();
1075 for index in tool_indices[..keep_start].iter().rev() {
1076 let Some(key) = paginated_read_result_key(messages, &messages[*index]) else {
1077 break;
1078 };
1079 if !recent_keys.contains(&key) {
1080 break;
1081 }
1082 protected.insert(*index);
1083 if protected.len() >= MAX_RELATED_RECENT_READ_RESULTS {
1084 break;
1085 }
1086 }
1087 protected
1088}
1089
1090fn paginated_read_result_key(messages: &[Message], tool_msg: &Message) -> Option<ReadResultKey> {
1091 let tool_name = find_message_tool_call_name(messages, tool_msg);
1092 if !is_read_file_tool_name(&tool_name) {
1093 return None;
1094 }
1095 let value = tool_msg.tool_result_content()?.result.as_ref()?;
1096 let object = value.as_object()?;
1097 object.get("lines_shown").and_then(|v| v.as_object())?;
1098 Some(ReadResultKey {
1099 tool_name,
1100 path: object.get("path")?.as_str()?.to_string(),
1101 content_hash: object.get("content_hash")?.as_str()?.to_string(),
1102 })
1103}
1104
1105fn is_read_file_tool_name(tool_name: &str) -> bool {
1106 matches!(
1107 tool_name,
1108 "read_file"
1109 | "daytona_read_file"
1110 | "sandbox_read_file"
1111 | "e2b_read_file"
1112 | "docker_read_file"
1113 | "deno_read_file"
1114 | "sprites_read_file"
1115 | "read_github_file"
1116 )
1117}
1118
1119fn find_message_tool_call_name(messages: &[Message], tool_msg: &Message) -> String {
1120 let Some(call_id) = tool_msg.tool_call_id() else {
1121 return "unknown_tool".to_string();
1122 };
1123
1124 for msg in messages.iter().rev() {
1125 if msg.role != MessageRole::Agent {
1126 continue;
1127 }
1128 for tool_call in msg.tool_calls() {
1129 if tool_call.id == call_id {
1130 return tool_call.name.clone();
1131 }
1132 }
1133 }
1134
1135 "unknown_tool".to_string()
1136}
1137
1138fn message_tool_result_len(message: &Message) -> usize {
1139 let Some(result) = message.tool_result_content() else {
1140 return 0;
1141 };
1142 result
1143 .result
1144 .as_ref()
1145 .map(estimate_json_value_len)
1146 .unwrap_or(0)
1147 + result.error.as_ref().map_or(0, String::len)
1148}
1149
1150fn mask_tool_result_message(message: &Message, tool_name: &str) -> Message {
1151 let Some(result) = message.tool_result_content() else {
1152 return message.clone();
1153 };
1154 let summary = summarize_tool_result(tool_name, result.result.as_ref(), result.error.as_ref());
1155 let was_error = result.error.is_some();
1156 let mut masked = message.clone();
1157 for part in &mut masked.content {
1158 if let ContentPart::ToolResult(tool_result) = part {
1159 if was_error {
1160 tool_result.result = None;
1161 tool_result.error = Some(summary);
1162 } else {
1163 tool_result.result = Some(serde_json::json!({
1164 "masked": true,
1165 "summary": summary,
1166 }));
1167 tool_result.error = None;
1168 }
1169 break;
1170 }
1171 }
1172 masked
1173}
1174
1175fn summarize_tool_result(
1176 tool_name: &str,
1177 result: Option<&serde_json::Value>,
1178 error: Option<&String>,
1179) -> String {
1180 if let Some(error) = error {
1181 return format!("[{tool_name} error: {}]", truncate_inline(error, 160));
1182 }
1183 let Some(value) = result else {
1184 return format!("[{tool_name} returned no result]");
1185 };
1186 let Some(object) = value.as_object() else {
1187 return format!(
1188 "[{tool_name} -> {}, {} bytes]",
1189 value_kind(value),
1190 estimate_json_value_len(value)
1191 );
1192 };
1193
1194 match tool_name {
1195 tool_name if is_read_file_tool_name(tool_name) => {
1196 summarize_read_file_result(tool_name, object, value)
1197 }
1198 "bash" | "daytona_exec" | "sandbox_exec" | "e2b_exec" | "docker_exec" | "deno_exec" => {
1199 summarize_exec_result(tool_name, object, value)
1200 }
1201 "list_directory" => summarize_list_directory_result(tool_name, object, value),
1202 "grep_files" => summarize_grep_files_result(tool_name, object, value),
1203 _ => summarize_generic_tool_result(tool_name, object, value),
1204 }
1205}
1206
1207fn summarize_read_file_result(
1208 tool_name: &str,
1209 object: &serde_json::Map<String, serde_json::Value>,
1210 value: &serde_json::Value,
1211) -> String {
1212 let path = object
1213 .get("path")
1214 .and_then(|v| v.as_str())
1215 .unwrap_or("(unknown path)");
1216 let lines = object.get("lines_shown").and_then(|v| v.as_object());
1217 let line_range = lines
1218 .and_then(|lines| {
1219 let start = lines.get("start")?.as_u64()?;
1220 let end = lines.get("end")?.as_u64()?;
1221 Some(format!(" lines {start}-{end}"))
1222 })
1223 .unwrap_or_default();
1224 let total_lines = object
1225 .get("total_lines")
1226 .and_then(|v| v.as_u64())
1227 .map(|lines| format!(", total_lines={lines}"))
1228 .unwrap_or_default();
1229 let next_offset = object
1230 .get("truncation")
1231 .and_then(|v| v.as_object())
1232 .and_then(|truncation| truncation.get("next_offset"))
1233 .and_then(|v| v.as_u64())
1234 .map(|offset| format!(", next_offset={offset}"))
1235 .unwrap_or_default();
1236 let hash = object
1237 .get("content_hash")
1238 .and_then(|v| v.as_str())
1239 .map(|hash| format!(", hash={hash}"))
1240 .unwrap_or_default();
1241 let truncated = object
1242 .get("truncated")
1243 .and_then(|v| v.as_bool())
1244 .unwrap_or(false);
1245
1246 format!(
1247 "[{tool_name} {path}{line_range}, {} bytes, truncated={truncated}{total_lines}{next_offset}{hash}]",
1248 estimate_json_value_len(value)
1249 )
1250}
1251
1252fn summarize_exec_result(
1253 tool_name: &str,
1254 object: &serde_json::Map<String, serde_json::Value>,
1255 value: &serde_json::Value,
1256) -> String {
1257 let exit = object
1258 .get("exit_code")
1259 .and_then(|v| v.as_i64())
1260 .map(|code| format!(" exit={code}"))
1261 .unwrap_or_default();
1262 let stdout_len = object
1263 .get("stdout")
1264 .and_then(|v| v.as_str())
1265 .map(|stdout| stdout.len())
1266 .unwrap_or(0);
1267 let stderr_len = object
1268 .get("stderr")
1269 .and_then(|v| v.as_str())
1270 .map(|stderr| stderr.len())
1271 .unwrap_or(0);
1272 let full_output = object
1273 .get("full_output")
1274 .and_then(|v| v.as_str())
1275 .map(|path| format!(", full_output={path}"))
1276 .unwrap_or_default();
1277 let total_lines = object
1278 .get("total_lines")
1279 .and_then(|v| v.as_u64())
1280 .map(|lines| format!(", total_lines={lines}"))
1281 .unwrap_or_default();
1282
1283 format!(
1284 "[{tool_name}{exit}, stdout={} bytes, stderr={} bytes, result={} bytes{full_output}{total_lines}]",
1285 stdout_len,
1286 stderr_len,
1287 estimate_json_value_len(value)
1288 )
1289}
1290
1291fn summarize_list_directory_result(
1292 tool_name: &str,
1293 object: &serde_json::Map<String, serde_json::Value>,
1294 value: &serde_json::Value,
1295) -> String {
1296 let path = object
1297 .get("path")
1298 .and_then(|v| v.as_str())
1299 .unwrap_or("(unknown path)");
1300 let count = object
1301 .get("count")
1302 .and_then(|v| v.as_u64())
1303 .or_else(|| {
1304 object
1305 .get("entries")
1306 .and_then(|v| v.as_array())
1307 .map(|v| v.len() as u64)
1308 })
1309 .unwrap_or(0);
1310 format!(
1311 "[{tool_name} {path}, {count} entries, {} bytes]",
1312 estimate_json_value_len(value)
1313 )
1314}
1315
1316fn summarize_grep_files_result(
1317 tool_name: &str,
1318 object: &serde_json::Map<String, serde_json::Value>,
1319 value: &serde_json::Value,
1320) -> String {
1321 let pattern = object
1322 .get("pattern")
1323 .and_then(|v| v.as_str())
1324 .map(|pattern| format!(" pattern={:?}", truncate_inline(pattern, 80)))
1325 .unwrap_or_default();
1326 let match_count = object
1327 .get("match_count")
1328 .and_then(|v| v.as_u64())
1329 .unwrap_or(0);
1330 format!(
1331 "[{tool_name}{pattern}, matches={match_count}, {} bytes]",
1332 estimate_json_value_len(value)
1333 )
1334}
1335
1336fn summarize_generic_tool_result(
1337 tool_name: &str,
1338 object: &serde_json::Map<String, serde_json::Value>,
1339 value: &serde_json::Value,
1340) -> String {
1341 let keys = object.keys().take(5).cloned().collect::<Vec<_>>().join(",");
1342 format!(
1343 "[{tool_name} result, {} bytes, keys={keys}]",
1344 estimate_json_value_len(value)
1345 )
1346}
1347
1348fn value_kind(value: &serde_json::Value) -> &'static str {
1349 match value {
1350 serde_json::Value::Null => "null",
1351 serde_json::Value::Bool(_) => "bool",
1352 serde_json::Value::Number(_) => "number",
1353 serde_json::Value::String(_) => "string",
1354 serde_json::Value::Array(_) => "array",
1355 serde_json::Value::Object(_) => "object",
1356 }
1357}
1358
1359fn estimate_json_value_len(value: &serde_json::Value) -> usize {
1360 let mut writer = CountingWriter::default();
1361 serde_json::to_writer(&mut writer, value)
1362 .map(|_| writer.bytes)
1363 .unwrap_or(0)
1364}
1365
1366#[derive(Default)]
1367struct CountingWriter {
1368 bytes: usize,
1369}
1370
1371impl std::io::Write for CountingWriter {
1372 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1373 self.bytes += buf.len();
1374 Ok(buf.len())
1375 }
1376
1377 fn flush(&mut self) -> std::io::Result<()> {
1378 Ok(())
1379 }
1380}
1381
1382fn truncate_inline(text: &str, max_chars: usize) -> String {
1383 if text.chars().count() <= max_chars {
1384 return text.to_string();
1385 }
1386 let mut truncated = text.chars().take(max_chars).collect::<String>();
1387 truncated.push_str("...");
1388 truncated
1389}
1390
1391fn apply_observation_masking_with_protected(
1395 messages: &[LlmMessage],
1396 config: &ObservationMaskingConfig,
1397 extra_protected_call_ids: &std::collections::HashSet<String>,
1398) -> ObservationMaskingResult {
1399 let tool_indices: Vec<usize> = messages
1401 .iter()
1402 .enumerate()
1403 .filter(|(_, m)| {
1404 m.role == LlmMessageRole::Tool
1405 && !is_protected_tool_result(messages, m)
1406 && !m
1407 .tool_call_id
1408 .as_ref()
1409 .is_some_and(|id| extra_protected_call_ids.contains(id))
1410 })
1411 .map(|(i, _)| i)
1412 .collect();
1413
1414 if tool_indices.len() <= config.keep_recent_tool_outputs {
1415 return ObservationMaskingResult {
1416 messages: messages.to_vec(),
1417 masked_count: 0,
1418 };
1419 }
1420
1421 let to_mask_count = tool_indices.len() - config.keep_recent_tool_outputs;
1422 let indices_to_mask: std::collections::HashSet<usize> =
1423 tool_indices[..to_mask_count].iter().copied().collect();
1424
1425 let mut result = Vec::with_capacity(messages.len());
1426 let mut masked_count = 0;
1427
1428 for (i, msg) in messages.iter().enumerate() {
1429 if indices_to_mask.contains(&i) {
1430 let tool_name = find_tool_call_name(messages, msg);
1431 let summary = match config.summary_format {
1432 MaskingSummaryFormat::OneLine => format_one_line_summary(&tool_name, &msg.content),
1433 MaskingSummaryFormat::HeadTail => format_head_tail_summary(&msg.content),
1434 };
1435 result.push(LlmMessage {
1436 role: LlmMessageRole::Tool,
1437 content: LlmMessageContent::Text(summary),
1438 tool_calls: msg.tool_calls.clone(),
1439 tool_call_id: msg.tool_call_id.clone(),
1440 phase: msg.phase,
1441 thinking: None,
1442 thinking_signature: None,
1443 });
1444 masked_count += 1;
1445 } else {
1446 result.push(msg.clone());
1447 }
1448 }
1449
1450 ObservationMaskingResult {
1451 messages: result,
1452 masked_count,
1453 }
1454}
1455
1456fn find_tool_call_name(messages: &[LlmMessage], tool_msg: &LlmMessage) -> String {
1458 let Some(ref call_id) = tool_msg.tool_call_id else {
1459 return "unknown_tool".to_string();
1460 };
1461
1462 for msg in messages.iter().rev() {
1463 if msg.role == LlmMessageRole::Assistant
1464 && let Some(ref tool_calls) = msg.tool_calls
1465 {
1466 for tc in tool_calls {
1467 if tc.id == *call_id {
1468 return tc.name.clone();
1469 }
1470 }
1471 }
1472 }
1473
1474 "unknown_tool".to_string()
1475}
1476
1477fn extract_text(content: &LlmMessageContent) -> String {
1478 match content {
1479 LlmMessageContent::Text(t) => t.clone(),
1480 LlmMessageContent::Parts(parts) => parts
1481 .iter()
1482 .filter_map(|p| {
1483 if let LlmContentPart::Text { text } = p {
1484 Some(text.clone())
1485 } else {
1486 None
1487 }
1488 })
1489 .collect::<Vec<_>>()
1490 .join(" "),
1491 }
1492}
1493
1494fn format_one_line_summary(tool_name: &str, content: &LlmMessageContent) -> String {
1495 let text = extract_text(content);
1496 let line_count = text.lines().count();
1497 let byte_len = text.len();
1498
1499 if byte_len <= 100 {
1500 format!("[{tool_name} → {text}]")
1501 } else {
1502 format!("[{tool_name} → {line_count} lines, {byte_len} bytes]")
1503 }
1504}
1505
1506fn format_head_tail_summary(content: &LlmMessageContent) -> String {
1507 let text = extract_text(content);
1508 let lines: Vec<&str> = text.lines().collect();
1509
1510 if lines.len() <= 6 {
1511 return text;
1512 }
1513
1514 let head: Vec<&str> = lines[..3].to_vec();
1515 let tail: Vec<&str> = lines[lines.len() - 3..].to_vec();
1516
1517 format!(
1518 "{}\n... ({} lines omitted) ...\n{}",
1519 head.join("\n"),
1520 lines.len() - 6,
1521 tail.join("\n")
1522 )
1523}
1524
1525pub fn build_summarization_prompt(config: &SummarizationConfig) -> String {
1531 let preserve_items = if config.preserve.is_empty() {
1532 default_preserve()
1533 } else {
1534 config.preserve.clone()
1535 };
1536
1537 let preserve_list = preserve_items
1538 .iter()
1539 .map(|item| format!("- {item}"))
1540 .collect::<Vec<_>>()
1541 .join("\n");
1542
1543 let custom_instructions = config
1544 .instructions
1545 .as_deref()
1546 .map(|instr| format!("\n- {instr}"))
1547 .unwrap_or_default();
1548
1549 format!(
1550 r#"<task>
1551Summarize the following conversation history. The summary replaces these
1552messages in the agent's context window — it must contain everything the
1553agent needs to continue working.
1554</task>
1555
1556<preserve>
1557{preserve_list}{custom_instructions}
1558</preserve>
1559
1560<format>
1561Produce a structured summary. Use sections. Be concise but complete.
1562Do not include tool output verbatim — reference files by path.
1563IMPORTANT: Any activate_skill tool results contain durable skill instructions.
1564Include them verbatim in a dedicated "Active Skills" section — do not summarize
1565or paraphrase skill instructions.
1566</format>"#
1567 )
1568}
1569
1570pub fn format_messages_for_summarization(messages: &[LlmMessage]) -> String {
1572 let mut parts = Vec::new();
1573 for msg in messages {
1574 let role = match msg.role {
1575 LlmMessageRole::System => "system",
1576 LlmMessageRole::User => "user",
1577 LlmMessageRole::Assistant => "assistant",
1578 LlmMessageRole::Tool => "tool",
1579 };
1580
1581 let content = extract_text(&msg.content);
1582
1583 let is_protected = is_protected_tool_result(messages, msg);
1586
1587 let truncated = if !is_protected && content.len() > 2000 {
1589 let safe_prefix = truncate_at_char_boundary(&content, 2000);
1590 format!(
1591 "{}... [truncated, {} chars total]",
1592 safe_prefix,
1593 content.len()
1594 )
1595 } else {
1596 content
1597 };
1598
1599 parts.push(format!("[{role}]: {truncated}"));
1600 }
1601 parts.join("\n\n")
1602}
1603
1604fn truncate_at_char_boundary(content: &str, max_bytes: usize) -> &str {
1605 if content.len() <= max_bytes {
1606 return content;
1607 }
1608
1609 if content.is_char_boundary(max_bytes) {
1610 return &content[..max_bytes];
1611 }
1612
1613 let mut end = max_bytes;
1614 while end > 0 && !content.is_char_boundary(end) {
1615 end -= 1;
1616 }
1617
1618 &content[..end]
1619}
1620
1621pub fn build_summary_message(summary_text: &str) -> LlmMessage {
1623 LlmMessage {
1624 role: LlmMessageRole::System,
1625 content: LlmMessageContent::Text(format!(
1626 "[CONVERSATION_SUMMARY]\n{summary_text}\n[/CONVERSATION_SUMMARY]"
1627 )),
1628 tool_calls: None,
1629 tool_call_id: None,
1630 phase: None,
1631 thinking: None,
1632 thinking_signature: None,
1633 }
1634}
1635
1636#[derive(Debug, Clone, Serialize, Deserialize)]
1642pub struct CompactionStep {
1643 pub strategy: String,
1645 pub messages_after: usize,
1647 pub duration_ms: u64,
1649}
1650
1651#[cfg(test)]
1656mod tests {
1657 use super::*;
1658 use crate::tool_types::ToolCall;
1659 use serde_json::json;
1660
1661 fn make_user_msg(text: &str) -> LlmMessage {
1662 LlmMessage {
1663 role: LlmMessageRole::User,
1664 content: LlmMessageContent::Text(text.to_string()),
1665 tool_calls: None,
1666 tool_call_id: None,
1667 phase: None,
1668 thinking: None,
1669 thinking_signature: None,
1670 }
1671 }
1672
1673 fn make_assistant_msg(text: &str) -> LlmMessage {
1674 LlmMessage {
1675 role: LlmMessageRole::Assistant,
1676 content: LlmMessageContent::Text(text.to_string()),
1677 tool_calls: None,
1678 tool_call_id: None,
1679 phase: None,
1680 thinking: None,
1681 thinking_signature: None,
1682 }
1683 }
1684
1685 fn make_assistant_with_tool_call(call_id: &str, tool_name: &str) -> LlmMessage {
1686 LlmMessage {
1687 role: LlmMessageRole::Assistant,
1688 content: LlmMessageContent::Text(String::new()),
1689 tool_calls: Some(vec![ToolCall {
1690 id: call_id.to_string(),
1691 name: tool_name.to_string(),
1692 arguments: json!({"path": "src/main.rs"}),
1693 }]),
1694 tool_call_id: None,
1695 phase: None,
1696 thinking: None,
1697 thinking_signature: None,
1698 }
1699 }
1700
1701 fn make_tool_result(call_id: &str, output: &str) -> LlmMessage {
1702 LlmMessage {
1703 role: LlmMessageRole::Tool,
1704 content: LlmMessageContent::Text(output.to_string()),
1705 tool_calls: None,
1706 tool_call_id: Some(call_id.to_string()),
1707 phase: None,
1708 thinking: None,
1709 thinking_signature: None,
1710 }
1711 }
1712
1713 #[test]
1718 fn test_capability_metadata() {
1719 let cap = CompactionCapability;
1720 assert_eq!(cap.id(), COMPACTION_CAPABILITY_ID);
1721 assert_eq!(cap.name(), "Compaction");
1722 assert_eq!(cap.status(), CapabilityStatus::Available);
1723 assert_eq!(cap.category(), Some("Optimization"));
1724 assert!(cap.tools().is_empty());
1725 assert!(cap.message_filter_provider().is_some());
1726 }
1727
1728 #[test]
1729 fn test_config_schema_and_validate_config() {
1730 let cap = CompactionCapability;
1731
1732 let schema = cap.config_schema().expect("config schema");
1733 assert_eq!(schema["type"], "object");
1734 assert!(schema["properties"]["strategy"].is_object());
1736 assert!(schema["properties"]["proactive"].is_object());
1737 assert!(schema["properties"]["budget_percent"].is_object());
1738 assert!(schema["properties"].get("observation_masking").is_none());
1739 assert!(schema["properties"].get("cost_control").is_none());
1740
1741 assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
1743 assert!(
1744 cap.validate_config(&json!({
1745 "strategy": "native",
1746 "proactive": false,
1747 "budget_percent": 0.9
1748 }))
1749 .is_ok()
1750 );
1751 assert!(
1753 cap.validate_config(&json!({
1754 "strategy": "observation_masking",
1755 "observation_masking": { "keep_recent_tool_outputs": 4 },
1756 "cost_control": { "enabled": false }
1757 }))
1758 .is_ok()
1759 );
1760
1761 assert!(cap.validate_config(&json!({"strategy": "bogus"})).is_err());
1763 let err = cap
1764 .validate_config(&json!({"budget_percent": 5.0}))
1765 .unwrap_err();
1766 assert!(err.contains("budget_percent"));
1767 }
1768
1769 #[test]
1770 fn test_localizations_resolve_uk() {
1771 let cap = CompactionCapability;
1772 assert_eq!(cap.localized_name(Some("uk-UA")), "Ущільнення контексту");
1773 assert!(cap.describe_schema(None).is_some());
1774 }
1775
1776 #[test]
1777 fn test_default_config() {
1778 let config = CompactionConfig::default();
1779 assert_eq!(config.strategy, CompactionStrategy::Auto);
1780 assert!(config.proactive);
1781 assert!((config.budget_percent - 0.85).abs() < f32::EPSILON);
1782 assert_eq!(config.observation_masking.keep_recent_tool_outputs, 2);
1783 assert_eq!(
1784 config.observation_masking.summary_format,
1785 MaskingSummaryFormat::OneLine
1786 );
1787 assert!(config.summarization.model.is_none());
1788 assert_eq!(config.summarization.preserve.len(), 5);
1789 assert!(config.summarization.instructions.is_none());
1790 assert!(config.cost_control.enabled);
1791 assert_eq!(config.cost_control.keep_recent_tool_results, 2);
1792 }
1793
1794 #[test]
1795 fn test_config_from_empty_json() {
1796 let config = CompactionConfig::from_json(&json!({}));
1797 assert_eq!(config.strategy, CompactionStrategy::Auto);
1798 assert!(config.proactive);
1799 }
1800
1801 #[test]
1802 fn test_config_native_only() {
1803 let config = CompactionConfig::from_json(&json!({"strategy": "native"}));
1804 assert_eq!(config.strategy, CompactionStrategy::Native);
1805 assert!(config.proactive);
1806 }
1807
1808 #[test]
1809 fn test_config_observation_masking_with_custom_settings() {
1810 let config = CompactionConfig::from_json(&json!({
1811 "strategy": "observation_masking",
1812 "proactive": false,
1813 "observation_masking": {
1814 "keep_recent_tool_outputs": 10,
1815 "summary_format": "head_tail"
1816 }
1817 }));
1818 assert_eq!(config.strategy, CompactionStrategy::ObservationMasking);
1819 assert!(!config.proactive);
1820 assert_eq!(config.observation_masking.keep_recent_tool_outputs, 10);
1821 assert_eq!(
1822 config.observation_masking.summary_format,
1823 MaskingSummaryFormat::HeadTail
1824 );
1825 }
1826
1827 #[test]
1828 fn test_config_cost_control_with_custom_settings() {
1829 let config = CompactionConfig::from_json(&json!({
1830 "cost_control": {
1831 "enabled": true,
1832 "keep_recent_tool_results": 1,
1833 "mask_after_tool_results": 2,
1834 "max_live_tool_result_bytes": 4096,
1835 "max_uncached_input_tokens": 50000,
1836 "min_cache_read_ratio": 0.5
1837 }
1838 }));
1839
1840 assert!(config.cost_control.enabled);
1841 assert_eq!(config.cost_control.keep_recent_tool_results, 1);
1842 assert_eq!(config.cost_control.mask_after_tool_results, 2);
1843 assert_eq!(config.cost_control.max_live_tool_result_bytes, 4096);
1844 assert_eq!(config.cost_control.max_uncached_input_tokens, 50000);
1845 assert!((config.cost_control.min_cache_read_ratio - 0.5).abs() < f32::EPSILON);
1846 }
1847
1848 #[test]
1849 fn test_config_summarization_with_custom_model() {
1850 let config = CompactionConfig::from_json(&json!({
1851 "strategy": "summarization",
1852 "summarization": {
1853 "model": "claude-haiku-4-5-20251001",
1854 "instructions": "Focus on API decisions",
1855 "preserve": ["decisions", "errors"]
1856 }
1857 }));
1858 assert_eq!(config.strategy, CompactionStrategy::Summarization);
1859 assert_eq!(
1860 config.summarization.model.as_deref(),
1861 Some("claude-haiku-4-5-20251001")
1862 );
1863 assert_eq!(
1864 config.summarization.instructions.as_deref(),
1865 Some("Focus on API decisions")
1866 );
1867 assert_eq!(config.summarization.preserve.len(), 2);
1868 }
1869
1870 fn make_message_tool_turn(
1871 call_id: &str,
1872 tool_name: &str,
1873 result: serde_json::Value,
1874 ) -> Vec<Message> {
1875 vec![
1876 Message::assistant_with_tools(
1877 "",
1878 vec![ToolCall {
1879 id: call_id.to_string(),
1880 name: tool_name.to_string(),
1881 arguments: json!({"path": "/workspace/src/lib.rs"}),
1882 }],
1883 ),
1884 Message::tool_result(call_id, Some(result), None),
1885 ]
1886 }
1887
1888 #[test]
1889 fn test_cost_control_masks_old_read_file_results() {
1890 let mut messages = vec![Message::user("inspect files")];
1891 for index in 0..5 {
1892 messages.extend(make_message_tool_turn(
1893 &format!("call_{index}"),
1894 "read_file",
1895 json!({
1896 "path": "/workspace/src/lib.rs",
1897 "content": format!("{}{}", "line\n".repeat(400), index),
1898 "total_lines": 900,
1899 "lines_shown": {"start": 1, "end": 400},
1900 "truncated": true,
1901 "content_hash": format!("sha256:{index}"),
1902 "truncation": {"truncated": true, "next_offset": 400, "reason": "line_cap"}
1903 }),
1904 ));
1905 }
1906
1907 let config = CompactionConfig::from_json(&json!({
1908 "cost_control": {
1909 "keep_recent_tool_results": 2,
1910 "mask_after_tool_results": 4
1911 }
1912 }));
1913 let result = apply_cost_control_masking(&messages, &config, None);
1914
1915 assert_eq!(result.masked_count, 3);
1916 assert!(result.tool_result_bytes_after < result.tool_result_bytes_before);
1917
1918 let first_tool = result.messages[2].tool_result_content().unwrap();
1919 let masked = first_tool.result.as_ref().unwrap();
1920 assert_eq!(masked["masked"], true);
1921 let summary = masked["summary"].as_str().unwrap();
1922 assert!(summary.contains("read_file"));
1923 assert!(summary.contains("/workspace/src/lib.rs"));
1924 assert!(summary.contains("lines 1-400"));
1925 assert!(summary.contains("next_offset=400"));
1926 assert!(!summary.contains("line\nline"));
1927
1928 let last_tool = result
1929 .messages
1930 .last()
1931 .unwrap()
1932 .tool_result_content()
1933 .unwrap();
1934 assert!(last_tool.result.as_ref().unwrap().get("content").is_some());
1935 }
1936
1937 #[test]
1938 fn test_cost_control_keeps_recent_paginated_read_group() {
1939 let mut messages = vec![Message::user("inspect saved output")];
1940 messages.extend(make_message_tool_turn(
1941 "call_bash",
1942 "bash",
1943 json!({
1944 "stdout": "old command output",
1945 "stderr": "",
1946 "exit_code": 0,
1947 "success": true
1948 }),
1949 ));
1950 messages.extend(make_message_tool_turn(
1951 "call_read_first",
1952 "read_file",
1953 json!({
1954 "path": "/workspace/outputs/call_123.stdout",
1955 "content": "first page\n".repeat(200),
1956 "total_lines": 400,
1957 "lines_shown": {"start": 1, "end": 200},
1958 "truncated": true,
1959 "content_hash": "sha256:same-output",
1960 "truncation": {"truncated": true, "next_offset": 200, "reason": "line_cap"}
1961 }),
1962 ));
1963 messages.extend(make_message_tool_turn(
1964 "call_read_second",
1965 "read_file",
1966 json!({
1967 "path": "/workspace/outputs/call_123.stdout",
1968 "content": "second page\n".repeat(200),
1969 "total_lines": 400,
1970 "lines_shown": {"start": 201, "end": 400},
1971 "truncated": false,
1972 "content_hash": "sha256:same-output"
1973 }),
1974 ));
1975
1976 let config = CompactionConfig::from_json(&json!({
1977 "cost_control": {
1978 "keep_recent_tool_results": 1,
1979 "mask_after_tool_results": 2
1980 }
1981 }));
1982 let result = build_model_view_messages(&messages, &config, None);
1983
1984 assert_eq!(result.masked_count, 1);
1985 let bash_result = result.messages[2].tool_result_content().unwrap();
1986 assert_eq!(bash_result.result.as_ref().unwrap()["masked"], true);
1987 let first_page = result.messages[4].tool_result_content().unwrap();
1988 assert!(first_page.result.as_ref().unwrap().get("content").is_some());
1989 let second_page = result.messages[6].tool_result_content().unwrap();
1990 assert!(
1991 second_page
1992 .result
1993 .as_ref()
1994 .unwrap()
1995 .get("content")
1996 .is_some()
1997 );
1998 }
1999
2000 #[test]
2001 fn test_model_view_masks_with_compaction_config() {
2002 let mut messages = vec![Message::user("inspect files repeatedly")];
2003 for index in 0..9 {
2004 messages.extend(make_message_tool_turn(
2005 &format!("call_{index}"),
2006 "read_file",
2007 json!({
2008 "path": "/workspace/session_019e4c9dd1b17021af70ad3227361b16.jsonl",
2009 "content": format!("{}{}", "large transcript line\n".repeat(1000), index),
2010 "total_lines": 1000,
2011 "lines_shown": {"start": 1, "end": 1000},
2012 "truncated": false,
2013 "content_hash": format!("sha256:{index}")
2014 }),
2015 ));
2016 }
2017
2018 let config = CompactionConfig::default();
2019 let result = build_model_view_messages(&messages, &config, None);
2020
2021 assert_eq!(result.masked_count, 7);
2022 assert!(result.tool_result_bytes_after < result.tool_result_bytes_before / 4);
2023 let first_tool = result.messages[2].tool_result_content().unwrap();
2024 let masked = first_tool.result.as_ref().unwrap();
2025 assert_eq!(masked["masked"], true);
2026 assert!(masked["summary"].as_str().unwrap().contains("read_file"));
2027 let last_tool = result
2028 .messages
2029 .last()
2030 .unwrap()
2031 .tool_result_content()
2032 .unwrap();
2033 assert!(last_tool.result.as_ref().unwrap().get("content").is_some());
2034 }
2035
2036 #[test]
2037 fn test_compaction_capability_contributes_model_view_provider() {
2038 let mut messages = vec![Message::user("inspect files repeatedly")];
2039 for index in 0..9 {
2040 messages.extend(make_message_tool_turn(
2041 &format!("call_{index}"),
2042 "read_file",
2043 json!({
2044 "path": "/workspace/src/lib.rs",
2045 "content": format!("{}{}", "large file line\n".repeat(1000), index),
2046 "total_lines": 1000,
2047 "lines_shown": {"start": 1, "end": 1000},
2048 "truncated": false
2049 }),
2050 ));
2051 }
2052
2053 let capability = CompactionCapability;
2054 let provider = capability.model_view_provider().unwrap();
2055 let context = ModelViewContext {
2056 session_id: crate::typed_id::SessionId::new(),
2057 prior_usage: None,
2058 };
2059 let result = provider.apply_model_view(messages, &json!({}), &context);
2060
2061 let first_tool = result[2].tool_result_content().unwrap();
2062 assert_eq!(first_tool.result.as_ref().unwrap()["masked"], true);
2063 let last_tool = result.last().unwrap().tool_result_content().unwrap();
2064 assert!(last_tool.result.as_ref().unwrap().get("content").is_some());
2065 }
2066
2067 #[test]
2068 fn test_model_view_respects_disabled_cost_control_config() {
2069 let mut messages = vec![Message::user("inspect files repeatedly")];
2070 for index in 0..5 {
2071 messages.extend(make_message_tool_turn(
2072 &format!("call_{index}"),
2073 "read_file",
2074 json!({
2075 "path": "/workspace/src/lib.rs",
2076 "content": "line\n".repeat(400),
2077 "total_lines": 400,
2078 "lines_shown": {"start": 1, "end": 400},
2079 "truncated": false
2080 }),
2081 ));
2082 }
2083
2084 let config = CompactionConfig::from_json(&json!({
2085 "cost_control": {
2086 "enabled": false,
2087 "keep_recent_tool_results": 1,
2088 "mask_after_tool_results": 2
2089 }
2090 }));
2091 let result = build_model_view_messages(&messages, &config, None);
2092
2093 assert_eq!(result.masked_count, 0);
2094 assert_eq!(
2095 result.tool_result_bytes_after,
2096 result.tool_result_bytes_before
2097 );
2098 }
2099
2100 #[test]
2101 fn test_cost_control_uses_prior_usage_signal() {
2102 let mut messages = vec![Message::user("run commands")];
2103 for index in 0..3 {
2104 messages.extend(make_message_tool_turn(
2105 &format!("call_{index}"),
2106 "bash",
2107 json!({
2108 "stdout": "small output",
2109 "stderr": "",
2110 "exit_code": 0,
2111 "success": true
2112 }),
2113 ));
2114 }
2115
2116 let config = CompactionConfig::from_json(&json!({
2117 "cost_control": {
2118 "keep_recent_tool_results": 1,
2119 "mask_after_tool_results": 99,
2120 "max_live_tool_result_bytes": 999999,
2121 "max_uncached_input_tokens": 1000
2122 }
2123 }));
2124 let usage = TokenUsage::with_cache(10_000, 100, Some(0), None);
2125 let result = apply_cost_control_masking(&messages, &config, Some(&usage));
2126
2127 assert_eq!(result.masked_count, 2);
2128 let first_tool = result.messages[2].tool_result_content().unwrap();
2129 let summary = first_tool.result.as_ref().unwrap()["summary"]
2130 .as_str()
2131 .unwrap();
2132 assert!(summary.contains("bash exit=0"));
2133 }
2134
2135 #[test]
2136 fn test_cost_control_masking_uses_disjoint_cache_buckets() {
2137 let config = CostControlConfig {
2140 mask_after_tool_results: usize::MAX,
2141 max_live_tool_result_bytes: usize::MAX,
2142 max_uncached_input_tokens: 50_000,
2143 min_cache_read_ratio: 0.35,
2144 ..CostControlConfig::default()
2145 };
2146
2147 let well_cached = TokenUsage::with_cache(20_000, 100, Some(180_000), None);
2150 assert!(!should_apply_cost_control_masking(
2151 0,
2152 0,
2153 &config,
2154 Some(&well_cached)
2155 ));
2156
2157 let cache_poor = TokenUsage::with_cache(20_000, 100, Some(2_000), None);
2159 assert!(should_apply_cost_control_masking(
2160 0,
2161 0,
2162 &config,
2163 Some(&cache_poor)
2164 ));
2165
2166 let heavy_uncached = TokenUsage::with_cache(60_000, 100, Some(200_000), None);
2168 assert!(should_apply_cost_control_masking(
2169 0,
2170 0,
2171 &config,
2172 Some(&heavy_uncached)
2173 ));
2174 }
2175
2176 #[test]
2177 fn test_model_view_uses_provider_cache_signal_from_compaction_config() {
2178 let mut messages = vec![Message::user("run commands")];
2179 for index in 0..3 {
2180 messages.extend(make_message_tool_turn(
2181 &format!("call_{index}"),
2182 "bash",
2183 json!({
2184 "stdout": "small output",
2185 "stderr": "",
2186 "exit_code": 0,
2187 "success": true
2188 }),
2189 ));
2190 }
2191 let usage = TokenUsage::with_cache(150_000, 100, Some(0), None);
2192
2193 let config = CompactionConfig::default();
2194 let result = build_model_view_messages(&messages, &config, Some(&usage));
2195
2196 assert_eq!(result.masked_count, 1);
2197 let first_tool = result.messages[2].tool_result_content().unwrap();
2198 assert_eq!(first_tool.result.as_ref().unwrap()["masked"], true);
2199 }
2200
2201 #[test]
2202 fn test_config_falls_back_to_defaults_for_invalid_json() {
2203 let config = CompactionConfig::from_json(&json!({
2204 "strategy": "nonexistent_strategy",
2205 "budget_percent": "not-a-number"
2206 }));
2207 assert_eq!(config.strategy, CompactionStrategy::Auto);
2208 assert!(config.proactive);
2209 }
2210
2211 #[test]
2212 fn test_config_partial_override() {
2213 let config = CompactionConfig::from_json(&json!({
2214 "budget_percent": 0.7,
2215 "observation_masking": {
2216 "keep_recent_tool_outputs": 3
2217 }
2218 }));
2219 assert_eq!(config.strategy, CompactionStrategy::Auto);
2220 assert!(config.proactive);
2221 assert!((config.budget_percent - 0.7).abs() < f32::EPSILON);
2222 assert_eq!(config.observation_masking.keep_recent_tool_outputs, 3);
2223 assert_eq!(
2224 config.observation_masking.summary_format,
2225 MaskingSummaryFormat::OneLine
2226 );
2227 }
2228
2229 #[test]
2230 fn test_strategy_serialization_roundtrip() {
2231 for strategy in [
2232 CompactionStrategy::Auto,
2233 CompactionStrategy::Native,
2234 CompactionStrategy::ObservationMasking,
2235 CompactionStrategy::Summarization,
2236 ] {
2237 let json = serde_json::to_value(strategy).unwrap();
2238 let deserialized: CompactionStrategy = serde_json::from_value(json).unwrap();
2239 assert_eq!(strategy, deserialized);
2240 }
2241 }
2242
2243 #[test]
2244 fn test_strategy_display() {
2245 assert_eq!(CompactionStrategy::Auto.to_string(), "auto");
2246 assert_eq!(CompactionStrategy::Native.to_string(), "native");
2247 assert_eq!(
2248 CompactionStrategy::ObservationMasking.to_string(),
2249 "observation_masking"
2250 );
2251 assert_eq!(
2252 CompactionStrategy::Summarization.to_string(),
2253 "summarization"
2254 );
2255 }
2256
2257 #[test]
2258 fn test_masking_format_serialization_roundtrip() {
2259 for format in [
2260 MaskingSummaryFormat::OneLine,
2261 MaskingSummaryFormat::HeadTail,
2262 ] {
2263 let json = serde_json::to_value(format).unwrap();
2264 let deserialized: MaskingSummaryFormat = serde_json::from_value(json).unwrap();
2265 assert_eq!(format, deserialized);
2266 }
2267 }
2268
2269 #[test]
2270 fn test_budget_percent_boundary_values() {
2271 let config = CompactionConfig::from_json(&json!({"budget_percent": 0.1}));
2272 assert!((config.budget_percent - 0.1).abs() < f32::EPSILON);
2273
2274 let config = CompactionConfig::from_json(&json!({"budget_percent": 0.99}));
2275 assert!((config.budget_percent - 0.99).abs() < f32::EPSILON);
2276 }
2277
2278 #[test]
2279 fn test_keep_recent_tool_outputs_zero() {
2280 let config = CompactionConfig::from_json(&json!({
2281 "observation_masking": {"keep_recent_tool_outputs": 0}
2282 }));
2283 assert_eq!(config.observation_masking.keep_recent_tool_outputs, 0);
2284 }
2285
2286 #[test]
2291 fn test_masking_no_tool_messages() {
2292 let messages = vec![make_user_msg("hello"), make_assistant_msg("hi")];
2293 let config = ObservationMaskingConfig::default();
2294 let result = apply_observation_masking(&messages, &config);
2295 assert_eq!(result.masked_count, 0);
2296 assert_eq!(result.messages.len(), 2);
2297 }
2298
2299 #[test]
2300 fn test_masking_fewer_than_keep_recent() {
2301 let messages = vec![
2302 make_user_msg("read file"),
2303 make_assistant_with_tool_call("call_1", "read_file"),
2304 make_tool_result("call_1", "file contents"),
2305 make_assistant_msg("done"),
2306 ];
2307 let config = ObservationMaskingConfig {
2308 keep_recent_tool_outputs: 5,
2309 summary_format: MaskingSummaryFormat::OneLine,
2310 };
2311 let result = apply_observation_masking(&messages, &config);
2312 assert_eq!(result.masked_count, 0);
2313 }
2314
2315 #[test]
2316 fn test_masking_masks_old_outputs() {
2317 let messages = vec![
2318 make_user_msg("start"),
2319 make_assistant_with_tool_call("call_1", "read_file"),
2320 make_tool_result(
2321 "call_1",
2322 "old file contents that are very long and should be masked by the observation masking strategy because it exceeds 100 chars",
2323 ),
2324 make_assistant_msg("got it"),
2325 make_user_msg("next"),
2326 make_assistant_with_tool_call("call_2", "search"),
2327 make_tool_result("call_2", "search results"),
2328 make_assistant_msg("found it"),
2329 make_user_msg("more"),
2330 make_assistant_with_tool_call("call_3", "bash"),
2331 make_tool_result("call_3", "command output"),
2332 ];
2333
2334 let config = ObservationMaskingConfig {
2335 keep_recent_tool_outputs: 2,
2336 summary_format: MaskingSummaryFormat::OneLine,
2337 };
2338 let result = apply_observation_masking(&messages, &config);
2339
2340 assert_eq!(result.masked_count, 1);
2341
2342 let masked = &result.messages[2];
2344 assert_eq!(masked.role, LlmMessageRole::Tool);
2345 let text = extract_text(&masked.content);
2346 assert!(
2347 text.starts_with('['),
2348 "Expected masked summary, got: {text}"
2349 );
2350 assert!(text.contains("read_file"), "Expected tool name: {text}");
2351
2352 assert_eq!(extract_text(&result.messages[6].content), "search results");
2354 assert_eq!(extract_text(&result.messages[10].content), "command output");
2355 }
2356
2357 #[test]
2358 fn test_masking_preserves_tool_call_id() {
2359 let messages = vec![
2360 make_assistant_with_tool_call("call_1", "read_file"),
2361 make_tool_result("call_1", "content"),
2362 make_assistant_with_tool_call("call_2", "bash"),
2363 make_tool_result("call_2", "output"),
2364 ];
2365
2366 let config = ObservationMaskingConfig {
2367 keep_recent_tool_outputs: 1,
2368 summary_format: MaskingSummaryFormat::OneLine,
2369 };
2370 let result = apply_observation_masking(&messages, &config);
2371 assert_eq!(result.messages[1].tool_call_id, Some("call_1".to_string()));
2372 }
2373
2374 #[test]
2375 fn test_masking_head_tail_format() {
2376 let long_output = (0..20)
2377 .map(|i| format!("line {i}"))
2378 .collect::<Vec<_>>()
2379 .join("\n");
2380
2381 let messages = vec![
2382 make_assistant_with_tool_call("call_1", "bash"),
2383 make_tool_result("call_1", &long_output),
2384 make_assistant_with_tool_call("call_2", "bash"),
2385 make_tool_result("call_2", "recent output"),
2386 ];
2387
2388 let config = ObservationMaskingConfig {
2389 keep_recent_tool_outputs: 1,
2390 summary_format: MaskingSummaryFormat::HeadTail,
2391 };
2392 let result = apply_observation_masking(&messages, &config);
2393
2394 let text = extract_text(&result.messages[1].content);
2395 assert!(text.contains("line 0"), "Should contain first lines");
2396 assert!(text.contains("line 19"), "Should contain last lines");
2397 assert!(text.contains("lines omitted"), "Should indicate omissions");
2398 }
2399
2400 #[test]
2401 fn test_masking_short_output_inline() {
2402 let messages = vec![
2403 make_assistant_with_tool_call("call_1", "get_time"),
2404 make_tool_result("call_1", "2024-01-01"),
2405 make_assistant_with_tool_call("call_2", "bash"),
2406 make_tool_result("call_2", "ok"),
2407 ];
2408
2409 let config = ObservationMaskingConfig {
2410 keep_recent_tool_outputs: 1,
2411 summary_format: MaskingSummaryFormat::OneLine,
2412 };
2413 let result = apply_observation_masking(&messages, &config);
2414 let text = extract_text(&result.messages[1].content);
2415 assert!(text.contains("2024-01-01"), "Short output included: {text}");
2416 }
2417
2418 #[test]
2419 fn test_masking_all_when_keep_zero() {
2420 let messages = vec![
2421 make_assistant_with_tool_call("call_1", "a"),
2422 make_tool_result("call_1", "output1"),
2423 make_assistant_with_tool_call("call_2", "b"),
2424 make_tool_result("call_2", "output2"),
2425 ];
2426
2427 let config = ObservationMaskingConfig {
2428 keep_recent_tool_outputs: 0,
2429 summary_format: MaskingSummaryFormat::OneLine,
2430 };
2431 let result = apply_observation_masking(&messages, &config);
2432 assert_eq!(result.masked_count, 2);
2433 }
2434
2435 #[test]
2436 fn test_masking_empty_messages() {
2437 let result = apply_observation_masking(&[], &ObservationMaskingConfig::default());
2438 assert_eq!(result.masked_count, 0);
2439 assert!(result.messages.is_empty());
2440 }
2441
2442 #[test]
2443 fn test_masking_preserves_message_count() {
2444 let messages = vec![
2445 make_user_msg("start"),
2446 make_assistant_with_tool_call("c1", "read_file"),
2447 make_tool_result("c1", "content 1"),
2448 make_assistant_msg("ok"),
2449 make_user_msg("next"),
2450 make_assistant_with_tool_call("c2", "bash"),
2451 make_tool_result("c2", "content 2"),
2452 make_assistant_msg("done"),
2453 ];
2454
2455 let config = ObservationMaskingConfig {
2456 keep_recent_tool_outputs: 1,
2457 summary_format: MaskingSummaryFormat::OneLine,
2458 };
2459 let result = apply_observation_masking(&messages, &config);
2460 assert_eq!(result.messages.len(), messages.len());
2461 }
2462
2463 #[test]
2464 fn test_masking_unknown_tool_call_id() {
2465 let messages = vec![
2466 make_tool_result("orphan", "some output"),
2467 make_assistant_with_tool_call("call_2", "bash"),
2468 make_tool_result("call_2", "recent"),
2469 ];
2470
2471 let config = ObservationMaskingConfig {
2472 keep_recent_tool_outputs: 1,
2473 summary_format: MaskingSummaryFormat::OneLine,
2474 };
2475 let result = apply_observation_masking(&messages, &config);
2476 assert_eq!(result.masked_count, 1);
2477 let text = extract_text(&result.messages[0].content);
2478 assert!(text.contains("unknown_tool"), "Fallback name: {text}");
2479 }
2480
2481 #[test]
2482 fn test_masking_many_tool_calls_keeps_exactly_n() {
2483 let mut messages = Vec::new();
2484 for i in 0..10 {
2485 let id = format!("call_{i}");
2486 messages.push(make_assistant_with_tool_call(&id, &format!("tool_{i}")));
2487 messages.push(make_tool_result(&id, &format!("output {i}")));
2488 }
2489
2490 let config = ObservationMaskingConfig {
2491 keep_recent_tool_outputs: 3,
2492 summary_format: MaskingSummaryFormat::OneLine,
2493 };
2494 let result = apply_observation_masking(&messages, &config);
2495 assert_eq!(result.masked_count, 7);
2496
2497 assert_eq!(extract_text(&result.messages[15].content), "output 7");
2499 assert_eq!(extract_text(&result.messages[17].content), "output 8");
2500 assert_eq!(extract_text(&result.messages[19].content), "output 9");
2501 }
2502
2503 #[test]
2508 fn test_summarization_prompt_default() {
2509 let config = SummarizationConfig::default();
2510 let prompt = build_summarization_prompt(&config);
2511 assert!(prompt.contains("<task>"));
2512 assert!(prompt.contains("decisions"));
2513 assert!(prompt.contains("files_modified"));
2514 assert!(prompt.contains("errors"));
2515 assert!(prompt.contains("current_plan"));
2516 }
2517
2518 #[test]
2519 fn test_summarization_prompt_custom_instructions() {
2520 let config = SummarizationConfig {
2521 instructions: Some("Focus on API changes".to_string()),
2522 ..Default::default()
2523 };
2524 let prompt = build_summarization_prompt(&config);
2525 assert!(prompt.contains("Focus on API changes"));
2526 }
2527
2528 #[test]
2529 fn test_summarization_prompt_custom_preserve() {
2530 let config = SummarizationConfig {
2531 preserve: vec!["auth_tokens".to_string(), "database_schema".to_string()],
2532 ..Default::default()
2533 };
2534 let prompt = build_summarization_prompt(&config);
2535 assert!(prompt.contains("auth_tokens"));
2536 assert!(prompt.contains("database_schema"));
2537 assert!(!prompt.contains("decisions"));
2538 }
2539
2540 #[test]
2541 fn test_summarization_prompt_empty_preserve_uses_defaults() {
2542 let config = SummarizationConfig {
2543 preserve: vec![],
2544 ..Default::default()
2545 };
2546 let prompt = build_summarization_prompt(&config);
2547 assert!(prompt.contains("decisions"));
2548 }
2549
2550 #[test]
2551 fn test_format_messages_for_summarization() {
2552 let messages = vec![
2553 make_user_msg("What is 2+2?"),
2554 make_assistant_msg("The answer is 4."),
2555 ];
2556 let formatted = format_messages_for_summarization(&messages);
2557 assert!(formatted.contains("[user]: What is 2+2?"));
2558 assert!(formatted.contains("[assistant]: The answer is 4."));
2559 }
2560
2561 #[test]
2562 fn test_format_messages_truncates_long_content() {
2563 let long_content = "x".repeat(5000);
2564 let messages = vec![make_user_msg(&long_content)];
2565 let formatted = format_messages_for_summarization(&messages);
2566 assert!(formatted.contains("truncated"));
2567 assert!(formatted.len() < long_content.len());
2568 }
2569
2570 #[test]
2571 fn test_format_messages_truncates_utf8_without_panic() {
2572 let multibyte = "é".repeat(1001); let messages = vec![make_user_msg(&multibyte)];
2574 let formatted = format_messages_for_summarization(&messages);
2575 assert!(formatted.contains("truncated"));
2576 assert!(formatted.contains("[truncated, 2002 chars total]"));
2577 }
2578
2579 #[test]
2580 fn test_build_summary_message() {
2581 let msg = build_summary_message("The user asked about APIs.");
2582 assert_eq!(msg.role, LlmMessageRole::System);
2583 let text = extract_text(&msg.content);
2584 assert!(text.contains("[CONVERSATION_SUMMARY]"));
2585 assert!(text.contains("The user asked about APIs."));
2586 assert!(text.contains("[/CONVERSATION_SUMMARY]"));
2587 }
2588
2589 #[test]
2594 fn test_head_tail_short_content_unchanged() {
2595 let content = LlmMessageContent::Text("line1\nline2\nline3".to_string());
2596 assert_eq!(format_head_tail_summary(&content), "line1\nline2\nline3");
2597 }
2598
2599 #[test]
2600 fn test_head_tail_exactly_six_lines() {
2601 let content = LlmMessageContent::Text("1\n2\n3\n4\n5\n6".to_string());
2602 assert_eq!(format_head_tail_summary(&content), "1\n2\n3\n4\n5\n6");
2603 }
2604
2605 #[test]
2606 fn test_head_tail_seven_lines() {
2607 let content = LlmMessageContent::Text("1\n2\n3\n4\n5\n6\n7".to_string());
2608 let result = format_head_tail_summary(&content);
2609 assert!(result.contains("1\n2\n3"));
2610 assert!(result.contains("5\n6\n7"));
2611 assert!(result.contains("1 lines omitted"));
2612 }
2613
2614 #[test]
2619 fn test_one_line_empty_output() {
2620 let result = format_one_line_summary("bash", &LlmMessageContent::Text(String::new()));
2621 assert_eq!(result, "[bash → ]");
2622 }
2623
2624 #[test]
2625 fn test_one_line_exactly_100_chars() {
2626 let text = "x".repeat(100);
2627 let result = format_one_line_summary("bash", &LlmMessageContent::Text(text.clone()));
2628 assert!(result.contains(&text));
2629 }
2630
2631 #[test]
2632 fn test_one_line_101_chars_summarized() {
2633 let text = "x".repeat(101);
2634 let result = format_one_line_summary("bash", &LlmMessageContent::Text(text));
2635 assert!(result.contains("lines"));
2636 assert!(result.contains("bytes"));
2637 }
2638
2639 #[test]
2640 fn test_one_line_multipart_content() {
2641 let content = LlmMessageContent::Parts(vec![
2642 LlmContentPart::Text {
2643 text: "part1".to_string(),
2644 },
2645 LlmContentPart::Text {
2646 text: "part2".to_string(),
2647 },
2648 ]);
2649 let result = format_one_line_summary("tool", &content);
2650 assert!(result.contains("part1"));
2651 assert!(result.contains("part2"));
2652 }
2653
2654 #[test]
2659 fn test_compaction_step_serialization() {
2660 let step = CompactionStep {
2661 strategy: "observation_masking".to_string(),
2662 messages_after: 42,
2663 duration_ms: 12,
2664 };
2665 let json = serde_json::to_value(&step).unwrap();
2666 assert_eq!(json["strategy"], "observation_masking");
2667 assert_eq!(json["messages_after"], 42);
2668 assert_eq!(json["duration_ms"], 12);
2669 }
2670
2671 #[test]
2676 fn test_estimate_tokens_text() {
2677 let msg = make_user_msg("hello world"); let tokens = estimate_tokens(&msg);
2679 assert_eq!(tokens, 11 / 4);
2680 }
2681
2682 #[test]
2683 fn test_estimate_tokens_empty() {
2684 let msg = make_user_msg("");
2685 assert_eq!(estimate_tokens(&msg), 0);
2686 }
2687
2688 #[test]
2689 fn test_estimate_total_tokens() {
2690 let messages = vec![
2691 make_user_msg("a".repeat(400).as_str()), make_assistant_msg("b".repeat(200).as_str()), ];
2694 assert_eq!(estimate_total_tokens(&messages), 150);
2695 }
2696
2697 #[test]
2698 fn test_estimate_tokens_with_tool_calls() {
2699 let msg = make_assistant_with_tool_call("call_1", "read_file");
2700 let tokens = estimate_tokens(&msg);
2701 assert!(tokens > 0, "Tool call should contribute tokens");
2702 }
2703
2704 #[test]
2709 fn test_should_compact_proactively_under_budget() {
2710 let messages = vec![make_user_msg("short")];
2711 let config = CompactionConfig::default(); assert!(!should_compact_proactively(&messages, &config, 128_000));
2713 }
2714
2715 #[test]
2716 fn test_should_compact_proactively_over_budget() {
2717 let big_text = "x".repeat(4000); let messages = vec![make_user_msg(&big_text)];
2720 let config = CompactionConfig::default();
2721 assert!(should_compact_proactively(&messages, &config, 1000));
2722 }
2723
2724 #[test]
2725 fn test_should_compact_proactively_disabled() {
2726 let big_text = "x".repeat(4000);
2727 let messages = vec![make_user_msg(&big_text)];
2728 let config = CompactionConfig {
2729 proactive: false,
2730 ..Default::default()
2731 };
2732 assert!(!should_compact_proactively(&messages, &config, 1000));
2733 }
2734
2735 #[test]
2740 fn test_aggressive_trim_keeps_newest() {
2741 let messages = vec![
2743 make_user_msg(&"s".repeat(400)), make_user_msg(&"a".repeat(400)), make_assistant_msg(&"b".repeat(400)), make_user_msg(&"c".repeat(400)), make_assistant_msg(&"d".repeat(400)), ];
2749 let target_tokens = 300;
2751 let result = aggressive_trim(&messages, target_tokens, true);
2752 assert!(
2753 result.len() < messages.len(),
2754 "Expected trim, got {} messages",
2755 result.len()
2756 );
2757 assert_eq!(result[0].role, LlmMessageRole::User);
2759 }
2760
2761 #[test]
2762 fn test_aggressive_trim_empty() {
2763 let result = aggressive_trim(&[], 100, false);
2764 assert!(result.is_empty());
2765 }
2766
2767 #[test]
2768 fn test_aggressive_trim_anchors_first_conversation_message() {
2769 let messages = vec![
2773 make_user_msg("sys"), make_user_msg(&"TASK ".repeat(80)), make_assistant_msg(&"x".repeat(400)), make_user_msg(&"c".repeat(400)), make_assistant_msg(&"d".repeat(400)), ];
2779 let result = aggressive_trim(&messages, 250, true);
2780
2781 assert!(
2782 result.len() < messages.len(),
2783 "expected a trim, got {} messages",
2784 result.len()
2785 );
2786 let kept_task = result.iter().any(|m| match &m.content {
2787 LlmMessageContent::Text(t) => t.contains("TASK"),
2788 _ => false,
2789 });
2790 assert!(kept_task, "the original task must be anchored, not dropped");
2791 }
2792
2793 #[test]
2794 fn test_aggressive_trim_everything_fits() {
2795 let messages = vec![make_user_msg("hi"), make_assistant_msg("hello")];
2796 let result = aggressive_trim(&messages, 100_000, false);
2797 assert_eq!(result.len(), 2);
2798 }
2799
2800 #[test]
2805 fn test_session_metrics_record() {
2806 let mut metrics = SessionCompactionMetrics::default();
2807 metrics.record("observation_masking+native", 100, 50, 200);
2808
2809 assert_eq!(metrics.compaction_count, 1);
2810 assert_eq!(metrics.total_messages_saved, 50);
2811 assert_eq!(metrics.total_duration_ms, 200);
2812 assert_eq!(metrics.strategy_counts["observation_masking"], 1);
2813 assert_eq!(metrics.strategy_counts["native"], 1);
2814 }
2815
2816 #[test]
2817 fn test_session_metrics_accumulate() {
2818 let mut metrics = SessionCompactionMetrics::default();
2819 metrics.record("observation_masking", 100, 80, 10);
2820 metrics.record("summarization", 80, 40, 500);
2821
2822 assert_eq!(metrics.compaction_count, 2);
2823 assert_eq!(metrics.total_messages_saved, 60);
2824 assert_eq!(metrics.total_duration_ms, 510);
2825 assert_eq!(metrics.strategy_counts["observation_masking"], 1);
2826 assert_eq!(metrics.strategy_counts["summarization"], 1);
2827 }
2828
2829 #[test]
2830 fn test_session_metrics_serialization() {
2831 let mut metrics = SessionCompactionMetrics::default();
2832 metrics.record("auto", 50, 30, 100);
2833 let json = serde_json::to_value(&metrics).unwrap();
2834 assert_eq!(json["compaction_count"], 1);
2835 assert_eq!(json["total_messages_saved"], 20);
2836 }
2837
2838 #[test]
2843 fn test_classify_memory_tiers_basic() {
2844 let messages: Vec<LlmMessage> = (0..30)
2845 .map(|i| make_user_msg(&format!("msg {i}")))
2846 .collect();
2847
2848 let config = HierarchicalMemoryConfig {
2849 hot_messages: 5,
2850 warm_messages: 10,
2851 };
2852
2853 let classified = classify_memory_tiers(&messages, &config);
2854 assert_eq!(classified.len(), 30);
2855
2856 assert_eq!(classified[29].0, MemoryTier::Hot);
2858 assert_eq!(classified[25].0, MemoryTier::Hot);
2859
2860 assert_eq!(classified[24].0, MemoryTier::Warm);
2862 assert_eq!(classified[15].0, MemoryTier::Warm);
2863
2864 assert_eq!(classified[14].0, MemoryTier::Cold);
2866 assert_eq!(classified[0].0, MemoryTier::Cold);
2867 }
2868
2869 #[test]
2870 fn test_classify_memory_tiers_all_hot() {
2871 let messages: Vec<LlmMessage> =
2872 (0..3).map(|i| make_user_msg(&format!("msg {i}"))).collect();
2873
2874 let config = HierarchicalMemoryConfig::default(); let classified = classify_memory_tiers(&messages, &config);
2877 assert!(classified.iter().all(|(tier, _)| *tier == MemoryTier::Hot));
2878 }
2879
2880 #[test]
2881 fn test_apply_hierarchical_memory_basic() {
2882 let mut messages = Vec::new();
2883
2884 for i in 0..5 {
2886 let id = format!("old_{i}");
2887 messages.push(make_assistant_with_tool_call(&id, "read_file"));
2888 messages.push(make_tool_result(&id, &format!("old content {i}")));
2889 }
2890
2891 for i in 0..3 {
2893 let id = format!("mid_{i}");
2894 messages.push(make_assistant_with_tool_call(&id, "bash"));
2895 messages.push(make_tool_result(&id, &format!("mid output {i}")));
2896 }
2897
2898 messages.push(make_user_msg("what now?"));
2900 messages.push(make_assistant_msg("let me check"));
2901
2902 let config = HierarchicalMemoryConfig {
2903 hot_messages: 2,
2904 warm_messages: 6,
2905 };
2906 let masking_config = ObservationMaskingConfig::default();
2907
2908 let result = apply_hierarchical_memory(
2909 &messages,
2910 &config,
2911 &masking_config,
2912 Some("Summary of old work"),
2913 );
2914
2915 assert!(result.len() <= 9);
2917 let first_text = extract_text(&result[0].content);
2919 assert!(first_text.contains("CONVERSATION_SUMMARY"));
2920 let last = extract_text(&result[result.len() - 1].content);
2922 assert!(last.contains("let me check"));
2923 }
2924
2925 #[test]
2926 fn test_apply_hierarchical_memory_no_cold() {
2927 let messages = vec![make_user_msg("hello"), make_assistant_msg("hi")];
2928
2929 let config = HierarchicalMemoryConfig {
2930 hot_messages: 5,
2931 warm_messages: 5,
2932 };
2933
2934 let result = apply_hierarchical_memory(
2935 &messages,
2936 &config,
2937 &ObservationMaskingConfig::default(),
2938 None,
2939 );
2940 assert_eq!(result.len(), 2);
2942 }
2943
2944 #[test]
2945 fn test_memory_tier_config_from_json() {
2946 let config: HierarchicalMemoryConfig = serde_json::from_value(json!({
2947 "hot_messages": 10,
2948 "warm_messages": 50
2949 }))
2950 .unwrap();
2951 assert_eq!(config.hot_messages, 10);
2952 assert_eq!(config.warm_messages, 50);
2953 }
2954
2955 #[test]
2956 fn test_memory_tier_config_defaults() {
2957 let config = HierarchicalMemoryConfig::default();
2958 assert_eq!(config.hot_messages, 20);
2959 assert_eq!(config.warm_messages, 100);
2960 }
2961
2962 #[test]
2963 fn test_compaction_config_with_memory_tiers() {
2964 let config = CompactionConfig::from_json(&json!({
2965 "strategy": "auto",
2966 "memory_tiers": {
2967 "hot_messages": 15,
2968 "warm_messages": 80
2969 }
2970 }));
2971 assert_eq!(config.memory_tiers.hot_messages, 15);
2972 assert_eq!(config.memory_tiers.warm_messages, 80);
2973 }
2974
2975 #[test]
2976 fn test_memory_tier_serialization() {
2977 assert_eq!(serde_json::to_value(MemoryTier::Hot).unwrap(), json!("hot"));
2978 assert_eq!(
2979 serde_json::to_value(MemoryTier::Warm).unwrap(),
2980 json!("warm")
2981 );
2982 assert_eq!(
2983 serde_json::to_value(MemoryTier::Cold).unwrap(),
2984 json!("cold")
2985 );
2986 }
2987
2988 #[test]
2993 fn test_masking_skips_activate_skill_results() {
2994 let messages = vec![
2997 make_assistant_with_tool_call("call_skill", "activate_skill"),
2998 make_tool_result(
2999 "call_skill",
3000 "You are a code review agent. Follow these instructions...",
3001 ),
3002 make_assistant_msg("Skill activated"),
3003 make_assistant_with_tool_call("call_read", "read_file"),
3004 make_tool_result(
3005 "call_read",
3006 "file contents that are long enough to be masked by observation masking because they exceed one hundred characters easily",
3007 ),
3008 make_assistant_msg("got it"),
3009 make_assistant_with_tool_call("call_bash", "bash"),
3010 make_tool_result("call_bash", "command output"),
3011 ];
3012
3013 let config = ObservationMaskingConfig {
3014 keep_recent_tool_outputs: 1,
3015 summary_format: MaskingSummaryFormat::OneLine,
3016 };
3017 let result = apply_observation_masking(&messages, &config);
3018
3019 assert_eq!(
3021 extract_text(&result.messages[1].content),
3022 "You are a code review agent. Follow these instructions..."
3023 );
3024 assert!(extract_text(&result.messages[4].content).starts_with('['));
3026 assert_eq!(extract_text(&result.messages[7].content), "command output");
3028 assert_eq!(result.masked_count, 1);
3029 }
3030
3031 #[test]
3032 fn test_masking_all_activate_skill_exempt_from_count() {
3033 let messages = vec![
3036 make_assistant_with_tool_call("s1", "activate_skill"),
3037 make_tool_result("s1", "Skill 1 instructions"),
3038 make_assistant_with_tool_call("s2", "activate_skill"),
3039 make_tool_result("s2", "Skill 2 instructions"),
3040 make_assistant_with_tool_call("c1", "bash"),
3041 make_tool_result("c1", "output"),
3042 ];
3043
3044 let config = ObservationMaskingConfig {
3045 keep_recent_tool_outputs: 0,
3046 summary_format: MaskingSummaryFormat::OneLine,
3047 };
3048 let result = apply_observation_masking(&messages, &config);
3049
3050 assert_eq!(result.masked_count, 1);
3051 assert_eq!(
3053 extract_text(&result.messages[1].content),
3054 "Skill 1 instructions"
3055 );
3056 assert_eq!(
3057 extract_text(&result.messages[3].content),
3058 "Skill 2 instructions"
3059 );
3060 }
3061
3062 #[test]
3063 fn test_aggressive_trim_preserves_skill_messages() {
3064 let messages = vec![
3067 make_user_msg(&"s".repeat(400)), make_assistant_with_tool_call("skill1", "activate_skill"),
3069 make_tool_result("skill1", "Important skill instructions"),
3070 make_user_msg(&"a".repeat(400)), make_assistant_msg(&"b".repeat(400)), make_user_msg(&"c".repeat(400)), make_assistant_msg(&"d".repeat(400)), ];
3075
3076 let target_tokens = 400;
3079 let result = aggressive_trim(&messages, target_tokens, true);
3080
3081 let has_skill_result = result.iter().any(|m| {
3083 m.role == LlmMessageRole::Tool
3084 && extract_text(&m.content) == "Important skill instructions"
3085 });
3086 assert!(
3087 has_skill_result,
3088 "Skill tool result must survive aggressive trim"
3089 );
3090
3091 let has_skill_call = result.iter().any(|m| {
3092 m.tool_calls
3093 .as_ref()
3094 .is_some_and(|calls| calls.iter().any(|tc| tc.name == "activate_skill"))
3095 });
3096 assert!(
3097 has_skill_call,
3098 "Skill tool call must survive aggressive trim"
3099 );
3100 }
3101
3102 #[test]
3103 fn test_hierarchical_memory_rescues_skill_from_cold_tier() {
3104 let mut messages = Vec::new();
3105
3106 messages.push(make_assistant_with_tool_call("skill1", "activate_skill"));
3108 messages.push(make_tool_result(
3109 "skill1",
3110 "You must always validate input.",
3111 ));
3112 for i in 0..8 {
3113 let id = format!("old_{i}");
3114 messages.push(make_assistant_with_tool_call(&id, "read_file"));
3115 messages.push(make_tool_result(&id, &format!("old content {i}")));
3116 }
3117
3118 for i in 0..3 {
3120 let id = format!("mid_{i}");
3121 messages.push(make_assistant_with_tool_call(&id, "bash"));
3122 messages.push(make_tool_result(&id, &format!("mid output {i}")));
3123 }
3124
3125 messages.push(make_user_msg("what now?"));
3127 messages.push(make_assistant_msg("let me check"));
3128
3129 let config = HierarchicalMemoryConfig {
3130 hot_messages: 2,
3131 warm_messages: 6,
3132 };
3133 let masking_config = ObservationMaskingConfig::default();
3134
3135 let result = apply_hierarchical_memory(
3136 &messages,
3137 &config,
3138 &masking_config,
3139 Some("Summary of old work"),
3140 );
3141
3142 let has_skill_instructions = result
3144 .iter()
3145 .any(|m| extract_text(&m.content).contains("You must always validate input."));
3146 assert!(
3147 has_skill_instructions,
3148 "Skill instructions from cold tier must be rescued into output"
3149 );
3150
3151 assert!(extract_text(&result[0].content).contains("CONVERSATION_SUMMARY"));
3153 }
3154
3155 #[test]
3156 fn test_is_protected_tool_result_detection() {
3157 let messages = vec![
3158 make_assistant_with_tool_call("s1", "activate_skill"),
3159 make_tool_result("s1", "skill content"),
3160 make_assistant_with_tool_call("r1", "read_file"),
3161 make_tool_result("r1", "file content"),
3162 ];
3163
3164 assert!(is_protected_tool_result(&messages, &messages[1]));
3166 assert!(!is_protected_tool_result(&messages, &messages[3]));
3168 assert!(!is_protected_tool_result(&messages, &messages[0]));
3170 }
3171
3172 #[test]
3173 fn test_is_protected_tool_call_message_detection() {
3174 let skill_call = make_assistant_with_tool_call("s1", "activate_skill");
3175 let regular_call = make_assistant_with_tool_call("r1", "read_file");
3176 let user_msg = make_user_msg("hello");
3177
3178 assert!(is_protected_tool_call_message(&skill_call));
3179 assert!(!is_protected_tool_call_message(®ular_call));
3180 assert!(!is_protected_tool_call_message(&user_msg));
3181 }
3182
3183 #[test]
3184 fn test_default_preserve_includes_skill_instructions() {
3185 let config = SummarizationConfig::default();
3186 assert!(
3187 config.preserve.contains(&"skill_instructions".to_string()),
3188 "Default preserve list must include skill_instructions"
3189 );
3190 }
3191
3192 #[test]
3193 fn test_summarization_prompt_mentions_skill_protection() {
3194 let config = SummarizationConfig::default();
3195 let prompt = build_summarization_prompt(&config);
3196 assert!(
3197 prompt.contains("activate_skill"),
3198 "Summarization prompt must instruct LLM to preserve skill content"
3199 );
3200 }
3201
3202 #[test]
3203 fn test_aggressive_trim_protected_exceed_budget() {
3204 let messages = vec![
3207 make_user_msg(&"s".repeat(400)), make_assistant_with_tool_call("skill1", "activate_skill"), make_tool_result("skill1", &"x".repeat(800)), make_assistant_with_tool_call("skill2", "activate_skill"), make_tool_result("skill2", &"y".repeat(800)), make_user_msg(&"z".repeat(400)), ];
3214
3215 let result = aggressive_trim(&messages, 200, true);
3217
3218 let has_non_protected = result
3220 .iter()
3221 .any(|m| m.role == LlmMessageRole::User && extract_text(&m.content).contains('z'));
3222 assert!(
3223 !has_non_protected,
3224 "Non-protected messages must be dropped when protected exceed budget"
3225 );
3226 }
3227
3228 #[test]
3229 fn test_format_messages_no_truncate_protected_tool_result() {
3230 let long_instructions = "a".repeat(5000);
3232 let messages = vec![
3233 make_assistant_with_tool_call("s1", "activate_skill"),
3234 make_tool_result("s1", &long_instructions),
3235 make_assistant_with_tool_call("r1", "read_file"),
3236 make_tool_result("r1", &"b".repeat(5000)),
3237 ];
3238
3239 let formatted = format_messages_for_summarization(&messages);
3240
3241 assert!(
3243 formatted.contains(&long_instructions),
3244 "Protected tool result must not be truncated"
3245 );
3246 assert!(
3248 formatted.contains("[truncated, 5000 chars total]"),
3249 "Non-protected tool result should be truncated"
3250 );
3251 }
3252
3253 #[test]
3254 fn test_hierarchical_memory_cross_tier_boundary_protection() {
3255 let mut messages = Vec::new();
3258
3259 messages.push(make_assistant_with_tool_call("skill1", "activate_skill"));
3261 for i in 0..9 {
3262 let id = format!("cold_{i}");
3263 messages.push(make_assistant_with_tool_call(&id, "read_file"));
3264 messages.push(make_tool_result(&id, &format!("cold content {i}")));
3265 }
3266
3267 messages.push(make_tool_result(
3269 "skill1",
3270 "Cross-tier skill instructions that must survive",
3271 ));
3272 for i in 0..2 {
3273 let id = format!("warm_{i}");
3274 messages.push(make_assistant_with_tool_call(&id, "bash"));
3275 messages.push(make_tool_result(&id, &format!("warm output {i}")));
3276 }
3277
3278 messages.push(make_user_msg("continue"));
3280 messages.push(make_assistant_msg("ok"));
3281
3282 let config = HierarchicalMemoryConfig {
3283 hot_messages: 2,
3284 warm_messages: 5, };
3286 let masking_config = ObservationMaskingConfig {
3287 keep_recent_tool_outputs: 0,
3288 summary_format: MaskingSummaryFormat::OneLine,
3289 };
3290
3291 let result = apply_hierarchical_memory(&messages, &config, &masking_config, None);
3292
3293 let has_skill_instructions = result.iter().any(|m| {
3294 extract_text(&m.content).contains("Cross-tier skill instructions that must survive")
3295 });
3296 assert!(
3297 has_skill_instructions,
3298 "Skill result in warm tier with call in cold tier must be protected"
3299 );
3300 }
3301}