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