Skip to main content

vtcode_commons/
message_metadata.rs

1//! Per-message metadata for conversation history.
2//!
3//! Each message in the conversation carries metadata about its origin,
4//! importance, compression state, and resource usage. This enables smart
5//! context pruning (drop low-importance messages first), compression
6//! tracking, and latency analysis.
7//!
8//! Following the "state as a first-class citizen" principle (Hitchhiker's
9//! Guide to Agentic AI, Section 18.6.1), metadata is the foundation for
10//! conversation state quality-of-service decisions.
11
12use serde::{Deserialize, Serialize};
13
14/// Metadata attached to every message in the conversation history.
15///
16/// Skipped during serialization when `None` to preserve backward compatibility
17/// with all existing persistence formats (session archives, snapshots, etc.).
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct MessageMetadata {
20    /// Unix millisecond timestamp when the message was created.
21    timestamp: u64,
22
23    /// Importance score in [0.0, 1.0]: 0.0 = low (safe to drop first),
24    /// 1.0 = high (preserve as long as possible).
25    ///
26    /// Initialised to 0.5 (neutral) and adjusted by the compression/pruning
27    /// system or by explicit agent reflection.
28    importance_score: f64,
29
30    /// Current compression status of this message.
31    compression_status: CompressionStatus,
32
33    /// Cached token estimate for this message. Populated on creation and
34    /// updated after compression.
35    estimated_tokens: usize,
36
37    /// Origin of this message: "user_input", "llm_response", "tool_result",
38    /// "system", or "synthetic".
39    source: Option<String>,
40
41    /// Stable identity of the queued steering intent that produced this user
42    /// message, when the message was injected by runtime steering.
43    ///
44    /// This field is optional so older history files and ordinary user
45    /// messages remain wire-compatible.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    intent_id: Option<String>,
48    /// Canonical event item that produced this message, when known.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    event_item_id: Option<String>,
51}
52
53impl MessageMetadata {
54    /// Create metadata for a message originating from a user.
55    pub fn user_input(timestamp: u64, estimated_tokens: usize) -> Self {
56        Self {
57            timestamp,
58            importance_score: 0.5,
59            compression_status: CompressionStatus::Uncompressed,
60            estimated_tokens,
61            source: Some("user_input".into()),
62            intent_id: None,
63            event_item_id: None,
64        }
65    }
66
67    /// Create metadata for a message originating from an LLM response.
68    pub fn llm_response(timestamp: u64, estimated_tokens: usize) -> Self {
69        Self {
70            timestamp,
71            importance_score: 0.6,
72            compression_status: CompressionStatus::Uncompressed,
73            estimated_tokens,
74            source: Some("llm_response".into()),
75            intent_id: None,
76            event_item_id: None,
77        }
78    }
79
80    /// Create metadata for a tool result message.
81    pub fn tool_result(timestamp: u64, estimated_tokens: usize) -> Self {
82        Self {
83            timestamp,
84            importance_score: 0.4,
85            compression_status: CompressionStatus::Uncompressed,
86            estimated_tokens,
87            source: Some("tool_result".into()),
88            intent_id: None,
89            event_item_id: None,
90        }
91    }
92
93    /// Create metadata for a system message.
94    pub fn system(timestamp: u64, estimated_tokens: usize) -> Self {
95        Self {
96            timestamp,
97            importance_score: 1.0,
98            compression_status: CompressionStatus::Uncompressed,
99            estimated_tokens,
100            source: Some("system".into()),
101            intent_id: None,
102            event_item_id: None,
103        }
104    }
105
106    /// Create metadata for a synthetic (e.g., recovery/injected) message.
107    pub fn synthetic(timestamp: u64, estimated_tokens: usize) -> Self {
108        Self {
109            timestamp,
110            importance_score: 0.3,
111            compression_status: CompressionStatus::Uncompressed,
112            estimated_tokens,
113            source: Some("synthetic".into()),
114            intent_id: None,
115            event_item_id: None,
116        }
117    }
118
119    /// Create metadata for a harness-generated notice injected as a `User`-role
120    /// turn (recovery nudges, tracker continuations, evaluator retries).
121    ///
122    /// The role stays `User` because providers only accept user-role steering,
123    /// but the provenance is machine-generated: reverse history must not offer
124    /// it as something the user typed.
125    #[must_use]
126    pub fn harness_notice(timestamp: u64, estimated_tokens: usize) -> Self {
127        Self::synthetic(timestamp, estimated_tokens)
128    }
129
130    /// Return whether this message was generated by the harness rather than
131    /// typed by the user.
132    #[must_use]
133    pub fn is_synthetic(&self) -> bool {
134        self.source.as_deref() == Some("synthetic")
135    }
136
137    /// Return the recorded message origin (`user_input`, `llm_response`,
138    /// `tool_result`, `system`, or `synthetic`).
139    #[must_use]
140    pub fn source(&self) -> Option<&str> {
141        self.source.as_deref()
142    }
143
144    /// Associate this metadata with a queued steering intent.
145    #[must_use]
146    pub fn with_intent_id(mut self, intent_id: impl Into<String>) -> Self {
147        self.intent_id = Some(intent_id.into());
148        self
149    }
150
151    /// Return the steering intent identity associated with this message.
152    #[must_use]
153    pub fn intent_id(&self) -> Option<&str> {
154        self.intent_id.as_deref()
155    }
156
157    /// Record the canonical event identity for durable transcript correlation.
158    pub fn set_event_item_id(&mut self, item_id: String) {
159        self.event_item_id = Some(item_id);
160    }
161
162    /// Return the canonical event identity when one was recorded.
163    #[must_use]
164    pub fn event_item_id(&self) -> Option<&str> {
165        self.event_item_id.as_deref()
166    }
167
168    /// Mark this message as compressed, recording the original and new token counts.
169    fn mark_compressed(&mut self, original_tokens: usize, compressed_tokens: usize) {
170        self.compression_status = CompressionStatus::Compressed {
171            original_token_count: original_tokens,
172            summary_token_count: compressed_tokens,
173        };
174        self.estimated_tokens = compressed_tokens;
175    }
176
177    /// Mark this message as summarized.
178    fn mark_summarized(&mut self, original_tokens: usize, summary_tokens: usize) {
179        self.compression_status = CompressionStatus::Summarized {
180            original_token_count: original_tokens,
181            summary_token_count: summary_tokens,
182        };
183        self.estimated_tokens = summary_tokens;
184    }
185
186    /// Set the importance score (clamped to [0.0, 1.0]).
187    fn set_importance(&mut self, score: f64) {
188        self.importance_score = score.clamp(0.0, 1.0);
189    }
190
191    /// Returns the original (pre-compression) token count, or the current count
192    /// if the message was never compressed.
193    fn original_token_count(&self) -> usize {
194        match self.compression_status {
195            CompressionStatus::Uncompressed => self.estimated_tokens,
196            CompressionStatus::Compressed { original_token_count, .. }
197            | CompressionStatus::Summarized { original_token_count, .. } => original_token_count,
198            CompressionStatus::Dropped => 0,
199        }
200    }
201
202    /// Returns the effective (post-compression) token count.
203    fn effective_token_count(&self) -> usize {
204        match self.compression_status {
205            CompressionStatus::Uncompressed => self.estimated_tokens,
206            CompressionStatus::Compressed { summary_token_count, .. }
207            | CompressionStatus::Summarized { summary_token_count, .. } => summary_token_count,
208            CompressionStatus::Dropped => 0,
209        }
210    }
211}
212
213/// Tracks the compression state of a single message in conversation history.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216pub enum CompressionStatus {
217    /// Message is in its original uncompressed form.
218    Uncompressed,
219    /// Message has been compressed with token-level preservation of information.
220    Compressed {
221        original_token_count: usize,
222        summary_token_count: usize,
223    },
224    /// Message has been semantically summarized (lossy compression).
225    Summarized {
226        original_token_count: usize,
227        summary_token_count: usize,
228    },
229    /// Message has been dropped from the active context but may be in long-term
230    /// memory.
231    Dropped,
232}
233
234#[allow(
235    clippy::derivable_impls,
236    reason = "Intentional compatibility, platform, or test-only suppression."
237)]
238impl Default for CompressionStatus {
239    fn default() -> Self {
240        Self::Uncompressed
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn test_create_user_metadata() {
250        let meta = MessageMetadata::user_input(1000, 50);
251        assert_eq!(meta.timestamp, 1000);
252        assert!((meta.importance_score - 0.5).abs() < f64::EPSILON);
253        assert_eq!(meta.compression_status, CompressionStatus::Uncompressed);
254        assert_eq!(meta.estimated_tokens, 50);
255        assert_eq!(meta.source.as_deref(), Some("user_input"));
256        assert_eq!(meta.intent_id(), None);
257    }
258
259    #[test]
260    fn test_create_llm_response_metadata() {
261        let meta = MessageMetadata::llm_response(2000, 150);
262        assert!((meta.importance_score - 0.6).abs() < f64::EPSILON);
263    }
264
265    #[test]
266    fn test_mark_compressed() {
267        let mut meta = MessageMetadata::user_input(1000, 200);
268        meta.mark_compressed(200, 50);
269        assert_eq!(meta.estimated_tokens, 50);
270        assert_eq!(meta.effective_token_count(), 50);
271        assert_eq!(meta.original_token_count(), 200);
272    }
273
274    #[test]
275    fn test_mark_summarized() {
276        let mut meta = MessageMetadata::user_input(1000, 300);
277        meta.mark_summarized(300, 30);
278        assert_eq!(meta.effective_token_count(), 30);
279        assert_eq!(meta.original_token_count(), 300);
280    }
281
282    #[test]
283    fn test_set_importance_clamps() {
284        let mut meta = MessageMetadata::user_input(1000, 50);
285        meta.set_importance(1.5);
286        assert!((meta.importance_score - 1.0).abs() < f64::EPSILON);
287        meta.set_importance(-0.5);
288        assert!((meta.importance_score - 0.0).abs() < f64::EPSILON);
289    }
290
291    #[test]
292    fn test_compression_status_serde_roundtrip() {
293        let status = CompressionStatus::Compressed { original_token_count: 200, summary_token_count: 50 };
294        let json = serde_json::to_string(&status).unwrap();
295        let deserialized: CompressionStatus = serde_json::from_str(&json).unwrap();
296        assert_eq!(status, deserialized);
297    }
298
299    #[test]
300    fn test_intent_id_is_optional_and_serde_compatible() {
301        let meta = MessageMetadata::user_input(1000, 50).with_intent_id("intent-1");
302        let json = serde_json::to_string(&meta).unwrap();
303        let restored: MessageMetadata = serde_json::from_str(&json).unwrap();
304        assert_eq!(restored.intent_id(), Some("intent-1"));
305
306        let legacy = r#"{"timestamp":1000,"importance_score":0.5,"compression_status":"uncompressed","estimated_tokens":50,"source":"user_input"}"#;
307        let restored: MessageMetadata = serde_json::from_str(legacy).unwrap();
308        assert_eq!(restored.intent_id(), None);
309    }
310
311    #[test]
312    fn event_item_identity_round_trips_and_legacy_metadata_has_none() {
313        let mut metadata = MessageMetadata::llm_response(42, 7);
314        metadata.set_event_item_id("event-1".into());
315        let encoded = serde_json::to_string(&metadata).unwrap();
316        let decoded: MessageMetadata = serde_json::from_str(&encoded).unwrap();
317        assert_eq!(decoded.event_item_id(), Some("event-1"));
318        let legacy = r#"{"timestamp":1000,"importance_score":0.5,"compression_status":"uncompressed","estimated_tokens":50,"source":"user_input"}"#;
319        let decoded: MessageMetadata = serde_json::from_str(legacy).unwrap();
320        assert_eq!(decoded.event_item_id(), None);
321    }
322}