1use std::collections::BTreeMap;
30use std::rc::Rc;
31
32use serde_json::Value as JsonValue;
33
34use crate::agent_events::AgentEvent;
35use crate::llm::api::LlmCallOptions;
36use crate::llm::helpers::{
37 emit_reminder_lifecycle_event, normalize_transcript_asset, reminder_from_event,
38 reminder_lifecycle_payload, replace_reminder_payload, SystemReminder,
39 REMINDER_DEDUPED_EVENT_KIND, REMINDER_EXPIRED_EVENT_KIND,
40};
41use crate::value::{VmError, VmValue};
42
43use super::{
44 auto_compact_messages_with_result, compact_strategy_name, compaction_policy_metadata_fields,
45 estimate_message_tokens, parse_compact_strategy, run_lifecycle_hooks,
46 run_lifecycle_hooks_with_control, AutoCompactConfig, CompactStrategy, CompactionPolicy,
47 HookControl, HookEvent,
48};
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum CompactMode {
56 Manual,
59 Host,
62 Auto,
66 Workflow,
69 Worker,
71 ResumeDigest,
74}
75
76impl CompactMode {
77 pub fn as_str(self) -> &'static str {
78 match self {
79 CompactMode::Manual => "manual",
80 CompactMode::Host => "host",
81 CompactMode::Auto => "auto",
82 CompactMode::Workflow => "workflow",
83 CompactMode::Worker => "worker",
84 CompactMode::ResumeDigest => "resume_digest",
85 }
86 }
87
88 pub fn fires_hooks(self) -> bool {
95 match self {
96 CompactMode::Manual | CompactMode::Host | CompactMode::Auto => true,
97 CompactMode::Workflow | CompactMode::Worker | CompactMode::ResumeDigest => false,
98 }
99 }
100}
101
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub enum CompactionTrigger {
107 Manual,
108 Threshold,
109 BudgetPressure,
110}
111
112impl CompactionTrigger {
113 pub fn as_str(self) -> &'static str {
114 match self {
115 Self::Manual => "manual",
116 Self::Threshold => "threshold",
117 Self::BudgetPressure => "budget_pressure",
118 }
119 }
120}
121
122pub struct CompactLifecycle<'a> {
125 pub session_id: Option<&'a str>,
126 pub transcript_id: Option<&'a str>,
127 pub mode: CompactMode,
128 pub trigger: CompactionTrigger,
129 pub fire_hooks: bool,
130 pub reminder_events: Vec<VmValue>,
134 pub summary_override: Option<String>,
139 pub provider_options: JsonValue,
142 pub source_transcript: Option<&'a VmValue>,
147 pub evaluate_providers: bool,
150}
151
152impl<'a> CompactLifecycle<'a> {
153 pub fn new(mode: CompactMode) -> Self {
154 let trigger = match mode {
155 CompactMode::Manual | CompactMode::Host | CompactMode::ResumeDigest => {
156 CompactionTrigger::Manual
157 }
158 CompactMode::Auto | CompactMode::Workflow | CompactMode::Worker => {
159 CompactionTrigger::Threshold
160 }
161 };
162 Self {
163 session_id: None,
164 transcript_id: None,
165 mode,
166 trigger,
167 fire_hooks: mode.fires_hooks(),
168 reminder_events: Vec::new(),
169 summary_override: None,
170 provider_options: JsonValue::Object(serde_json::Map::new()),
171 source_transcript: None,
172 evaluate_providers: true,
173 }
174 }
175
176 pub fn with_session_id(mut self, session_id: Option<&'a str>) -> Self {
177 self.session_id = session_id;
178 self
179 }
180
181 pub fn with_transcript_id(mut self, transcript_id: Option<&'a str>) -> Self {
182 self.transcript_id = transcript_id;
183 self
184 }
185
186 pub fn with_trigger(mut self, trigger: CompactionTrigger) -> Self {
187 self.trigger = trigger;
188 self
189 }
190
191 pub fn with_hook_dispatch(mut self, fire_hooks: bool) -> Self {
192 self.fire_hooks = fire_hooks;
193 self
194 }
195
196 pub fn with_reminder_events(mut self, events: Vec<VmValue>) -> Self {
197 self.reminder_events = events;
198 self
199 }
200
201 pub fn with_summary_override(mut self, summary: Option<String>) -> Self {
202 self.summary_override = summary;
203 self
204 }
205
206 pub fn with_provider_options(mut self, options: JsonValue) -> Self {
207 self.provider_options = options;
208 self
209 }
210
211 pub fn with_source_transcript(mut self, transcript: Option<&'a VmValue>) -> Self {
212 self.source_transcript = transcript;
213 self
214 }
215
216 pub fn with_evaluate_providers(mut self, evaluate: bool) -> Self {
217 self.evaluate_providers = evaluate;
218 self
219 }
220}
221
222pub struct CompactionOutcome {
228 pub summary: String,
229 pub archived_messages: usize,
230 pub estimated_tokens_before: usize,
231 pub estimated_tokens_after: usize,
232 pub reminder_report: ReminderCompactReport,
233 pub snapshot_asset: Option<VmValue>,
236 pub snapshot_asset_id: Option<String>,
238 pub strategy: CompactStrategy,
240 pub policy_strategy: String,
242 pub event_metadata: JsonValue,
245}
246
247#[derive(Clone, Debug)]
248pub struct TranscriptCompactedEventMetrics {
249 pub archived_messages: usize,
250 pub estimated_tokens_before: usize,
251 pub estimated_tokens_after: usize,
252 pub snapshot_asset_id: Option<String>,
253}
254
255#[derive(Debug, Default)]
258pub struct ReminderCompactReport {
259 pub preserved_events: Vec<VmValue>,
262 pub custom_reminders: Vec<VmValue>,
265 pub expired: Vec<SystemReminder>,
267 pub compacted: Vec<SystemReminder>,
270 pub deduped: Vec<ReminderDedupeRecord>,
273 pub decremented_count: usize,
275 pub preserved_count: usize,
277}
278
279#[derive(Clone, Debug)]
280pub struct ReminderDedupeRecord {
281 pub replaced_id: String,
282 pub replacing_id: String,
283 pub dedupe_key: String,
284}
285
286pub(crate) async fn run_compaction_lifecycle(
295 messages: &mut Vec<JsonValue>,
296 config: &mut AutoCompactConfig,
297 llm_opts: Option<&LlmCallOptions>,
298 mut lifecycle: CompactLifecycle<'_>,
299) -> Result<Option<CompactionOutcome>, VmError> {
300 let reminder_events = std::mem::take(&mut lifecycle.reminder_events);
303
304 let estimated_tokens_before = estimate_message_tokens(messages);
305 let original_message_count = messages.len();
306
307 let fires_hooks = lifecycle.fire_hooks;
308
309 if fires_hooks {
310 let pre_payload = build_hook_payload(
311 HookEvent::PreCompact,
312 &lifecycle,
313 config,
314 HookPayloadStage::Pre {
315 message_count: original_message_count,
316 estimated_tokens_before,
317 },
318 );
319 match run_lifecycle_hooks_with_control(HookEvent::PreCompact, &pre_payload).await? {
320 HookControl::Block { .. } => return Ok(None),
321 HookControl::Modify { payload } => apply_pre_modify_overrides(config, &payload)?,
322 HookControl::Allow | HookControl::Decision { .. } => {}
323 }
324 }
325
326 let reminder_report = compact_reminder_events(reminder_events);
327 config.custom_compactor_reminders = reminder_report.custom_reminders.clone();
328
329 let Some(compact_result) =
330 auto_compact_messages_with_result(messages, config, llm_opts).await?
331 else {
332 return Ok(None);
333 };
334 let engine_strategy = compact_result.strategy;
335 let raw_summary = compact_result.summary;
336 let summary = lifecycle.summary_override.clone().unwrap_or(raw_summary);
337
338 if fires_hooks {
339 emit_reminder_lifecycle_records(lifecycle.transcript_id, &reminder_report);
340 }
341
342 let estimated_tokens_after = estimate_message_tokens(messages);
343 let archived_messages = original_message_count
344 .saturating_sub(messages.len())
345 .saturating_add(1);
346
347 let snapshot_asset = lifecycle.source_transcript.map(|transcript| {
348 build_snapshot_asset(
349 transcript,
350 config,
351 &engine_strategy,
352 archived_messages,
353 estimated_tokens_before,
354 estimated_tokens_after,
355 )
356 });
357 let snapshot_asset_id = snapshot_asset.as_ref().map(snapshot_asset_id_of);
358 let event_metrics = TranscriptCompactedEventMetrics {
359 archived_messages,
360 estimated_tokens_before,
361 estimated_tokens_after,
362 snapshot_asset_id: snapshot_asset_id.clone(),
363 };
364
365 let event_metadata = build_event_metadata(
366 &lifecycle,
367 config,
368 &event_metrics,
369 &reminder_report,
370 &summary,
371 &engine_strategy,
372 );
373
374 if fires_hooks {
375 let post_payload = build_hook_payload(
376 HookEvent::PostCompact,
377 &lifecycle,
378 config,
379 HookPayloadStage::Post {
380 original_message_count,
381 remaining_messages: messages.len(),
382 archived_messages,
383 estimated_tokens_before,
384 estimated_tokens_after,
385 summary: &summary,
386 snapshot_asset_id: snapshot_asset_id.as_deref(),
387 reminder_report: &reminder_report,
388 },
389 );
390 run_lifecycle_hooks(HookEvent::PostCompact, &post_payload).await?;
391
392 if let Some(session_id) = lifecycle.session_id {
393 emit_transcript_compacted_event(
394 session_id,
395 lifecycle.mode,
396 lifecycle.trigger.as_str(),
397 config,
398 event_metrics.clone(),
399 )
400 .await;
401 if lifecycle.evaluate_providers {
402 let _ = crate::llm::reminder_providers::evaluate_and_inject(
403 HookEvent::PostCompact,
404 session_id,
405 post_payload,
406 lifecycle.provider_options.clone(),
407 )
408 .await;
409 }
410 }
411 }
412
413 Ok(Some(CompactionOutcome {
414 summary,
415 archived_messages,
416 estimated_tokens_before,
417 estimated_tokens_after,
418 reminder_report,
419 snapshot_asset,
420 snapshot_asset_id,
421 strategy: engine_strategy,
422 policy_strategy: config.policy_strategy.clone(),
423 event_metadata,
424 }))
425}
426
427pub async fn emit_transcript_compacted_event(
432 session_id: &str,
433 mode: CompactMode,
434 reason: &str,
435 config: &AutoCompactConfig,
436 metrics: TranscriptCompactedEventMetrics,
437) {
438 crate::llm::emit_live_agent_event(&AgentEvent::TranscriptCompacted {
439 session_id: session_id.to_string(),
440 mode: mode.as_str().to_string(),
441 reason: reason.to_string(),
442 strategy: config.policy_strategy.clone(),
443 archived_messages: metrics.archived_messages,
444 estimated_tokens_before: metrics.estimated_tokens_before,
445 estimated_tokens_after: metrics.estimated_tokens_after,
446 snapshot_asset_id: metrics.snapshot_asset_id,
447 instruction_mode: Some(config.policy.instruction_mode().to_string()),
448 instruction_source: config.policy.instruction_source().map(str::to_string),
449 compaction_policy: config.policy.metadata_json(),
450 })
451 .await;
452}
453
454pub fn emit_transcript_compacted_event_sync(
458 session_id: &str,
459 mode: CompactMode,
460 reason: String,
461 policy: &CompactionPolicy,
462 policy_strategy: String,
463 metrics: TranscriptCompactedEventMetrics,
464) {
465 crate::llm::emit_live_agent_event_sync(&AgentEvent::TranscriptCompacted {
466 session_id: session_id.to_string(),
467 mode: mode.as_str().to_string(),
468 reason,
469 strategy: policy_strategy,
470 archived_messages: metrics.archived_messages,
471 estimated_tokens_before: metrics.estimated_tokens_before,
472 estimated_tokens_after: metrics.estimated_tokens_after,
473 snapshot_asset_id: metrics.snapshot_asset_id,
474 instruction_mode: Some(policy.instruction_mode().to_string()),
475 instruction_source: policy.instruction_source().map(str::to_string),
476 compaction_policy: policy.metadata_json(),
477 });
478}
479
480enum HookPayloadStage<'a> {
486 Pre {
487 message_count: usize,
488 estimated_tokens_before: usize,
489 },
490 Post {
491 original_message_count: usize,
492 remaining_messages: usize,
493 archived_messages: usize,
494 estimated_tokens_before: usize,
495 estimated_tokens_after: usize,
496 summary: &'a str,
497 snapshot_asset_id: Option<&'a str>,
498 reminder_report: &'a ReminderCompactReport,
499 },
500}
501
502fn build_hook_payload(
503 event: HookEvent,
504 lifecycle: &CompactLifecycle<'_>,
505 config: &AutoCompactConfig,
506 stage: HookPayloadStage<'_>,
507) -> JsonValue {
508 let session_id = lifecycle.session_id.unwrap_or_default();
509 let strategy = compact_strategy_name(&config.compact_strategy);
510 let mut payload = serde_json::json!({
511 "event": event.as_str(),
512 "session": {"id": session_id},
513 "session_id": session_id,
514 "mode": lifecycle.mode.as_str(),
515 "reason": lifecycle.trigger.as_str(),
516 "strategy": strategy,
517 "engine_strategy": strategy,
518 "keep_last": config.keep_last,
519 "target_tokens": serde_json::Value::Null,
520 });
521 if config.token_threshold > 0 {
522 payload["target_tokens"] = serde_json::json!(config.token_threshold);
523 }
524 let Some(map) = payload.as_object_mut() else {
525 return payload;
526 };
527 for (key, value) in compaction_policy_metadata_fields(&config.policy) {
528 map.insert(key.to_string(), value);
529 }
530 match stage {
531 HookPayloadStage::Pre {
532 message_count,
533 estimated_tokens_before,
534 } => {
535 map.insert(
536 "message_count".to_string(),
537 serde_json::json!(message_count),
538 );
539 map.insert(
540 "estimated_tokens_before".to_string(),
541 serde_json::json!(estimated_tokens_before),
542 );
543 }
544 HookPayloadStage::Post {
545 original_message_count,
546 remaining_messages,
547 archived_messages,
548 estimated_tokens_before,
549 estimated_tokens_after,
550 summary,
551 snapshot_asset_id,
552 reminder_report,
553 } => {
554 map.insert(
555 "message_count".to_string(),
556 serde_json::json!(original_message_count),
557 );
558 map.insert(
559 "remaining_messages".to_string(),
560 serde_json::json!(remaining_messages),
561 );
562 map.insert(
563 "archived_messages".to_string(),
564 serde_json::json!(archived_messages),
565 );
566 map.insert(
567 "estimated_tokens_before".to_string(),
568 serde_json::json!(estimated_tokens_before),
569 );
570 map.insert(
571 "estimated_tokens_after".to_string(),
572 serde_json::json!(estimated_tokens_after),
573 );
574 map.insert("summary".to_string(), serde_json::json!(summary));
575 map.insert(
576 "new_summary_len".to_string(),
577 serde_json::json!(summary.len()),
578 );
579 if let Some(id) = snapshot_asset_id {
580 map.insert("snapshot_asset_id".to_string(), serde_json::json!(id));
581 }
582 map.insert(
583 "reminders_decremented".to_string(),
584 serde_json::json!(reminder_report.decremented_count),
585 );
586 map.insert(
587 "reminders_expired".to_string(),
588 serde_json::json!(reminder_report.expired.len()),
589 );
590 map.insert(
591 "reminders_deduped".to_string(),
592 serde_json::json!(reminder_report.deduped.len()),
593 );
594 map.insert(
595 "reminders_preserved".to_string(),
596 serde_json::json!(reminder_report.preserved_count),
597 );
598 }
599 }
600 payload
601}
602
603fn apply_pre_modify_overrides(
604 config: &mut AutoCompactConfig,
605 payload: &JsonValue,
606) -> Result<(), VmError> {
607 let Some(map) = payload.as_object() else {
608 return Ok(());
609 };
610 if let Some(value) = map.get("keep_last").and_then(JsonValue::as_u64) {
611 config.keep_last = value as usize;
612 }
613 if let Some(value) = map.get("target_tokens").and_then(JsonValue::as_u64) {
614 config.token_threshold = value as usize;
615 config.hard_limit_tokens = Some(value as usize);
616 }
617 if let Some(value) = map.get("strategy").or_else(|| map.get("engine_strategy")) {
618 if let Some(name) = value.as_str() {
619 let strategy = parse_compact_strategy(name)?;
620 config.policy_strategy = compact_strategy_name(&strategy).to_string();
621 config.compact_strategy = strategy;
622 }
623 }
624 Ok(())
625}
626
627fn build_event_metadata(
628 lifecycle: &CompactLifecycle<'_>,
629 config: &AutoCompactConfig,
630 metrics: &TranscriptCompactedEventMetrics,
631 reminder_report: &ReminderCompactReport,
632 summary: &str,
633 engine_strategy: &CompactStrategy,
634) -> JsonValue {
635 let mut metadata = serde_json::json!({
636 "mode": lifecycle.mode.as_str(),
637 "reason": lifecycle.trigger.as_str(),
638 "strategy": config.policy_strategy,
639 "engine_strategy": compact_strategy_name(engine_strategy),
640 "keep_last": config.keep_last,
641 "target_tokens": (config.token_threshold > 0).then_some(config.token_threshold),
642 "archived_messages": metrics.archived_messages,
643 "estimated_tokens_before": metrics.estimated_tokens_before,
644 "estimated_tokens_after": metrics.estimated_tokens_after,
645 "new_summary_len": summary.len(),
646 "snapshot_asset_id": metrics.snapshot_asset_id.as_deref(),
647 "reminders_decremented": reminder_report.decremented_count,
648 "reminders_expired": reminder_report.expired.len(),
649 "reminders_deduped": reminder_report.deduped.len(),
650 "reminders_preserved": reminder_report.preserved_count,
651 });
652 if let Some(map) = metadata.as_object_mut() {
653 for (key, value) in compaction_policy_metadata_fields(&config.policy) {
654 map.insert(key.to_string(), value);
655 }
656 }
657 metadata
658}
659
660enum CompactEvent {
661 Other(VmValue),
662 Reminder {
663 event: VmValue,
664 reminder: SystemReminder,
665 reminder_index: usize,
666 },
667}
668
669pub fn compact_reminder_events(extra_events: Vec<VmValue>) -> ReminderCompactReport {
673 let mut events = Vec::with_capacity(extra_events.len());
674 let mut reminders = Vec::new();
675 let mut expired = Vec::new();
676 let mut decremented_count = 0;
677
678 for event in extra_events {
679 let Some(reminder) = reminder_from_event(&event) else {
680 events.push(CompactEvent::Other(event));
681 continue;
682 };
683
684 let (event, reminder) = match reminder.ttl_turns {
685 Some(ttl) if ttl <= 1 => {
686 expired.push(reminder);
687 continue;
688 }
689 Some(ttl) => {
690 let mut updated = reminder;
691 updated.ttl_turns = Some(ttl - 1);
692 decremented_count += 1;
693 (replace_reminder_payload(&event, &updated), updated)
694 }
695 None => (event, reminder),
696 };
697
698 let reminder_index = reminders.len();
699 reminders.push(reminder.clone());
700 events.push(CompactEvent::Reminder {
701 event,
702 reminder,
703 reminder_index,
704 });
705 }
706
707 let mut newest_by_dedupe_key = BTreeMap::new();
708 for (index, reminder) in reminders.iter().enumerate() {
709 if let Some(dedupe_key) = reminder.dedupe_key.as_deref() {
710 newest_by_dedupe_key.insert(dedupe_key.to_string(), index);
711 }
712 }
713
714 let mut kept_reminders = Vec::new();
715 let mut preserved_events = Vec::new();
716 let mut compacted = Vec::new();
717 let mut deduped = Vec::new();
718 let mut preserved_count = 0;
719
720 for event in events {
721 match event {
722 CompactEvent::Other(event) => preserved_events.push(event),
723 CompactEvent::Reminder {
724 event,
725 reminder,
726 reminder_index,
727 } => {
728 let keep = reminder
729 .dedupe_key
730 .as_deref()
731 .and_then(|key| newest_by_dedupe_key.get(key))
732 .is_none_or(|newest| *newest == reminder_index);
733 if !keep {
734 let replacing_id = reminder
735 .dedupe_key
736 .as_deref()
737 .and_then(|key| newest_by_dedupe_key.get(key))
738 .and_then(|index| reminders.get(*index))
739 .map(|newest| newest.id.clone())
740 .unwrap_or_default();
741 deduped.push(ReminderDedupeRecord {
742 replaced_id: reminder.id.clone(),
743 replacing_id,
744 dedupe_key: reminder.dedupe_key.clone().unwrap_or_default(),
745 });
746 continue;
747 }
748
749 kept_reminders.push(crate::stdlib::json_to_vm_value(
750 &serde_json::to_value(&reminder).unwrap_or(JsonValue::Null),
751 ));
752 if reminder.preserve_on_compact {
753 preserved_count += 1;
754 preserved_events.push(event);
755 } else {
756 compacted.push(reminder);
757 }
758 }
759 }
760 }
761
762 ReminderCompactReport {
763 preserved_events,
764 custom_reminders: kept_reminders,
765 expired,
766 compacted,
767 deduped,
768 decremented_count,
769 preserved_count,
770 }
771}
772
773fn emit_reminder_lifecycle_records(transcript_id: Option<&str>, report: &ReminderCompactReport) {
774 for reminder in &report.expired {
775 let mut payload = reminder_lifecycle_payload(transcript_id, reminder);
776 if let Some(obj) = payload.as_object_mut() {
777 obj.insert(
778 "transcript_id".to_string(),
779 serde_json::json!(transcript_id),
780 );
781 obj.insert("reason".to_string(), JsonValue::String("ttl".to_string()));
782 obj.insert(
783 "ttl_turns_before".to_string(),
784 serde_json::json!(reminder.ttl_turns),
785 );
786 obj.insert("expired_at_turn".to_string(), JsonValue::Null);
787 obj.insert(
788 "expired_at_boundary".to_string(),
789 JsonValue::String("pre_compact".to_string()),
790 );
791 obj.insert(
792 "phase".to_string(),
793 JsonValue::String("pre_compact".to_string()),
794 );
795 }
796 emit_reminder_lifecycle_event(REMINDER_EXPIRED_EVENT_KIND, payload);
797 }
798
799 for reminder in &report.compacted {
800 let mut payload = reminder_lifecycle_payload(transcript_id, reminder);
801 if let Some(obj) = payload.as_object_mut() {
802 obj.insert(
803 "transcript_id".to_string(),
804 serde_json::json!(transcript_id),
805 );
806 obj.insert(
807 "reason".to_string(),
808 JsonValue::String("compaction".to_string()),
809 );
810 obj.insert(
811 "expired_at_boundary".to_string(),
812 JsonValue::String("pre_compact".to_string()),
813 );
814 obj.insert(
815 "phase".to_string(),
816 JsonValue::String("pre_compact".to_string()),
817 );
818 }
819 emit_reminder_lifecycle_event(REMINDER_EXPIRED_EVENT_KIND, payload);
820 }
821
822 if !report.deduped.is_empty() {
823 let dropped_reminder_ids = report
824 .deduped
825 .iter()
826 .map(|record| record.replaced_id.clone())
827 .collect::<Vec<_>>();
828 emit_reminder_lifecycle_event(
829 REMINDER_DEDUPED_EVENT_KIND,
830 serde_json::json!({
831 "transcript_id": transcript_id,
832 "boundary": "pre_compact",
833 "replaced_id": report.deduped.first().map(|record| &record.replaced_id),
834 "replacing_id": report.deduped.first().map(|record| &record.replacing_id),
835 "dedupe_key": report.deduped.first().map(|record| &record.dedupe_key),
836 "replaced_ids": &dropped_reminder_ids,
837 "dropped_reminder_ids": &dropped_reminder_ids,
838 "dropped_count": dropped_reminder_ids.len(),
839 }),
840 );
841 }
842}
843
844fn build_snapshot_asset(
845 transcript: &VmValue,
846 config: &AutoCompactConfig,
847 engine_strategy: &CompactStrategy,
848 archived_messages: usize,
849 estimated_tokens_before: usize,
850 estimated_tokens_after: usize,
851) -> VmValue {
852 let mut asset_metadata = BTreeMap::from([
853 (
854 "strategy".to_string(),
855 VmValue::String(Rc::from(compact_strategy_name(engine_strategy))),
856 ),
857 (
858 "archived_messages".to_string(),
859 VmValue::Int(archived_messages as i64),
860 ),
861 (
862 "estimated_tokens_before".to_string(),
863 VmValue::Int(estimated_tokens_before as i64),
864 ),
865 (
866 "estimated_tokens_after".to_string(),
867 VmValue::Int(estimated_tokens_after as i64),
868 ),
869 (
870 "instruction_mode".to_string(),
871 VmValue::String(Rc::from(config.policy.instruction_mode())),
872 ),
873 ]);
874 if let Some(policy_json) = config.policy.metadata_json() {
875 asset_metadata.insert(
876 "compaction_policy".to_string(),
877 crate::stdlib::json_to_vm_value(&policy_json),
878 );
879 }
880 if let Some(source) = config.policy.instruction_source() {
881 asset_metadata.insert(
882 "instruction_source".to_string(),
883 VmValue::String(Rc::from(source)),
884 );
885 }
886 let asset = VmValue::Dict(Rc::new(BTreeMap::from([
887 (
888 "id".to_string(),
889 VmValue::String(Rc::from(format!(
890 "compaction-source-{}",
891 uuid::Uuid::now_v7()
892 ))),
893 ),
894 (
895 "kind".to_string(),
896 VmValue::String(Rc::from("compaction_source_transcript")),
897 ),
898 (
899 "title".to_string(),
900 VmValue::String(Rc::from("Pre-compaction transcript")),
901 ),
902 (
903 "visibility".to_string(),
904 VmValue::String(Rc::from("internal")),
905 ),
906 ("data".to_string(), transcript.clone()),
907 (
908 "metadata".to_string(),
909 VmValue::Dict(Rc::new(asset_metadata)),
910 ),
911 ])));
912 normalize_transcript_asset(&asset)
913}
914
915fn snapshot_asset_id_of(asset: &VmValue) -> String {
916 asset
917 .as_dict()
918 .and_then(|dict| dict.get("id"))
919 .map(|value| value.display())
920 .unwrap_or_default()
921}
922
923pub fn transcript_compactable_events(transcript: &BTreeMap<String, VmValue>) -> Vec<VmValue> {
929 transcript
930 .get("events")
931 .and_then(|events| match events {
932 VmValue::List(list) => Some(
933 list.iter()
934 .filter(|event| {
935 event
936 .as_dict()
937 .and_then(|dict| dict.get("kind"))
938 .map(|value| value.display())
939 .is_some_and(|kind| kind != "message" && kind != "tool_result")
940 })
941 .cloned()
942 .collect(),
943 ),
944 _ => None,
945 })
946 .unwrap_or_default()
947}
948
949#[cfg(test)]
950mod tests {
951 use super::*;
952 use crate::llm::helpers::{ReminderPropagate, ReminderRoleHint, ReminderSource};
953
954 fn reminder_event_value(body: &str, preserve: bool, ttl: Option<i64>) -> VmValue {
955 let reminder = SystemReminder {
956 id: format!("rem-{}", uuid::Uuid::now_v7()),
957 tags: Vec::new(),
958 dedupe_key: None,
959 ttl_turns: ttl,
960 preserve_on_compact: preserve,
961 propagate: ReminderPropagate::Session,
962 role_hint: ReminderRoleHint::System,
963 source: ReminderSource::StdlibProvider,
964 body: body.to_string(),
965 fired_at_turn: 0,
966 originating_agent_id: None,
967 };
968 let reminder_value =
969 crate::stdlib::json_to_vm_value(&serde_json::to_value(&reminder).unwrap());
970 let mut event = BTreeMap::new();
971 event.insert(
972 "kind".to_string(),
973 VmValue::String(std::rc::Rc::from("system_reminder")),
974 );
975 event.insert(
976 "role".to_string(),
977 VmValue::String(std::rc::Rc::from("system")),
978 );
979 event.insert("reminder".to_string(), reminder_value);
980 VmValue::Dict(std::rc::Rc::new(event))
981 }
982
983 #[test]
984 fn preserve_on_compact_reminder_survives_lifecycle() {
985 let preserved = reminder_event_value("keep me", true, None);
986 let droppable = reminder_event_value("drop me", false, None);
987 let report = compact_reminder_events(vec![preserved, droppable]);
988 assert_eq!(report.preserved_count, 1);
989 assert_eq!(report.compacted.len(), 1);
990 assert_eq!(report.preserved_events.len(), 1);
991 assert!(report.preserved_events.iter().any(|event| {
992 event
993 .as_dict()
994 .and_then(|dict| dict.get("reminder"))
995 .and_then(|reminder| reminder.as_dict())
996 .and_then(|reminder| reminder.get("body"))
997 .map(|body| body.display())
998 .is_some_and(|body| body == "keep me")
999 }));
1000 }
1001
1002 #[test]
1003 fn ttl_one_reminder_expires_during_lifecycle() {
1004 let ttl_one = reminder_event_value("ephemeral", false, Some(1));
1005 let report = compact_reminder_events(vec![ttl_one]);
1006 assert_eq!(report.expired.len(), 1);
1007 assert_eq!(report.preserved_count, 0);
1008 }
1009
1010 #[test]
1011 fn ttl_above_one_decrements_and_keeps() {
1012 let ttl_three = reminder_event_value("keep ttl", false, Some(3));
1013 let report = compact_reminder_events(vec![ttl_three]);
1014 assert_eq!(report.decremented_count, 1);
1015 assert_eq!(report.preserved_events.len(), 0);
1016 assert_eq!(report.compacted.len(), 1);
1017 }
1018
1019 #[test]
1020 fn fires_hooks_only_for_session_owning_modes() {
1021 assert!(CompactMode::Manual.fires_hooks());
1023 assert!(CompactMode::Host.fires_hooks());
1024 assert!(CompactMode::Auto.fires_hooks());
1025 assert!(!CompactMode::Workflow.fires_hooks());
1029 assert!(!CompactMode::Worker.fires_hooks());
1030 assert!(!CompactMode::ResumeDigest.fires_hooks());
1031 }
1032}