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    /// Create metadata for a harness-generated notice injected as a `User`-role
112    /// turn (recovery nudges, tracker continuations, evaluator retries).
113    ///
114    /// The role stays `User` because providers only accept user-role steering,
115    /// but the provenance is machine-generated: reverse history must not offer
116    /// it as something the user typed.
117    #[must_use]
118    pub fn harness_notice(timestamp: u64, estimated_tokens: usize) -> Self {
119        Self::synthetic(timestamp, estimated_tokens)
120    }
121
122    /// Return whether this message was generated by the harness rather than
123    /// typed by the user.
124    #[must_use]
125    pub fn is_synthetic(&self) -> bool {
126        self.source.as_deref() == Some("synthetic")
127    }
128
129    /// Return the recorded message origin (`user_input`, `llm_response`,
130    /// `tool_result`, `system`, or `synthetic`).
131    #[must_use]
132    pub fn source(&self) -> Option<&str> {
133        self.source.as_deref()
134    }
135
136    /// Associate this metadata with a queued steering intent.
137    #[must_use]
138    pub fn with_intent_id(mut self, intent_id: impl Into<String>) -> Self {
139        self.intent_id = Some(intent_id.into());
140        self
141    }
142
143    /// Return the steering intent identity associated with this message.
144    #[must_use]
145    pub fn intent_id(&self) -> Option<&str> {
146        self.intent_id.as_deref()
147    }
148
149    /// Mark this message as compressed, recording the original and new token counts.
150    fn mark_compressed(&mut self, original_tokens: usize, compressed_tokens: usize) {
151        self.compression_status = CompressionStatus::Compressed {
152            original_token_count: original_tokens,
153            summary_token_count: compressed_tokens,
154        };
155        self.estimated_tokens = compressed_tokens;
156    }
157
158    /// Mark this message as summarized.
159    fn mark_summarized(&mut self, original_tokens: usize, summary_tokens: usize) {
160        self.compression_status = CompressionStatus::Summarized {
161            original_token_count: original_tokens,
162            summary_token_count: summary_tokens,
163        };
164        self.estimated_tokens = summary_tokens;
165    }
166
167    /// Set the importance score (clamped to [0.0, 1.0]).
168    fn set_importance(&mut self, score: f64) {
169        self.importance_score = score.clamp(0.0, 1.0);
170    }
171
172    /// Returns the original (pre-compression) token count, or the current count
173    /// if the message was never compressed.
174    fn original_token_count(&self) -> usize {
175        match self.compression_status {
176            CompressionStatus::Uncompressed => self.estimated_tokens,
177            CompressionStatus::Compressed { original_token_count, .. }
178            | CompressionStatus::Summarized { original_token_count, .. } => original_token_count,
179            CompressionStatus::Dropped => 0,
180        }
181    }
182
183    /// Returns the effective (post-compression) token count.
184    fn effective_token_count(&self) -> usize {
185        match self.compression_status {
186            CompressionStatus::Uncompressed => self.estimated_tokens,
187            CompressionStatus::Compressed { summary_token_count, .. }
188            | CompressionStatus::Summarized { summary_token_count, .. } => summary_token_count,
189            CompressionStatus::Dropped => 0,
190        }
191    }
192}
193
194/// Tracks the compression state of a single message in conversation history.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "snake_case")]
197pub enum CompressionStatus {
198    /// Message is in its original uncompressed form.
199    Uncompressed,
200    /// Message has been compressed with token-level preservation of information.
201    Compressed {
202        original_token_count: usize,
203        summary_token_count: usize,
204    },
205    /// Message has been semantically summarized (lossy compression).
206    Summarized {
207        original_token_count: usize,
208        summary_token_count: usize,
209    },
210    /// Message has been dropped from the active context but may be in long-term
211    /// memory.
212    Dropped,
213}
214
215#[allow(
216    clippy::derivable_impls,
217    reason = "Intentional compatibility, platform, or test-only suppression."
218)]
219impl Default for CompressionStatus {
220    fn default() -> Self {
221        Self::Uncompressed
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_create_user_metadata() {
231        let meta = MessageMetadata::user_input(1000, 50);
232        assert_eq!(meta.timestamp, 1000);
233        assert!((meta.importance_score - 0.5).abs() < f64::EPSILON);
234        assert_eq!(meta.compression_status, CompressionStatus::Uncompressed);
235        assert_eq!(meta.estimated_tokens, 50);
236        assert_eq!(meta.source.as_deref(), Some("user_input"));
237        assert_eq!(meta.intent_id(), None);
238    }
239
240    #[test]
241    fn test_create_llm_response_metadata() {
242        let meta = MessageMetadata::llm_response(2000, 150);
243        assert!((meta.importance_score - 0.6).abs() < f64::EPSILON);
244    }
245
246    #[test]
247    fn test_mark_compressed() {
248        let mut meta = MessageMetadata::user_input(1000, 200);
249        meta.mark_compressed(200, 50);
250        assert_eq!(meta.estimated_tokens, 50);
251        assert_eq!(meta.effective_token_count(), 50);
252        assert_eq!(meta.original_token_count(), 200);
253    }
254
255    #[test]
256    fn test_mark_summarized() {
257        let mut meta = MessageMetadata::user_input(1000, 300);
258        meta.mark_summarized(300, 30);
259        assert_eq!(meta.effective_token_count(), 30);
260        assert_eq!(meta.original_token_count(), 300);
261    }
262
263    #[test]
264    fn test_set_importance_clamps() {
265        let mut meta = MessageMetadata::user_input(1000, 50);
266        meta.set_importance(1.5);
267        assert!((meta.importance_score - 1.0).abs() < f64::EPSILON);
268        meta.set_importance(-0.5);
269        assert!((meta.importance_score - 0.0).abs() < f64::EPSILON);
270    }
271
272    #[test]
273    fn test_compression_status_serde_roundtrip() {
274        let status = CompressionStatus::Compressed { original_token_count: 200, summary_token_count: 50 };
275        let json = serde_json::to_string(&status).unwrap();
276        let deserialized: CompressionStatus = serde_json::from_str(&json).unwrap();
277        assert_eq!(status, deserialized);
278    }
279
280    #[test]
281    fn test_intent_id_is_optional_and_serde_compatible() {
282        let meta = MessageMetadata::user_input(1000, 50).with_intent_id("intent-1");
283        let json = serde_json::to_string(&meta).unwrap();
284        let restored: MessageMetadata = serde_json::from_str(&json).unwrap();
285        assert_eq!(restored.intent_id(), Some("intent-1"));
286
287        let legacy = r#"{"timestamp":1000,"importance_score":0.5,"compression_status":"uncompressed","estimated_tokens":50,"source":"user_input"}"#;
288        let restored: MessageMetadata = serde_json::from_str(legacy).unwrap();
289        assert_eq!(restored.intent_id(), None);
290    }
291}