Skip to main content

harn_vm/orchestration/
compaction_receipt.rs

1//! The canonical, versioned compaction receipt.
2//!
3//! A single transcript compaction is one lifecycle fact. Historically that fact
4//! was reconstructed independently in three places — the persisted transcript
5//! `compaction` event metadata, the live `AgentEvent::TranscriptCompacted`
6//! payload, and `RunObservabilityRecord.compaction_events` (which reparsed the
7//! transcript JSON key-by-key and dropped `reason`/`recap`). ACP consumers had
8//! no stable identity to key on and synthesized host-local UUIDs.
9//!
10//! [`CompactionReceipt`] is the one owner of that fact. It is constructed once
11//! at the lifecycle boundary (`run_compaction_lifecycle`) or, for host-script
12//! driven compaction, normalized once at the `__host_agent_record_compaction`
13//! builtin boundary. It is then:
14//!
15//!   * embedded verbatim under `metadata.receipt` on the transcript event, whose
16//!     event `id` is set to [`CompactionReceipt::receipt_id`];
17//!   * carried on `AgentEvent::TranscriptCompacted` and forwarded through ACP;
18//!   * deserialized (typed, not scraped) into `CompactionEventRecord`.
19//!
20//! So one compaction operation yields exactly one `receipt_id` across all four
21//! projections, and `reason`, `recap`, snapshot provenance, and policy survive
22//! every projection without a host synthesizing identity.
23
24use serde::{Deserialize, Serialize};
25
26use super::RecapMetrics;
27
28/// Current on-the-wire schema version for [`CompactionReceipt`]. Bump this when
29/// the receipt's meaning changes in a way readers must branch on; additive
30/// optional fields do not require a bump because `#[serde(default)]` fills them.
31pub const COMPACTION_RECEIPT_SCHEMA_VERSION: u32 = 1;
32
33fn default_schema_version() -> u32 {
34    COMPACTION_RECEIPT_SCHEMA_VERSION
35}
36
37/// One serializable, versioned record of a single transcript compaction. See the
38/// module docs for how it flows through every projection.
39#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(default)]
41pub struct CompactionReceipt {
42    /// Schema version. Lets readers migrate: a receipt read from an older
43    /// persisted transcript that predates a meaning change carries its original
44    /// version, and a transcript with no embedded receipt at all is migrated by
45    /// the record builder (see `compaction_events_from_transcript`).
46    #[serde(default = "default_schema_version")]
47    pub schema_version: u32,
48    /// Stable identity for this compaction operation, shared verbatim across the
49    /// transcript event id, the live event, ACP, and the observability record.
50    pub receipt_id: String,
51    /// Owning agent session, when the compaction ran against one.
52    pub session_id: Option<String>,
53    /// Owning transcript, when known. Best-effort provenance; the record's
54    /// `transcript_id` is sourced authoritatively from the transcript itself.
55    pub transcript_id: Option<String>,
56    /// Call-site surface that initiated compaction (`CompactMode::as_str`).
57    pub mode: String,
58    /// Why compaction fired (`CompactionTrigger::as_str`, or a host-supplied
59    /// reason such as `context_overflow`).
60    pub reason: String,
61    /// User-facing policy label (may be broader than the engine strategy).
62    pub strategy: String,
63    /// Engine strategy actually used after honoring any PreCompact `Modify`.
64    pub engine_strategy: String,
65    pub archived_messages: usize,
66    pub estimated_tokens_before: usize,
67    pub estimated_tokens_after: usize,
68    /// Id of the pre-compaction snapshot asset, when one was built.
69    pub snapshot_asset_id: Option<String>,
70    pub instruction_mode: Option<String>,
71    pub instruction_source: Option<String>,
72    pub compaction_policy: Option<serde_json::Value>,
73    /// Observation-mask recap metrics; `None` for the LLM/truncate/custom
74    /// strategies, which do not spend a recap budget.
75    pub recap: Option<RecapMetrics>,
76}
77
78/// Generate a fresh, unique compaction receipt id.
79pub fn new_compaction_receipt_id() -> String {
80    format!("compaction-{}", uuid::Uuid::now_v7())
81}
82
83impl CompactionReceipt {
84    /// Serialize the receipt for verbatim embedding under `metadata.receipt` on
85    /// the transcript event.
86    pub fn to_json(&self) -> serde_json::Value {
87        serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
88    }
89
90    /// Read a receipt embedded under `metadata.receipt` on a transcript
91    /// `compaction` event. Returns `None` when the key is absent (a legacy
92    /// transcript written before receipts existed) or malformed, so the caller
93    /// can fall back to the legacy flat-metadata migration path.
94    pub fn from_event_metadata(metadata: Option<&serde_json::Value>) -> Option<Self> {
95        let receipt = metadata?.get("receipt")?;
96        serde_json::from_value(receipt.clone()).ok()
97    }
98
99    /// Normalize a host-script compaction payload — the dict `.harn` code hands
100    /// to `agent_record_compaction` / `__host_agent_record_compaction` — into a
101    /// receipt at the builtin boundary, minting a fresh `receipt_id`. This is the
102    /// one place the host-driven shape is validated, so the `.harn` auto-compact
103    /// path yields the same unified receipt as the Rust lifecycle paths.
104    pub fn from_host_payload(session_id: &str, payload: &serde_json::Value) -> Self {
105        let str_field = |key: &str| {
106            payload
107                .get(key)
108                .and_then(serde_json::Value::as_str)
109                .map(str::to_string)
110        };
111        let usize_field = |key: &str| {
112            payload
113                .get(key)
114                .and_then(serde_json::Value::as_u64)
115                .unwrap_or(0) as usize
116        };
117        let strategy = str_field("strategy")
118            .or_else(|| str_field("engine_strategy"))
119            .unwrap_or_default();
120        let engine_strategy = str_field("engine_strategy").unwrap_or_else(|| strategy.clone());
121        Self {
122            schema_version: COMPACTION_RECEIPT_SCHEMA_VERSION,
123            receipt_id: new_compaction_receipt_id(),
124            session_id: Some(session_id.to_string()),
125            transcript_id: None,
126            mode: str_field("mode").unwrap_or_else(|| "auto".to_string()),
127            reason: str_field("reason").unwrap_or_else(|| "threshold".to_string()),
128            strategy,
129            engine_strategy,
130            archived_messages: usize_field("archived_messages"),
131            estimated_tokens_before: usize_field("estimated_tokens_before"),
132            estimated_tokens_after: usize_field("estimated_tokens_after"),
133            snapshot_asset_id: str_field("snapshot_asset_id"),
134            instruction_mode: str_field("instruction_mode"),
135            instruction_source: str_field("instruction_source"),
136            compaction_policy: payload.get("compaction_policy").cloned(),
137            recap: payload
138                .get("recap")
139                .and_then(|value| serde_json::from_value::<RecapMetrics>(value.clone()).ok()),
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn receipt_round_trips_through_json_with_recap_and_policy() {
150        let receipt = CompactionReceipt {
151            schema_version: COMPACTION_RECEIPT_SCHEMA_VERSION,
152            receipt_id: "compaction-abc".to_string(),
153            session_id: Some("session-1".to_string()),
154            transcript_id: Some("session-1".to_string()),
155            mode: "auto".to_string(),
156            reason: "threshold".to_string(),
157            strategy: "hybrid".to_string(),
158            engine_strategy: "observation_mask".to_string(),
159            archived_messages: 7,
160            estimated_tokens_before: 4000,
161            estimated_tokens_after: 1200,
162            snapshot_asset_id: Some("snapshot-9".to_string()),
163            instruction_mode: Some("extend".to_string()),
164            instruction_source: Some("author".to_string()),
165            compaction_policy: Some(serde_json::json!({"scope": "summary"})),
166            recap: Some(RecapMetrics {
167                recap_bytes: 512,
168                budget_bytes: 16_000,
169                kept_results_count: 3,
170                dropped_count: 1,
171                carried_prior_recap: true,
172            }),
173        };
174        let json = receipt.to_json();
175        let decoded = CompactionReceipt::from_event_metadata(Some(&serde_json::json!({
176            "receipt": json,
177        })))
178        .expect("embedded receipt decodes");
179        assert_eq!(decoded, receipt);
180    }
181
182    #[test]
183    fn absent_receipt_key_yields_none_for_legacy_migration() {
184        let legacy = serde_json::json!({
185            "mode": "manual",
186            "strategy": "truncate",
187            "archived_messages": 3,
188        });
189        assert!(CompactionReceipt::from_event_metadata(Some(&legacy)).is_none());
190        assert!(CompactionReceipt::from_event_metadata(None).is_none());
191    }
192
193    #[test]
194    fn missing_schema_version_defaults_to_current() {
195        let receipt: CompactionReceipt = serde_json::from_value(serde_json::json!({
196            "receipt_id": "compaction-xyz",
197            "mode": "manual",
198        }))
199        .expect("partial receipt loads");
200        assert_eq!(receipt.schema_version, COMPACTION_RECEIPT_SCHEMA_VERSION);
201        assert_eq!(receipt.receipt_id, "compaction-xyz");
202        assert!(receipt.recap.is_none());
203    }
204}