Skip to main content

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