Skip to main content

everruns_core/capabilities/
message_metadata.rs

1//! MessageMetadata Capability — annotates user/agent messages with metadata
2//! (currently the message timestamp) in the prompt-facing model view.
3//!
4//! Annotations are applied via [`ModelViewProvider`] at LLM message
5//! construction time; stored messages are never modified. Timestamps come from
6//! `Message::created_at`, which is immutable, so annotations are stable across
7//! turns and do not invalidate provider prompt caches.
8
9use std::sync::Arc;
10
11use chrono::SecondsFormat;
12use serde::{Deserialize, Serialize};
13
14use super::{Capability, CapabilityLocalization, ModelViewContext, ModelViewProvider};
15use crate::message::{ContentPart, Message, MessageRole};
16
17pub const MESSAGE_METADATA_CAPABILITY_ID: &str = "message_metadata";
18
19/// A metadata field that can be annotated onto a message.
20///
21/// New fields (e.g. the LLM model that produced an agent message, once stored
22/// messages record it) are added as variants here; each variant renders its
23/// own bracketed segment and may return `None` when the message lacks the data.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum MessageMetadataField {
27    /// Message timestamp (`Message::created_at`), rendered as
28    /// `[time <RFC3339 UTC>]`. For user messages this is when the message was
29    /// received; for agent messages, when the reply was generated.
30    Timestamp,
31}
32
33impl MessageMetadataField {
34    fn render(&self, msg: &Message) -> Option<String> {
35        match self {
36            Self::Timestamp => Some(format!(
37                "[time {}]",
38                msg.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)
39            )),
40        }
41    }
42}
43
44/// Per-agent configuration for message metadata annotations.
45///
46/// User and agent messages are always annotated; only the rendered fields are
47/// configurable.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct MessageMetadataConfig {
51    /// Which metadata fields to annotate, in render order.
52    #[serde(default = "default_fields")]
53    pub fields: Vec<MessageMetadataField>,
54}
55
56impl Default for MessageMetadataConfig {
57    fn default() -> Self {
58        Self {
59            fields: default_fields(),
60        }
61    }
62}
63
64fn default_fields() -> Vec<MessageMetadataField> {
65    vec![MessageMetadataField::Timestamp]
66}
67
68impl MessageMetadataConfig {
69    /// Parse from JSON value, falling back to defaults for invalid config.
70    pub fn from_json(value: &serde_json::Value) -> Self {
71        serde_json::from_value(value.clone()).unwrap_or_default()
72    }
73}
74
75/// MessageMetadata capability — annotates conversation messages with metadata
76/// (e.g. their timestamp) when they are sent to the LLM.
77pub struct MessageMetadataCapability;
78
79impl Capability for MessageMetadataCapability {
80    fn id(&self) -> &str {
81        MESSAGE_METADATA_CAPABILITY_ID
82    }
83
84    fn name(&self) -> &str {
85        "Message Metadata"
86    }
87
88    fn description(&self) -> &str {
89        "Annotates user and agent messages with metadata (message timestamp, UTC) when building the LLM request, so the model can reason about timing and gaps between messages. Stored messages are unchanged."
90    }
91
92    fn icon(&self) -> Option<&str> {
93        Some("clock")
94    }
95
96    fn category(&self) -> Option<&str> {
97        Some("Core")
98    }
99
100    fn system_prompt_addition(&self) -> Option<&str> {
101        Some(
102            "Conversation messages carry a bracketed annotation added by the system, e.g. `[time 2026-06-11T09:15:42Z]` — the message's timestamp (UTC). Use it to reason about timing and gaps between messages. It is not part of what the author wrote; never emit such annotations in your replies.",
103        )
104    }
105
106    fn config_schema(&self) -> Option<serde_json::Value> {
107        Some(serde_json::json!({
108            "type": "object",
109            "properties": {
110                "fields": {
111                    "type": "array",
112                    "items": {
113                        "type": "string",
114                        "title": "Metadata field",
115                        "description": "Metadata field rendered as a bracketed prefix on each message.",
116                        "oneOf": [
117                            { "const": "timestamp", "title": "Timestamp" }
118                        ]
119                    },
120                    "default": ["timestamp"],
121                    "title": "Metadata fields to annotate",
122                    "description": "Which metadata fields are annotated onto user and agent messages, in render order. An empty list disables annotations."
123                }
124            },
125            "additionalProperties": false
126        }))
127    }
128
129    fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
130        if config.is_null() {
131            return Ok(());
132        }
133        serde_json::from_value::<MessageMetadataConfig>(config.clone())
134            .map(|_| ())
135            .map_err(|e| format!("invalid message_metadata config: {e}"))
136    }
137
138    fn model_view_provider(&self) -> Option<Arc<dyn ModelViewProvider>> {
139        Some(Arc::new(MessageMetadataModelViewProvider))
140    }
141
142    fn localizations(&self) -> Vec<CapabilityLocalization> {
143        vec![
144            CapabilityLocalization {
145                locale: "en",
146                name: None,
147                description: None,
148                config_description: Some(
149                    "Choose which metadata fields are annotated onto messages sent to the LLM.",
150                ),
151                config_overlay: None,
152            },
153            CapabilityLocalization {
154                locale: "uk",
155                name: Some("Метадані повідомлень"),
156                description: Some(
157                    "Додає до повідомлень користувача й агента метадані (часову позначку, UTC) \
158                     під час формування запиту до LLM, щоб модель могла враховувати час і паузи \
159                     між повідомленнями. Збережені повідомлення не змінюються.",
160                ),
161                config_description: Some(
162                    "Визначає, які поля метаданих додаються до повідомлень, що надсилаються LLM.",
163                ),
164                config_overlay: Some(serde_json::json!({
165                    "properties": {
166                        "fields": {
167                            "title": "Поля метаданих",
168                            "description": "Які поля метаданих додаються до повідомлень користувача й агента, у порядку відображення. Порожній список вимикає анотації.",
169                            "items": {
170                                "title": "Поле метаданих",
171                                "description": "Поле метаданих, що відображається як префікс у дужках для кожного повідомлення.",
172                                "enum_labels": {
173                                    "timestamp": "Часова позначка"
174                                }
175                            }
176                        }
177                    }
178                })),
179            },
180        ]
181    }
182}
183
184struct MessageMetadataModelViewProvider;
185
186impl ModelViewProvider for MessageMetadataModelViewProvider {
187    fn apply_model_view(
188        &self,
189        mut messages: Vec<Message>,
190        config: &serde_json::Value,
191        _context: &ModelViewContext<'_>,
192    ) -> Vec<Message> {
193        let config = MessageMetadataConfig::from_json(config);
194        for msg in &mut messages {
195            if matches!(msg.role, MessageRole::User | MessageRole::Agent) {
196                annotate_message(msg, &config.fields);
197            }
198        }
199        messages
200    }
201
202    /// After compaction masking (50) so annotations land on the final view.
203    fn priority(&self) -> i32 {
204        100
205    }
206}
207
208/// Strip synthetic leading `[time <RFC3339 UTC>]` annotations from generated
209/// assistant text before it is persisted.
210///
211/// The model view prepends `[time …]` to messages so the model can reason about
212/// timing, with a system-prompt instruction never to emit it. Models
213/// nevertheless echo the annotation into their replies (EVE-710); the echoed
214/// text is then stored and shown to the user, breaking exact-output prompts.
215/// This removes only the exact synthetic pattern — one or more leading
216/// `[time <timestamp>]` segments, each followed by an optional single space —
217/// validating the bracket contents as a real RFC3339 timestamp so legitimate
218/// user-authored text like `[time to go]` is left untouched. Only leading
219/// occurrences are removed; a bracketed timestamp later in the text is treated
220/// as authored content and preserved.
221pub fn strip_leading_timestamp_annotations(text: &str) -> String {
222    let mut rest = text;
223    while let Some(after) = strip_one_timestamp_annotation(rest) {
224        rest = after;
225    }
226    rest.to_string()
227}
228
229/// Strip a single leading `[time <rfc3339>]` (plus one optional following space)
230/// from `text`, returning the remainder when it matches the synthetic format.
231fn strip_one_timestamp_annotation(text: &str) -> Option<&str> {
232    const PREFIX: &str = "[time ";
233    let rest = text.strip_prefix(PREFIX)?;
234    let close = rest.find(']')?;
235    // Only strip when the bracket holds a real RFC3339 timestamp — the exact
236    // synthetic format produced by `MessageMetadataField::Timestamp::render`.
237    chrono::DateTime::parse_from_rfc3339(&rest[..close]).ok()?;
238    let after = &rest[close + 1..];
239    Some(after.strip_prefix(' ').unwrap_or(after))
240}
241
242/// Render the combined metadata annotation for a message, e.g.
243/// `[time 2026-06-11T09:15:42Z]`. Returns `None` when no field yields a value.
244pub fn render_annotation(msg: &Message, fields: &[MessageMetadataField]) -> Option<String> {
245    let segments: Vec<String> = fields.iter().filter_map(|f| f.render(msg)).collect();
246    if segments.is_empty() {
247        None
248    } else {
249        Some(segments.join(" "))
250    }
251}
252
253fn annotate_message(msg: &mut Message, fields: &[MessageMetadataField]) {
254    let Some(annotation) = render_annotation(msg, fields) else {
255        return;
256    };
257    if let Some(ContentPart::Text(t)) = msg
258        .content
259        .iter_mut()
260        .find(|p| matches!(p, ContentPart::Text(_)))
261    {
262        t.text = if t.text.is_empty() {
263            annotation
264        } else {
265            format!("{annotation} {}", t.text)
266        };
267    } else {
268        // No text part (e.g. tool-call-only agent message): carry the
269        // annotation as its own leading text part.
270        msg.content.insert(0, ContentPart::text(annotation));
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::capabilities::CapabilityRegistry;
278    use crate::message::ToolCallContentPart;
279    use crate::typed_id::SessionId;
280
281    fn ctx() -> ModelViewContext<'static> {
282        ModelViewContext {
283            session_id: SessionId::new(),
284            prior_usage: None,
285        }
286    }
287
288    fn apply(messages: Vec<Message>, config: serde_json::Value) -> Vec<Message> {
289        MessageMetadataModelViewProvider.apply_model_view(messages, &config, &ctx())
290    }
291
292    fn time_annotation(msg: &Message) -> String {
293        render_annotation(msg, &[MessageMetadataField::Timestamp]).unwrap()
294    }
295
296    // Metadata constants covered by builtin_capabilities_satisfy_registry_invariants.
297
298    #[test]
299    fn test_capability_in_registry() {
300        let registry = CapabilityRegistry::with_builtins();
301        let cap = registry.get(MESSAGE_METADATA_CAPABILITY_ID).unwrap();
302        assert!(cap.model_view_provider().is_some());
303    }
304
305    #[test]
306    fn test_annotates_user_and_agent_messages() {
307        let user = Message::user("hello");
308        let agent = Message::assistant("hi there");
309        let expected_user = time_annotation(&user);
310        let expected_agent = time_annotation(&agent);
311
312        let out = apply(vec![user, agent], serde_json::json!({}));
313
314        assert_eq!(
315            out[0].text().unwrap(),
316            format!("{expected_user} hello"),
317            "user message gets timestamp prefix"
318        );
319        assert_eq!(out[1].text().unwrap(), format!("{expected_agent} hi there"));
320    }
321
322    #[test]
323    fn test_skips_system_and_tool_result_messages() {
324        let system = Message::system("you are a bot");
325        let tool = Message::tool_result("call_1", Some(serde_json::json!({"ok": true})), None);
326
327        let out = apply(vec![system, tool], serde_json::json!({}));
328
329        assert_eq!(out[0].text().unwrap(), "you are a bot");
330        assert!(out[1].text().is_none());
331    }
332
333    #[test]
334    fn test_explicit_fields_config() {
335        let user = Message::user("hello");
336        let expected = time_annotation(&user);
337        let out = apply(vec![user], serde_json::json!({"fields": ["timestamp"]}));
338        assert_eq!(out[0].text().unwrap(), format!("{expected} hello"));
339    }
340
341    #[test]
342    fn test_empty_fields_disable_annotations() {
343        let user = Message::user("hello");
344        let out = apply(vec![user], serde_json::json!({"fields": []}));
345        assert_eq!(out[0].text().unwrap(), "hello");
346        assert_eq!(out[0].content.len(), 1);
347    }
348
349    #[test]
350    fn test_tool_call_only_agent_message_gets_text_part() {
351        let mut agent = Message::assistant("");
352        agent.content = vec![ContentPart::ToolCall(ToolCallContentPart::new(
353            "call_1",
354            "get_weather",
355            serde_json::json!({}),
356        ))];
357        let expected = time_annotation(&agent);
358
359        let out = apply(vec![agent], serde_json::json!({}));
360
361        assert_eq!(out[0].content.len(), 2);
362        assert_eq!(out[0].text().unwrap(), expected);
363        assert!(matches!(out[0].content[1], ContentPart::ToolCall(_)));
364    }
365
366    #[test]
367    fn test_empty_text_part_gets_annotation_without_trailing_space() {
368        let agent = Message::assistant("");
369        let expected = time_annotation(&agent);
370
371        let out = apply(vec![agent], serde_json::json!({}));
372
373        assert_eq!(out[0].text().unwrap(), expected);
374    }
375
376    #[test]
377    fn test_annotation_format_is_rfc3339_utc() {
378        let user = Message::user("hello");
379        let out = apply(vec![user], serde_json::json!({}));
380        let text = out[0].text().unwrap();
381        assert!(text.starts_with("[time 2"), "got: {text}");
382        assert!(text.contains("Z] hello"), "got: {text}");
383    }
384
385    // EVE-710: models echo the synthetic `[time …]` prefix into their replies;
386    // it must be stripped before the assistant text is persisted.
387    #[test]
388    fn strip_removes_single_leading_annotation() {
389        assert_eq!(
390            strip_leading_timestamp_annotations("[time 2026-07-10T05:38:28Z] cobalt"),
391            "cobalt"
392        );
393    }
394
395    // EVE-710: duplicated prefixes (observed in the wild) are all removed.
396    #[test]
397    fn strip_removes_repeated_leading_annotations() {
398        assert_eq!(
399            strip_leading_timestamp_annotations(
400                "[time 2026-07-10T05:38:21Z] [time 2026-07-10T05:38:21Z] Understood"
401            ),
402            "Understood"
403        );
404    }
405
406    // EVE-710: an annotation with no following space still strips cleanly.
407    #[test]
408    fn strip_handles_annotation_without_trailing_space() {
409        assert_eq!(
410            strip_leading_timestamp_annotations("[time 2026-07-10T05:38:28Z]hi"),
411            "hi"
412        );
413    }
414
415    // EVE-710: legitimate user-authored bracket text that is not a timestamp is
416    // preserved.
417    #[test]
418    fn strip_preserves_non_timestamp_bracket_text() {
419        assert_eq!(
420            strip_leading_timestamp_annotations("[time to go] home"),
421            "[time to go] home"
422        );
423        assert_eq!(
424            strip_leading_timestamp_annotations("hello world"),
425            "hello world"
426        );
427    }
428
429    // EVE-710: a real timestamp appearing later in the text is authored content,
430    // not the synthetic leading prefix, and is kept.
431    #[test]
432    fn strip_only_touches_leading_annotation() {
433        assert_eq!(
434            strip_leading_timestamp_annotations("see [time 2026-07-10T05:38:28Z]"),
435            "see [time 2026-07-10T05:38:28Z]"
436        );
437    }
438
439    // EVE-710: stripping is the inverse of the model-view annotation, so an
440    // agent reply that merely echoed the injected prefix round-trips to the
441    // author's original text.
442    #[test]
443    fn strip_inverts_render_annotation() {
444        let agent = Message::assistant("the answer");
445        let annotated = format!("{} the answer", time_annotation(&agent));
446        assert_eq!(
447            strip_leading_timestamp_annotations(&annotated),
448            "the answer"
449        );
450    }
451
452    #[test]
453    fn test_validate_config() {
454        let cap = MessageMetadataCapability;
455        assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
456        assert!(cap.validate_config(&serde_json::json!({})).is_ok());
457        assert!(
458            cap.validate_config(&serde_json::json!({"fields": ["timestamp"]}))
459                .is_ok()
460        );
461        assert!(
462            cap.validate_config(&serde_json::json!({"fields": []}))
463                .is_ok()
464        );
465        assert!(
466            cap.validate_config(&serde_json::json!({"fields": ["model"]}))
467                .is_err(),
468            "unknown metadata fields are rejected until implemented"
469        );
470        assert!(
471            cap.validate_config(&serde_json::json!({"fields": "timestamp"}))
472                .is_err(),
473            "fields must be an array"
474        );
475        assert!(
476            cap.validate_config(&serde_json::json!({"user_messages": false}))
477                .is_err(),
478            "role toggles were removed; user/agent messages are always annotated"
479        );
480        assert!(
481            cap.validate_config(&serde_json::json!({"unknown": true}))
482                .is_err()
483        );
484    }
485
486    /// Keeps `config_schema()` honest against the serde config shape, since
487    /// there is no compile-time link between the two.
488    #[test]
489    fn test_config_schema_matches_config_shape() {
490        let cap = MessageMetadataCapability;
491        let schema = cap.config_schema().expect("capability exposes a schema");
492
493        assert_eq!(schema["type"], "object");
494        assert_eq!(
495            schema["additionalProperties"], false,
496            "schema must reject unknown keys like validate_config does"
497        );
498
499        // Schema properties == serde fields of the default config.
500        let schema_keys: std::collections::BTreeSet<&str> = schema["properties"]
501            .as_object()
502            .expect("properties object")
503            .keys()
504            .map(String::as_str)
505            .collect();
506        let config_value = serde_json::to_value(MessageMetadataConfig::default()).unwrap();
507        let config_keys: std::collections::BTreeSet<&str> = config_value
508            .as_object()
509            .expect("config serializes to object")
510            .keys()
511            .map(String::as_str)
512            .collect();
513        assert_eq!(schema_keys, config_keys);
514
515        // The schema's labeled oneOf of field names matches
516        // MessageMetadataField's serde names, and its default parses as a
517        // valid config.
518        let enum_values: Vec<serde_json::Value> = schema["properties"]["fields"]["items"]["oneOf"]
519            .as_array()
520            .expect("fields oneOf")
521            .iter()
522            .map(|option| option["const"].clone())
523            .collect();
524        for value in &enum_values {
525            assert!(
526                serde_json::from_value::<MessageMetadataField>(value.clone()).is_ok(),
527                "schema oneOf const {value} is not a known MessageMetadataField"
528            );
529        }
530        assert_eq!(
531            enum_values.len(),
532            1,
533            "add new MessageMetadataField variants to the schema oneOf"
534        );
535        let schema_default = serde_json::json!({
536            "fields": schema["properties"]["fields"]["default"]
537        });
538        assert!(cap.validate_config(&schema_default).is_ok());
539    }
540}