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, parse_compact_strategy,
46 run_lifecycle_hooks_with_control_with_ctx, run_lifecycle_hooks_with_ctx, AutoCompactConfig,
47 CompactStrategy, CompactionPolicy, 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 pub recap_metrics: Option<super::RecapMetrics>,
247}
248
249#[derive(Clone, Debug, Default)]
250pub struct TranscriptCompactedEventMetrics {
251 pub archived_messages: usize,
252 pub estimated_tokens_before: usize,
253 pub estimated_tokens_after: usize,
254 pub snapshot_asset_id: Option<String>,
255 pub recap: Option<JsonValue>,
259}
260
261#[derive(Debug, Default)]
264pub struct ReminderCompactReport {
265 pub preserved_events: Vec<VmValue>,
268 pub custom_reminders: Vec<VmValue>,
271 pub expired: Vec<SystemReminder>,
273 pub compacted: Vec<SystemReminder>,
276 pub deduped: Vec<ReminderDedupeRecord>,
279 pub decremented_count: usize,
281 pub preserved_count: usize,
283}
284
285#[derive(Clone, Debug)]
286pub struct ReminderDedupeRecord {
287 pub replaced_id: String,
288 pub replacing_id: String,
289 pub dedupe_key: String,
290}
291
292pub(crate) async fn run_compaction_lifecycle(
301 messages: &mut Vec<JsonValue>,
302 config: &mut AutoCompactConfig,
303 llm_opts: Option<&LlmCallOptions>,
304 lifecycle: CompactLifecycle<'_>,
305) -> Result<Option<CompactionOutcome>, VmError> {
306 run_compaction_lifecycle_with_ctx(None, messages, config, llm_opts, lifecycle).await
307}
308
309pub(crate) async fn run_compaction_lifecycle_with_ctx(
310 ctx: Option<&crate::vm::AsyncBuiltinCtx>,
311 messages: &mut Vec<JsonValue>,
312 config: &mut AutoCompactConfig,
313 llm_opts: Option<&LlmCallOptions>,
314 mut lifecycle: CompactLifecycle<'_>,
315) -> Result<Option<CompactionOutcome>, VmError> {
316 let reminder_events = std::mem::take(&mut lifecycle.reminder_events);
319
320 let estimated_tokens_before = estimate_message_tokens(messages);
321 let original_message_count = messages.len();
322
323 let fires_hooks = lifecycle.fire_hooks;
324
325 if fires_hooks {
326 let pre_payload = build_hook_payload(
327 HookEvent::PreCompact,
328 &lifecycle,
329 config,
330 HookPayloadStage::Pre {
331 message_count: original_message_count,
332 estimated_tokens_before,
333 },
334 );
335 match run_lifecycle_hooks_with_control_with_ctx(ctx, HookEvent::PreCompact, &pre_payload)
336 .await?
337 {
338 HookControl::Block { .. } => return Ok(None),
339 HookControl::Modify { payload } => apply_pre_modify_overrides(config, &payload)?,
340 HookControl::Allow | HookControl::Decision { .. } => {}
341 }
342 }
343
344 let reminder_report = compact_reminder_events(reminder_events);
345 config.custom_compactor_reminders = reminder_report.custom_reminders.clone();
346
347 let Some(compact_result) =
348 auto_compact_messages_with_result_with_ctx(ctx, messages, config, llm_opts).await?
349 else {
350 return Ok(None);
351 };
352 let engine_strategy = compact_result.strategy;
353 let raw_summary = compact_result.summary;
354 let recap_metrics = compact_result.recap_metrics;
355 let summary = lifecycle.summary_override.clone().unwrap_or(raw_summary);
356
357 if fires_hooks {
358 emit_reminder_lifecycle_records(lifecycle.transcript_id, &reminder_report);
359 }
360
361 let estimated_tokens_after = estimate_message_tokens(messages);
362 let archived_messages = original_message_count
363 .saturating_sub(messages.len())
364 .saturating_add(1);
365
366 let snapshot_asset = lifecycle.source_transcript.map(|transcript| {
367 build_snapshot_asset(
368 transcript,
369 config,
370 &engine_strategy,
371 archived_messages,
372 estimated_tokens_before,
373 estimated_tokens_after,
374 )
375 });
376 let snapshot_asset_id = snapshot_asset.as_ref().map(snapshot_asset_id_of);
377 let event_metrics = TranscriptCompactedEventMetrics {
378 archived_messages,
379 estimated_tokens_before,
380 estimated_tokens_after,
381 snapshot_asset_id: snapshot_asset_id.clone(),
382 recap: recap_metrics.map(super::RecapMetrics::to_json),
383 };
384
385 let event_metadata = build_event_metadata(
386 &lifecycle,
387 config,
388 &event_metrics,
389 &reminder_report,
390 &summary,
391 &engine_strategy,
392 );
393
394 if fires_hooks {
395 let post_payload = build_hook_payload(
396 HookEvent::PostCompact,
397 &lifecycle,
398 config,
399 HookPayloadStage::Post {
400 original_message_count,
401 remaining_messages: messages.len(),
402 archived_messages,
403 estimated_tokens_before,
404 estimated_tokens_after,
405 summary: &summary,
406 snapshot_asset_id: snapshot_asset_id.as_deref(),
407 reminder_report: &reminder_report,
408 },
409 );
410 run_lifecycle_hooks_with_ctx(ctx, HookEvent::PostCompact, &post_payload).await?;
411
412 if let Some(session_id) = lifecycle.session_id {
413 emit_transcript_compacted_event(
414 ctx,
415 session_id,
416 lifecycle.mode,
417 lifecycle.trigger.as_str(),
418 config,
419 event_metrics.clone(),
420 )
421 .await;
422 if lifecycle.evaluate_providers {
423 let _ = crate::llm::reminder_providers::evaluate_and_inject(
424 ctx,
425 HookEvent::PostCompact,
426 session_id,
427 post_payload,
428 lifecycle.provider_options.clone(),
429 )
430 .await;
431 }
432 }
433 }
434
435 Ok(Some(CompactionOutcome {
436 summary,
437 archived_messages,
438 estimated_tokens_before,
439 estimated_tokens_after,
440 reminder_report,
441 snapshot_asset,
442 snapshot_asset_id,
443 strategy: engine_strategy,
444 policy_strategy: config.policy_strategy.clone(),
445 event_metadata,
446 recap_metrics,
447 }))
448}
449
450pub async fn emit_transcript_compacted_event(
455 ctx: Option<&crate::vm::AsyncBuiltinCtx>,
456 session_id: &str,
457 mode: CompactMode,
458 reason: &str,
459 config: &AutoCompactConfig,
460 metrics: TranscriptCompactedEventMetrics,
461) {
462 crate::llm::emit_live_agent_event_with_ctx(
463 ctx,
464 &AgentEvent::TranscriptCompacted {
465 session_id: session_id.to_string(),
466 mode: mode.as_str().to_string(),
467 reason: reason.to_string(),
468 strategy: config.policy_strategy.clone(),
469 archived_messages: metrics.archived_messages,
470 estimated_tokens_before: metrics.estimated_tokens_before,
471 estimated_tokens_after: metrics.estimated_tokens_after,
472 snapshot_asset_id: metrics.snapshot_asset_id,
473 instruction_mode: Some(config.policy.instruction_mode().to_string()),
474 instruction_source: config.policy.instruction_source().map(str::to_string),
475 compaction_policy: config.policy.metadata_json(),
476 recap: metrics.recap,
477 },
478 )
479 .await;
480}
481
482pub fn emit_transcript_compacted_event_sync(
486 session_id: &str,
487 mode: CompactMode,
488 reason: String,
489 policy: &CompactionPolicy,
490 policy_strategy: String,
491 metrics: TranscriptCompactedEventMetrics,
492) {
493 crate::llm::emit_live_agent_event_sync(&AgentEvent::TranscriptCompacted {
494 session_id: session_id.to_string(),
495 mode: mode.as_str().to_string(),
496 reason,
497 strategy: policy_strategy,
498 archived_messages: metrics.archived_messages,
499 estimated_tokens_before: metrics.estimated_tokens_before,
500 estimated_tokens_after: metrics.estimated_tokens_after,
501 snapshot_asset_id: metrics.snapshot_asset_id,
502 instruction_mode: Some(policy.instruction_mode().to_string()),
503 instruction_source: policy.instruction_source().map(str::to_string),
504 compaction_policy: policy.metadata_json(),
505 recap: metrics.recap,
506 });
507}
508
509enum HookPayloadStage<'a> {
515 Pre {
516 message_count: usize,
517 estimated_tokens_before: usize,
518 },
519 Post {
520 original_message_count: usize,
521 remaining_messages: usize,
522 archived_messages: usize,
523 estimated_tokens_before: usize,
524 estimated_tokens_after: usize,
525 summary: &'a str,
526 snapshot_asset_id: Option<&'a str>,
527 reminder_report: &'a ReminderCompactReport,
528 },
529}
530
531fn build_hook_payload(
532 event: HookEvent,
533 lifecycle: &CompactLifecycle<'_>,
534 config: &AutoCompactConfig,
535 stage: HookPayloadStage<'_>,
536) -> JsonValue {
537 let session_id = lifecycle.session_id.unwrap_or_default();
538 let strategy = compact_strategy_name(&config.compact_strategy);
539 let mut payload = serde_json::json!({
540 "event": event.as_str(),
541 "session": {"id": session_id},
542 "session_id": session_id,
543 "mode": lifecycle.mode.as_str(),
544 "reason": lifecycle.trigger.as_str(),
545 "strategy": strategy,
546 "engine_strategy": strategy,
547 "keep_last": config.keep_last,
548 "target_tokens": serde_json::Value::Null,
549 });
550 if config.token_threshold > 0 {
551 payload["target_tokens"] = serde_json::json!(config.token_threshold);
552 }
553 let Some(map) = payload.as_object_mut() else {
554 return payload;
555 };
556 for (key, value) in compaction_policy_metadata_fields(&config.policy) {
557 map.insert(key.to_string(), value);
558 }
559 match stage {
560 HookPayloadStage::Pre {
561 message_count,
562 estimated_tokens_before,
563 } => {
564 map.insert(
565 "message_count".to_string(),
566 serde_json::json!(message_count),
567 );
568 map.insert(
569 "estimated_tokens_before".to_string(),
570 serde_json::json!(estimated_tokens_before),
571 );
572 }
573 HookPayloadStage::Post {
574 original_message_count,
575 remaining_messages,
576 archived_messages,
577 estimated_tokens_before,
578 estimated_tokens_after,
579 summary,
580 snapshot_asset_id,
581 reminder_report,
582 } => {
583 map.insert(
584 "message_count".to_string(),
585 serde_json::json!(original_message_count),
586 );
587 map.insert(
588 "remaining_messages".to_string(),
589 serde_json::json!(remaining_messages),
590 );
591 map.insert(
592 "archived_messages".to_string(),
593 serde_json::json!(archived_messages),
594 );
595 map.insert(
596 "estimated_tokens_before".to_string(),
597 serde_json::json!(estimated_tokens_before),
598 );
599 map.insert(
600 "estimated_tokens_after".to_string(),
601 serde_json::json!(estimated_tokens_after),
602 );
603 map.insert("summary".to_string(), serde_json::json!(summary));
604 map.insert(
605 "new_summary_len".to_string(),
606 serde_json::json!(summary.len()),
607 );
608 if let Some(id) = snapshot_asset_id {
609 map.insert("snapshot_asset_id".to_string(), serde_json::json!(id));
610 }
611 map.insert(
612 "reminders_decremented".to_string(),
613 serde_json::json!(reminder_report.decremented_count),
614 );
615 map.insert(
616 "reminders_expired".to_string(),
617 serde_json::json!(reminder_report.expired.len()),
618 );
619 map.insert(
620 "reminders_deduped".to_string(),
621 serde_json::json!(reminder_report.deduped.len()),
622 );
623 map.insert(
624 "reminders_preserved".to_string(),
625 serde_json::json!(reminder_report.preserved_count),
626 );
627 }
628 }
629 payload
630}
631
632fn apply_pre_modify_overrides(
633 config: &mut AutoCompactConfig,
634 payload: &JsonValue,
635) -> Result<(), VmError> {
636 let Some(map) = payload.as_object() else {
637 return Ok(());
638 };
639 if let Some(value) = map.get("keep_last").and_then(JsonValue::as_u64) {
640 config.keep_last = value as usize;
641 }
642 if let Some(value) = map.get("target_tokens").and_then(JsonValue::as_u64) {
643 config.token_threshold = value as usize;
644 config.hard_limit_tokens = Some(value as usize);
645 }
646 if let Some(value) = map.get("strategy").or_else(|| map.get("engine_strategy")) {
647 if let Some(name) = value.as_str() {
648 let strategy = parse_compact_strategy(name)?;
649 config.policy_strategy = compact_strategy_name(&strategy).to_string();
650 config.compact_strategy = strategy;
651 }
652 }
653 Ok(())
654}
655
656fn build_event_metadata(
657 lifecycle: &CompactLifecycle<'_>,
658 config: &AutoCompactConfig,
659 metrics: &TranscriptCompactedEventMetrics,
660 reminder_report: &ReminderCompactReport,
661 summary: &str,
662 engine_strategy: &CompactStrategy,
663) -> JsonValue {
664 let mut metadata = serde_json::json!({
665 "mode": lifecycle.mode.as_str(),
666 "reason": lifecycle.trigger.as_str(),
667 "strategy": config.policy_strategy,
668 "engine_strategy": compact_strategy_name(engine_strategy),
669 "keep_last": config.keep_last,
670 "target_tokens": (config.token_threshold > 0).then_some(config.token_threshold),
671 "archived_messages": metrics.archived_messages,
672 "estimated_tokens_before": metrics.estimated_tokens_before,
673 "estimated_tokens_after": metrics.estimated_tokens_after,
674 "new_summary_len": summary.len(),
675 "snapshot_asset_id": metrics.snapshot_asset_id.as_deref(),
676 "reminders_decremented": reminder_report.decremented_count,
677 "reminders_expired": reminder_report.expired.len(),
678 "reminders_deduped": reminder_report.deduped.len(),
679 "reminders_preserved": reminder_report.preserved_count,
680 });
681 if let Some(map) = metadata.as_object_mut() {
682 for (key, value) in compaction_policy_metadata_fields(&config.policy) {
683 map.insert(key.to_string(), value);
684 }
685 if let Some(recap) = metrics.recap.clone() {
686 map.insert("recap".to_string(), recap);
687 }
688 }
689 metadata
690}
691
692enum CompactEvent {
693 Other(VmValue),
694 Reminder {
695 event: VmValue,
696 reminder: SystemReminder,
697 reminder_index: usize,
698 },
699}
700
701pub fn compact_reminder_events(extra_events: Vec<VmValue>) -> ReminderCompactReport {
705 let mut events = Vec::with_capacity(extra_events.len());
706 let mut reminders = Vec::new();
707 let mut expired = Vec::new();
708 let mut decremented_count = 0;
709
710 for event in extra_events {
711 let Some(reminder) = reminder_from_event(&event) else {
712 events.push(CompactEvent::Other(event));
713 continue;
714 };
715
716 let (event, reminder) = match reminder.ttl_turns {
717 Some(ttl) if ttl <= 1 => {
718 expired.push(reminder);
719 continue;
720 }
721 Some(ttl) => {
722 let mut updated = reminder;
723 updated.ttl_turns = Some(ttl - 1);
724 decremented_count += 1;
725 (replace_reminder_payload(&event, &updated), updated)
726 }
727 None => (event, reminder),
728 };
729
730 let reminder_index = reminders.len();
731 reminders.push(reminder.clone());
732 events.push(CompactEvent::Reminder {
733 event,
734 reminder,
735 reminder_index,
736 });
737 }
738
739 let mut newest_by_dedupe_key = BTreeMap::new();
740 for (index, reminder) in reminders.iter().enumerate() {
741 if let Some(dedupe_key) = reminder.dedupe_key.as_deref() {
742 newest_by_dedupe_key.insert(dedupe_key.to_string(), index);
743 }
744 }
745
746 let mut kept_reminders = Vec::new();
747 let mut preserved_events = Vec::new();
748 let mut compacted = Vec::new();
749 let mut deduped = Vec::new();
750 let mut preserved_count = 0;
751
752 for event in events {
753 match event {
754 CompactEvent::Other(event) => preserved_events.push(event),
755 CompactEvent::Reminder {
756 event,
757 reminder,
758 reminder_index,
759 } => {
760 let keep = reminder
761 .dedupe_key
762 .as_deref()
763 .and_then(|key| newest_by_dedupe_key.get(key))
764 .is_none_or(|newest| *newest == reminder_index);
765 if !keep {
766 let replacing_id = reminder
767 .dedupe_key
768 .as_deref()
769 .and_then(|key| newest_by_dedupe_key.get(key))
770 .and_then(|index| reminders.get(*index))
771 .map(|newest| newest.id.clone())
772 .unwrap_or_default();
773 deduped.push(ReminderDedupeRecord {
774 replaced_id: reminder.id.clone(),
775 replacing_id,
776 dedupe_key: reminder.dedupe_key.clone().unwrap_or_default(),
777 });
778 continue;
779 }
780
781 kept_reminders.push(crate::stdlib::json_to_vm_value(
782 &serde_json::to_value(&reminder).unwrap_or(JsonValue::Null),
783 ));
784 if reminder.preserve_on_compact {
785 preserved_count += 1;
786 preserved_events.push(event);
787 } else {
788 compacted.push(reminder);
789 }
790 }
791 }
792 }
793
794 ReminderCompactReport {
795 preserved_events,
796 custom_reminders: kept_reminders,
797 expired,
798 compacted,
799 deduped,
800 decremented_count,
801 preserved_count,
802 }
803}
804
805fn emit_reminder_lifecycle_records(transcript_id: Option<&str>, report: &ReminderCompactReport) {
806 for reminder in &report.expired {
807 let mut payload = reminder_lifecycle_payload(transcript_id, reminder);
808 if let Some(obj) = payload.as_object_mut() {
809 obj.insert(
810 "transcript_id".to_string(),
811 serde_json::json!(transcript_id),
812 );
813 obj.insert("reason".to_string(), JsonValue::String("ttl".to_string()));
814 obj.insert(
815 "ttl_turns_before".to_string(),
816 serde_json::json!(reminder.ttl_turns),
817 );
818 obj.insert("expired_at_turn".to_string(), JsonValue::Null);
819 obj.insert(
820 "expired_at_boundary".to_string(),
821 JsonValue::String("pre_compact".to_string()),
822 );
823 obj.insert(
824 "phase".to_string(),
825 JsonValue::String("pre_compact".to_string()),
826 );
827 }
828 emit_reminder_lifecycle_event(REMINDER_EXPIRED_EVENT_KIND, payload);
829 }
830
831 for reminder in &report.compacted {
832 let mut payload = reminder_lifecycle_payload(transcript_id, reminder);
833 if let Some(obj) = payload.as_object_mut() {
834 obj.insert(
835 "transcript_id".to_string(),
836 serde_json::json!(transcript_id),
837 );
838 obj.insert(
839 "reason".to_string(),
840 JsonValue::String("compaction".to_string()),
841 );
842 obj.insert(
843 "expired_at_boundary".to_string(),
844 JsonValue::String("pre_compact".to_string()),
845 );
846 obj.insert(
847 "phase".to_string(),
848 JsonValue::String("pre_compact".to_string()),
849 );
850 }
851 emit_reminder_lifecycle_event(REMINDER_EXPIRED_EVENT_KIND, payload);
852 }
853
854 if !report.deduped.is_empty() {
855 let dropped_reminder_ids = report
856 .deduped
857 .iter()
858 .map(|record| record.replaced_id.clone())
859 .collect::<Vec<_>>();
860 emit_reminder_lifecycle_event(
861 REMINDER_DEDUPED_EVENT_KIND,
862 serde_json::json!({
863 "transcript_id": transcript_id,
864 "boundary": "pre_compact",
865 "replaced_id": report.deduped.first().map(|record| &record.replaced_id),
866 "replacing_id": report.deduped.first().map(|record| &record.replacing_id),
867 "dedupe_key": report.deduped.first().map(|record| &record.dedupe_key),
868 "replaced_ids": &dropped_reminder_ids,
869 "dropped_reminder_ids": &dropped_reminder_ids,
870 "dropped_count": dropped_reminder_ids.len(),
871 }),
872 );
873 }
874}
875
876fn build_snapshot_asset(
877 transcript: &VmValue,
878 config: &AutoCompactConfig,
879 engine_strategy: &CompactStrategy,
880 archived_messages: usize,
881 estimated_tokens_before: usize,
882 estimated_tokens_after: usize,
883) -> VmValue {
884 let mut asset_metadata = BTreeMap::from([
885 (
886 "strategy".to_string(),
887 VmValue::String(arcstr::ArcStr::from(compact_strategy_name(engine_strategy))),
888 ),
889 (
890 "archived_messages".to_string(),
891 VmValue::Int(archived_messages as i64),
892 ),
893 (
894 "estimated_tokens_before".to_string(),
895 VmValue::Int(estimated_tokens_before as i64),
896 ),
897 (
898 "estimated_tokens_after".to_string(),
899 VmValue::Int(estimated_tokens_after as i64),
900 ),
901 (
902 "instruction_mode".to_string(),
903 VmValue::String(arcstr::ArcStr::from(config.policy.instruction_mode())),
904 ),
905 ]);
906 if let Some(policy_json) = config.policy.metadata_json() {
907 asset_metadata.insert(
908 "compaction_policy".to_string(),
909 crate::stdlib::json_to_vm_value(&policy_json),
910 );
911 }
912 if let Some(source) = config.policy.instruction_source() {
913 asset_metadata.put_str("instruction_source", source);
914 }
915 let asset = VmValue::dict(BTreeMap::from([
916 (
917 "id".to_string(),
918 VmValue::String(arcstr::ArcStr::from(format!(
919 "compaction-source-{}",
920 uuid::Uuid::now_v7()
921 ))),
922 ),
923 (
924 "kind".to_string(),
925 VmValue::String(arcstr::ArcStr::from("compaction_source_transcript")),
926 ),
927 (
928 "title".to_string(),
929 VmValue::String(arcstr::ArcStr::from("Pre-compaction transcript")),
930 ),
931 (
932 "visibility".to_string(),
933 VmValue::String(arcstr::ArcStr::from("internal")),
934 ),
935 ("data".to_string(), transcript.clone()),
936 ("metadata".to_string(), VmValue::dict(asset_metadata)),
937 ]));
938 normalize_transcript_asset(&asset)
939}
940
941fn snapshot_asset_id_of(asset: &VmValue) -> String {
942 asset
943 .as_dict()
944 .and_then(|dict| dict.get("id"))
945 .map(|value| value.display())
946 .unwrap_or_default()
947}
948
949pub fn transcript_compactable_events(transcript: &crate::value::DictMap) -> Vec<VmValue> {
955 transcript
956 .get("events")
957 .and_then(|events| match events {
958 VmValue::List(list) => Some(
959 list.iter()
960 .filter(|event| {
961 event
962 .as_dict()
963 .and_then(|dict| dict.get("kind"))
964 .map(|value| value.display())
965 .is_some_and(|kind| kind != "message" && kind != "tool_result")
966 })
967 .cloned()
968 .collect(),
969 ),
970 _ => None,
971 })
972 .unwrap_or_default()
973}
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978 use crate::llm::helpers::{ReminderPropagate, ReminderRoleHint, ReminderSource};
979 use crate::value::VmDictExt;
980
981 fn reminder_event_value(body: &str, preserve: bool, ttl: Option<i64>) -> VmValue {
982 let reminder = SystemReminder {
983 id: format!("rem-{}", uuid::Uuid::now_v7()),
984 tags: Vec::new(),
985 dedupe_key: None,
986 ttl_turns: ttl,
987 preserve_on_compact: preserve,
988 propagate: ReminderPropagate::Session,
989 role_hint: ReminderRoleHint::System,
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}