everruns_core/capabilities/
message_metadata.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum MessageMetadataField {
27 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#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct MessageMetadataConfig {
51 #[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 pub fn from_json(value: &serde_json::Value) -> Self {
71 serde_json::from_value(value.clone()).unwrap_or_default()
72 }
73}
74
75pub 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 fn priority(&self) -> i32 {
204 100
205 }
206}
207
208pub 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
229fn 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 chrono::DateTime::parse_from_rfc3339(&rest[..close]).ok()?;
238 let after = &rest[close + 1..];
239 Some(after.strip_prefix(' ').unwrap_or(after))
240}
241
242pub 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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}