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}
49
50impl MessageMetadata {
51    /// Create metadata for a message originating from a user.
52    pub fn user_input(timestamp: u64, estimated_tokens: usize) -> Self {
53        Self {
54            timestamp,
55            importance_score: 0.5,
56            compression_status: CompressionStatus::Uncompressed,
57            estimated_tokens,
58            source: Some("user_input".into()),
59            intent_id: None,
60        }
61    }
62
63    /// Create metadata for a message originating from an LLM response.
64    pub fn llm_response(timestamp: u64, estimated_tokens: usize) -> Self {
65        Self {
66            timestamp,
67            importance_score: 0.6,
68            compression_status: CompressionStatus::Uncompressed,
69            estimated_tokens,
70            source: Some("llm_response".into()),
71            intent_id: None,
72        }
73    }
74
75    /// Create metadata for a tool result message.
76    pub fn tool_result(timestamp: u64, estimated_tokens: usize) -> Self {
77        Self {
78            timestamp,
79            importance_score: 0.4,
80            compression_status: CompressionStatus::Uncompressed,
81            estimated_tokens,
82            source: Some("tool_result".into()),
83            intent_id: None,
84        }
85    }
86
87    /// Create metadata for a system message.
88    pub fn system(timestamp: u64, estimated_tokens: usize) -> Self {
89        Self {
90            timestamp,
91            importance_score: 1.0,
92            compression_status: CompressionStatus::Uncompressed,
93            estimated_tokens,
94            source: Some("system".into()),
95            intent_id: None,
96        }
97    }
98
99    /// Create metadata for a synthetic (e.g., recovery/injected) message.
100    pub fn synthetic(timestamp: u64, estimated_tokens: usize) -> Self {
101        Self {
102            timestamp,
103            importance_score: 0.3,
104            compression_status: CompressionStatus::Uncompressed,
105            estimated_tokens,
106            source: Some("synthetic".into()),
107            intent_id: None,
108        }
109    }
110
111    /// Associate this metadata with a queued steering intent.
112    #[must_use]
113    pub fn with_intent_id(mut self, intent_id: impl Into<String>) -> Self {
114        self.intent_id = Some(intent_id.into());
115        self
116    }
117
118    /// Return the steering intent identity associated with this message.
119    #[must_use]
120    pub fn intent_id(&self) -> Option<&str> {
121        self.intent_id.as_deref()
122    }
123
124    /// Mark this message as compressed, recording the original and new token counts.
125    fn mark_compressed(&mut self, original_tokens: usize, compressed_tokens: usize) {
126        self.compression_status = CompressionStatus::Compressed {
127            original_token_count: original_tokens,
128            summary_token_count: compressed_tokens,
129        };
130        self.estimated_tokens = compressed_tokens;
131    }
132
133    /// Mark this message as summarized.
134    fn mark_summarized(&mut self, original_tokens: usize, summary_tokens: usize) {
135        self.compression_status = CompressionStatus::Summarized {
136            original_token_count: original_tokens,
137            summary_token_count: summary_tokens,
138        };
139        self.estimated_tokens = summary_tokens;
140    }
141
142    /// Set the importance score (clamped to [0.0, 1.0]).
143    fn set_importance(&mut self, score: f64) {
144        self.importance_score = score.clamp(0.0, 1.0);
145    }
146
147    /// Returns the original (pre-compression) token count, or the current count
148    /// if the message was never compressed.
149    fn original_token_count(&self) -> usize {
150        match self.compression_status {
151            CompressionStatus::Uncompressed => self.estimated_tokens,
152            CompressionStatus::Compressed { original_token_count, .. }
153            | CompressionStatus::Summarized { original_token_count, .. } => original_token_count,
154            CompressionStatus::Dropped => 0,
155        }
156    }
157
158    /// Returns the effective (post-compression) token count.
159    fn effective_token_count(&self) -> usize {
160        match self.compression_status {
161            CompressionStatus::Uncompressed => self.estimated_tokens,
162            CompressionStatus::Compressed { summary_token_count, .. }
163            | CompressionStatus::Summarized { summary_token_count, .. } => summary_token_count,
164            CompressionStatus::Dropped => 0,
165        }
166    }
167}
168
169/// Tracks the compression state of a single message in conversation history.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum CompressionStatus {
173    /// Message is in its original uncompressed form.
174    Uncompressed,
175    /// Message has been compressed with token-level preservation of information.
176    Compressed {
177        original_token_count: usize,
178        summary_token_count: usize,
179    },
180    /// Message has been semantically summarized (lossy compression).
181    Summarized {
182        original_token_count: usize,
183        summary_token_count: usize,
184    },
185    /// Message has been dropped from the active context but may be in long-term
186    /// memory.
187    Dropped,
188}
189
190#[allow(clippy::derivable_impls)]
191impl Default for CompressionStatus {
192    fn default() -> Self {
193        Self::Uncompressed
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_create_user_metadata() {
203        let meta = MessageMetadata::user_input(1000, 50);
204        assert_eq!(meta.timestamp, 1000);
205        assert!((meta.importance_score - 0.5).abs() < f64::EPSILON);
206        assert_eq!(meta.compression_status, CompressionStatus::Uncompressed);
207        assert_eq!(meta.estimated_tokens, 50);
208        assert_eq!(meta.source.as_deref(), Some("user_input"));
209        assert_eq!(meta.intent_id(), None);
210    }
211
212    #[test]
213    fn test_create_llm_response_metadata() {
214        let meta = MessageMetadata::llm_response(2000, 150);
215        assert!((meta.importance_score - 0.6).abs() < f64::EPSILON);
216    }
217
218    #[test]
219    fn test_mark_compressed() {
220        let mut meta = MessageMetadata::user_input(1000, 200);
221        meta.mark_compressed(200, 50);
222        assert_eq!(meta.estimated_tokens, 50);
223        assert_eq!(meta.effective_token_count(), 50);
224        assert_eq!(meta.original_token_count(), 200);
225    }
226
227    #[test]
228    fn test_mark_summarized() {
229        let mut meta = MessageMetadata::user_input(1000, 300);
230        meta.mark_summarized(300, 30);
231        assert_eq!(meta.effective_token_count(), 30);
232        assert_eq!(meta.original_token_count(), 300);
233    }
234
235    #[test]
236    fn test_set_importance_clamps() {
237        let mut meta = MessageMetadata::user_input(1000, 50);
238        meta.set_importance(1.5);
239        assert!((meta.importance_score - 1.0).abs() < f64::EPSILON);
240        meta.set_importance(-0.5);
241        assert!((meta.importance_score - 0.0).abs() < f64::EPSILON);
242    }
243
244    #[test]
245    fn test_compression_status_serde_roundtrip() {
246        let status = CompressionStatus::Compressed { original_token_count: 200, summary_token_count: 50 };
247        let json = serde_json::to_string(&status).unwrap();
248        let deserialized: CompressionStatus = serde_json::from_str(&json).unwrap();
249        assert_eq!(status, deserialized);
250    }
251
252    #[test]
253    fn test_intent_id_is_optional_and_serde_compatible() {
254        let meta = MessageMetadata::user_input(1000, 50).with_intent_id("intent-1");
255        let json = serde_json::to_string(&meta).unwrap();
256        let restored: MessageMetadata = serde_json::from_str(&json).unwrap();
257        assert_eq!(restored.intent_id(), Some("intent-1"));
258
259        let legacy = r#"{"timestamp":1000,"importance_score":0.5,"compression_status":"uncompressed","estimated_tokens":50,"source":"user_input"}"#;
260        let restored: MessageMetadata = serde_json::from_str(legacy).unwrap();
261        assert_eq!(restored.intent_id(), None);
262    }
263}