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//! `harness.agent.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, 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/// Identifies the call-site that initiated compaction. The string form is
52/// exposed in hook payloads and `AgentEvent::TranscriptCompacted` so
53/// downstream consumers can route user-initiated compactions differently from
54/// automatic agent-loop ones.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum CompactMode {
57    /// `transcript_compact()` stdlib builtin (user-initiated, transcript dict in,
58    /// transcript dict out).
59    Manual,
60    /// `harness.agent.compact()` stdlib builtin (host-initiated, mutates an
61    /// active agent session in place).
62    Host,
63    /// In-agent-loop automatic compaction emitted by host scripts after the
64    /// turn-budget check fires. Mirrors what `host_agent_record_compaction`
65    /// historically labelled as `auto`.
66    Auto,
67    /// `transcript_auto_compact()` workflow builtin operating on a raw message
68    /// list with no owning session.
69    Workflow,
70    /// Worker-transcript compaction during snapshot resume.
71    Worker,
72    /// Resume-time digest extraction (kept verbatim so the bypass remains
73    /// observable). No hooks fire for this mode.
74    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    /// Session-level `PreCompact` / `PostCompact` hooks fire only for
90    /// modes that operate against an owning agent session. The other
91    /// modes are utility wrappers around raw message lists or worker
92    /// transcripts — their callers (e.g. the `.harn` agent loop)
93    /// orchestrate the session-level hook firing separately, so the
94    /// lifecycle path here must stay silent to avoid double-dispatch.
95    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/// Identifies why compaction fired. This is separate from [`CompactMode`]:
104/// mode describes the caller surface, while trigger explains the pressure
105/// that made the caller compact.
106#[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
123/// Per-call inputs that travel with a compaction request through the
124/// lifecycle. Stored as references to keep allocations down on the hot path.
125pub 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    /// Reminder events from the source transcript that should pass through
132    /// the `preserve_on_compact` / `ttl_turns` / `dedupe_key` lifecycle
133    /// before being re-attached to the compacted transcript.
134    pub reminder_events: Vec<VmValue>,
135    /// Caller-supplied summary override. When `Some`, replaces the
136    /// `auto_compact_messages` output before the post-compact payload is
137    /// assembled. Used by `transcript_compact()` to support pre-computed
138    /// summaries.
139    pub summary_override: Option<String>,
140    /// Provider options forwarded to `evaluate_and_inject` so registered
141    /// providers see the same shape the caller observed.
142    pub provider_options: JsonValue,
143    /// Optional source-transcript value used to build a pre-compaction
144    /// snapshot asset. Paths that don't have a transcript dict (e.g.,
145    /// `transcript_auto_compact()` on a raw list) leave this `None` and
146    /// the post-compact payload omits `snapshot_asset_id`.
147    pub source_transcript: Option<&'a VmValue>,
148    /// Whether to invoke the registered reminder providers after the
149    /// post-compact hook chain. Only meaningful when `session_id` is set.
150    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
223/// Result of a successful compaction. Returned to callers so they can
224/// finalize their own persistence (transcript dict assembly, agent-session
225/// replacement, snapshot recording). The messages themselves are mutated in
226/// place on the caller's `Vec` so a no-op return (`Ok(None)`) leaves them
227/// unchanged for downstream code that always writes the messages back.
228pub 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    /// Snapshot asset built from the caller-supplied source transcript.
235    /// `None` when no source transcript was provided.
236    pub snapshot_asset: Option<VmValue>,
237    /// `snapshot_asset.id` extracted for inclusion in event payloads.
238    pub snapshot_asset_id: Option<String>,
239    /// Engine strategy actually used (after honoring any PreCompact `Modify`).
240    pub strategy: CompactStrategy,
241    /// User-facing policy label resolved on the config.
242    pub policy_strategy: String,
243    /// `metadata` block ready to attach to the persisted transcript
244    /// `"compaction"` event. Carries the canonical [`CompactionReceipt`] under
245    /// its `receipt` key alongside the flat policy fields + reminder counts that
246    /// transcript-inspecting scripts and hosts read.
247    pub event_metadata: JsonValue,
248    /// The one canonical receipt for this compaction. Its `receipt_id` is the
249    /// shared identity across the transcript event, the live event, ACP, and the
250    /// run-observability record. Callers attach the transcript `"compaction"`
251    /// event with `id = receipt.receipt_id`.
252    pub receipt: CompactionReceipt,
253    /// Observation-mask recap receipt, `None` for non-masking strategies.
254    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    /// Observation-mask recap receipt: `{recap_bytes, budget_bytes,
264    /// kept_results_count, dropped_count, carried_prior_recap}`. `None` for the
265    /// LLM/truncate/custom strategies, which do not spend a recap budget.
266    pub recap: Option<JsonValue>,
267}
268
269/// Reminder-lifecycle bookkeeping produced before the compaction runs and
270/// consumed by both the persisted transcript and the AgentEvent payload.
271#[derive(Debug, Default)]
272pub struct ReminderCompactReport {
273    /// Non-reminder events plus reminders flagged `preserve_on_compact`.
274    /// Callers re-attach these to the compacted transcript.
275    pub preserved_events: Vec<VmValue>,
276    /// Reminder values handed to `custom_compactor` callbacks so user
277    /// scripts can fold pending reminders into their summarization output.
278    pub custom_reminders: Vec<VmValue>,
279    /// Reminders whose `ttl_turns` reached zero this compaction.
280    pub expired: Vec<SystemReminder>,
281    /// Reminders that were folded into the compacted summary because
282    /// they had no `preserve_on_compact` flag.
283    pub compacted: Vec<SystemReminder>,
284    /// Reminders dropped because a newer reminder with the same
285    /// `dedupe_key` was retained.
286    pub deduped: Vec<ReminderDedupeRecord>,
287    /// Count of reminders whose `ttl_turns` were decremented (still alive).
288    pub decremented_count: usize,
289    /// Count of reminders that carried `preserve_on_compact = true`.
290    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
300/// Run a transcript compaction through the canonical lifecycle. The
301/// `messages` vec is mutated in place by [`auto_compact_messages_with_result`]; on a
302/// `Ok(None)` return it is left untouched so callers that always write
303/// messages back (e.g. `transcript_auto_compact()`) can do so unconditionally.
304///
305/// `Ok(None)` means no compaction happened — either the messages were
306/// already under threshold, a PreCompact hook returned `Block`, or
307/// `auto_compact_messages_with_result` itself decided there was nothing to do.
308pub(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    // Move `reminder_events` out up front so subsequent reads of
325    // `lifecycle` don't trip the partial-move check.
326    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
471/// Emit `AgentEvent::TranscriptCompacted` carrying the canonical receipt.
472/// Lifecycle callers reach this through [`run_compaction_lifecycle`]; the
473/// host-script and budget-pressure paths build their own receipt and call the
474/// sync variant.
475pub 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
490/// Synchronous variant of [`emit_transcript_compacted_event`]. Used by
491/// `host_agent_record_compaction` and the transcript-budget path, which run in
492/// sync contexts and can't `.await` directly.
493pub 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
500// ---------------------------------------------------------------------------
501// Internal payload + reminder helpers shared by stdlib builtins and the
502// agent-session host.
503// ---------------------------------------------------------------------------
504
505enum 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        // Embed the canonical receipt verbatim so the record builder reads it
681        // typed instead of re-scraping the flat keys above. The flat keys stay
682        // for transcript-inspecting scripts/hosts; both are projections of one
683        // in-code `CompactionReceipt`, so they cannot drift.
684        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
698/// Process a list of reminder events through the canonical lifecycle:
699/// expire by TTL, decrement remaining TTLs, dedupe by `dedupe_key`, and
700/// retain `preserve_on_compact` reminders for re-attachment.
701pub 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
946/// Extract the events from a transcript-shaped dict that should be routed
947/// through [`run_compaction_lifecycle`] (everything except `message` and
948/// `tool_result` events). This is the canonical filter used by every
949/// transcript-having compaction caller — keeping it in one place stops the
950/// trivial-but-load-bearing filter list from drifting per-callsite.
951pub 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        // 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}