Skip to main content

harn_vm/orchestration/
compact_lifecycle.rs

1//! Centralized compaction lifecycle.
2//!
3//! Every transcript compaction in the runtime — manual `transcript_compact()`,
4//! `agent_session_compact()`, `transcript_auto_compact()`, worker-transcript
5//! compaction during resume, and host-script-driven auto-compaction — funnels
6//! through [`run_compaction_lifecycle`] so the hook contract, reminder
7//! lifecycle, and `AgentEvent::TranscriptCompacted` payload are identical
8//! regardless of entry point.
9//!
10//! Lifecycle ordering:
11//!
12//! 1. Estimate tokens before.
13//! 2. Build the `PreCompact` payload.
14//! 3. Fire `PreCompact` lifecycle hooks with veto/modify control. `Block`
15//!    cancels compaction; `Modify` applies caller-facing overrides
16//!    (`keep_last`, `target_tokens`, `strategy`) back to the config.
17//! 4. Run the reminder lifecycle (`preserve_on_compact`, `ttl_turns`,
18//!    `dedupe_key`) over the caller-supplied reminder events.
19//! 5. Invoke [`auto_compact_messages`] to perform the actual compaction.
20//! 6. Emit per-reminder lifecycle events (`expired`, `deduped`).
21//! 7. Build the `PostCompact` payload with archived count, summary, and
22//!    optional snapshot asset id.
23//! 8. Fire `PostCompact` lifecycle hooks (non-veto).
24//! 9. Re-evaluate registered reminder providers against the post-compact
25//!    payload so injected reminders land on the next turn.
26//! 10. Emit `AgentEvent::TranscriptCompacted` when the call carries a
27//!     `session_id`.
28
29use 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/// Identifies the call-site that initiated compaction. The string form is
51/// exposed in hook payloads and `AgentEvent::TranscriptCompacted` so
52/// downstream consumers can route user-initiated compactions differently from
53/// automatic agent-loop ones.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum CompactMode {
56    /// `transcript_compact()` stdlib builtin (user-initiated, transcript dict in,
57    /// transcript dict out).
58    Manual,
59    /// `agent_session_compact()` stdlib builtin (host-initiated, mutates an
60    /// active agent session in place).
61    Host,
62    /// In-agent-loop automatic compaction emitted by host scripts after the
63    /// turn-budget check fires. Mirrors what `host_agent_record_compaction`
64    /// historically labelled as `auto`.
65    Auto,
66    /// `transcript_auto_compact()` workflow builtin operating on a raw message
67    /// list with no owning session.
68    Workflow,
69    /// Worker-transcript compaction during snapshot resume.
70    Worker,
71    /// Resume-time digest extraction (kept verbatim so the bypass remains
72    /// observable). No hooks fire for this mode.
73    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    /// Session-level `PreCompact` / `PostCompact` hooks fire only for
89    /// modes that operate against an owning agent session. The other
90    /// modes are utility wrappers around raw message lists or worker
91    /// transcripts — their callers (e.g. the `.harn` agent loop)
92    /// orchestrate the session-level hook firing separately, so the
93    /// lifecycle path here must stay silent to avoid double-dispatch.
94    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/// Identifies why compaction fired. This is separate from [`CompactMode`]:
103/// mode describes the caller surface, while trigger explains the pressure
104/// that made the caller compact.
105#[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
122/// Per-call inputs that travel with a compaction request through the
123/// lifecycle. Stored as references to keep allocations down on the hot path.
124pub 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    /// Reminder events from the source transcript that should pass through
131    /// the `preserve_on_compact` / `ttl_turns` / `dedupe_key` lifecycle
132    /// before being re-attached to the compacted transcript.
133    pub reminder_events: Vec<VmValue>,
134    /// Caller-supplied summary override. When `Some`, replaces the
135    /// `auto_compact_messages` output before the post-compact payload is
136    /// assembled. Used by `transcript_compact()` to support pre-computed
137    /// summaries.
138    pub summary_override: Option<String>,
139    /// Provider options forwarded to `evaluate_and_inject` so registered
140    /// providers see the same shape the caller observed.
141    pub provider_options: JsonValue,
142    /// Optional source-transcript value used to build a pre-compaction
143    /// snapshot asset. Paths that don't have a transcript dict (e.g.,
144    /// `transcript_auto_compact()` on a raw list) leave this `None` and
145    /// the post-compact payload omits `snapshot_asset_id`.
146    pub source_transcript: Option<&'a VmValue>,
147    /// Whether to invoke the registered reminder providers after the
148    /// post-compact hook chain. Only meaningful when `session_id` is set.
149    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
222/// Result of a successful compaction. Returned to callers so they can
223/// finalize their own persistence (transcript dict assembly, agent-session
224/// replacement, snapshot recording). The messages themselves are mutated in
225/// place on the caller's `Vec` so a no-op return (`Ok(None)`) leaves them
226/// unchanged for downstream code that always writes the messages back.
227pub 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    /// Snapshot asset built from the caller-supplied source transcript.
234    /// `None` when no source transcript was provided.
235    pub snapshot_asset: Option<VmValue>,
236    /// `snapshot_asset.id` extracted for inclusion in event payloads.
237    pub snapshot_asset_id: Option<String>,
238    /// Engine strategy actually used (after honoring any PreCompact `Modify`).
239    pub strategy: CompactStrategy,
240    /// User-facing policy label resolved on the config.
241    pub policy_strategy: String,
242    /// `metadata` block ready to attach to the persisted transcript
243    /// `"compaction"` event. Includes policy fields + reminder counts.
244    pub event_metadata: JsonValue,
245    /// Observation-mask recap receipt, `None` for non-masking strategies.
246    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    /// Observation-mask recap receipt: `{recap_bytes, budget_bytes,
256    /// kept_results_count, dropped_count, carried_prior_recap}`. `None` for the
257    /// LLM/truncate/custom strategies, which do not spend a recap budget.
258    pub recap: Option<JsonValue>,
259}
260
261/// Reminder-lifecycle bookkeeping produced before the compaction runs and
262/// consumed by both the persisted transcript and the AgentEvent payload.
263#[derive(Debug, Default)]
264pub struct ReminderCompactReport {
265    /// Non-reminder events plus reminders flagged `preserve_on_compact`.
266    /// Callers re-attach these to the compacted transcript.
267    pub preserved_events: Vec<VmValue>,
268    /// Reminder values handed to `custom_compactor` callbacks so user
269    /// scripts can fold pending reminders into their summarization output.
270    pub custom_reminders: Vec<VmValue>,
271    /// Reminders whose `ttl_turns` reached zero this compaction.
272    pub expired: Vec<SystemReminder>,
273    /// Reminders that were folded into the compacted summary because
274    /// they had no `preserve_on_compact` flag.
275    pub compacted: Vec<SystemReminder>,
276    /// Reminders dropped because a newer reminder with the same
277    /// `dedupe_key` was retained.
278    pub deduped: Vec<ReminderDedupeRecord>,
279    /// Count of reminders whose `ttl_turns` were decremented (still alive).
280    pub decremented_count: usize,
281    /// Count of reminders that carried `preserve_on_compact = true`.
282    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
292/// Run a transcript compaction through the canonical lifecycle. The
293/// `messages` vec is mutated in place by [`auto_compact_messages_with_result`]; on a
294/// `Ok(None)` return it is left untouched so callers that always write
295/// messages back (e.g. `transcript_auto_compact()`) can do so unconditionally.
296///
297/// `Ok(None)` means no compaction happened — either the messages were
298/// already under threshold, a PreCompact hook returned `Block`, or
299/// `auto_compact_messages_with_result` itself decided there was nothing to do.
300pub(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    // Move `reminder_events` out up front so subsequent reads of
317    // `lifecycle` don't trip the partial-move check.
318    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
450/// Emit `AgentEvent::TranscriptCompacted` with the shared payload shape.
451/// Exposed for the host-script `host_agent_record_compaction` builtin which
452/// records compactions performed entirely from `.harn` code; lifecycle
453/// callers reach this through [`run_compaction_lifecycle`].
454pub 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
482/// Synchronous variant of [`emit_transcript_compacted_event`]. Used by
483/// `host_agent_record_compaction` which runs in a sync builtin context and
484/// can't `.await` directly.
485pub 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
509// ---------------------------------------------------------------------------
510// Internal payload + reminder helpers shared by stdlib builtins and the
511// agent-session host.
512// ---------------------------------------------------------------------------
513
514enum 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
701/// Process a list of reminder events through the canonical lifecycle:
702/// expire by TTL, decrement remaining TTLs, dedupe by `dedupe_key`, and
703/// retain `preserve_on_compact` reminders for re-attachment.
704pub 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
949/// Extract the events from a transcript-shaped dict that should be routed
950/// through [`run_compaction_lifecycle`] (everything except `message` and
951/// `tool_result` events). This is the canonical filter used by every
952/// transcript-having compaction caller — keeping it in one place stops the
953/// trivial-but-load-bearing filter list from drifting per-callsite.
954pub 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        // Session-aware entry points fire hooks.
1043        assert!(CompactMode::Manual.fires_hooks());
1044        assert!(CompactMode::Host.fires_hooks());
1045        assert!(CompactMode::Auto.fires_hooks());
1046        // Utility paths stay silent so callers (`.harn` agent loop,
1047        // worker resume) can orchestrate session-level hooks
1048        // themselves without double-dispatch.
1049        assert!(!CompactMode::Workflow.fires_hooks());
1050        assert!(!CompactMode::Worker.fires_hooks());
1051        assert!(!CompactMode::ResumeDigest.fires_hooks());
1052    }
1053}