everruns-core 0.11.0

Core agent abstractions for Everruns - agent loop, events, tools, LLM providers
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
//! MessageMetadata Capability — annotates user/agent messages with metadata
//! (currently the message timestamp) in the prompt-facing model view.
//!
//! Annotations are applied via [`ModelViewProvider`] at LLM message
//! construction time; stored messages are never modified. Timestamps come from
//! `Message::created_at`, which is immutable, so annotations are stable across
//! turns and do not invalidate provider prompt caches.

use std::sync::Arc;

use chrono::SecondsFormat;
use serde::{Deserialize, Serialize};

use super::{Capability, CapabilityLocalization, ModelViewContext, ModelViewProvider};
use crate::message::{ContentPart, Message, MessageRole};

pub const MESSAGE_METADATA_CAPABILITY_ID: &str = "message_metadata";

/// A metadata field that can be annotated onto a message.
///
/// New fields (e.g. the LLM model that produced an agent message, once stored
/// messages record it) are added as variants here; each variant renders its
/// own bracketed segment and may return `None` when the message lacks the data.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageMetadataField {
    /// Message timestamp (`Message::created_at`), rendered as
    /// `[time <RFC3339 UTC>]`. For user messages this is when the message was
    /// received; for agent messages, when the reply was generated.
    Timestamp,
}

impl MessageMetadataField {
    fn render(&self, msg: &Message) -> Option<String> {
        match self {
            Self::Timestamp => Some(format!(
                "[time {}]",
                msg.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)
            )),
        }
    }
}

/// Per-agent configuration for message metadata annotations.
///
/// User and agent messages are always annotated; only the rendered fields are
/// configurable.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MessageMetadataConfig {
    /// Which metadata fields to annotate, in render order.
    #[serde(default = "default_fields")]
    pub fields: Vec<MessageMetadataField>,
}

impl Default for MessageMetadataConfig {
    fn default() -> Self {
        Self {
            fields: default_fields(),
        }
    }
}

fn default_fields() -> Vec<MessageMetadataField> {
    vec![MessageMetadataField::Timestamp]
}

impl MessageMetadataConfig {
    /// Parse from JSON value, falling back to defaults for invalid config.
    pub fn from_json(value: &serde_json::Value) -> Self {
        serde_json::from_value(value.clone()).unwrap_or_default()
    }
}

/// MessageMetadata capability — annotates conversation messages with metadata
/// (e.g. their timestamp) when they are sent to the LLM.
pub struct MessageMetadataCapability;

impl Capability for MessageMetadataCapability {
    fn id(&self) -> &str {
        MESSAGE_METADATA_CAPABILITY_ID
    }

    fn name(&self) -> &str {
        "Message Metadata"
    }

    fn description(&self) -> &str {
        "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."
    }

    fn icon(&self) -> Option<&str> {
        Some("clock")
    }

    fn category(&self) -> Option<&str> {
        Some("Utilities")
    }

    fn system_prompt_addition(&self) -> Option<&str> {
        Some(
            "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.",
        )
    }

    fn config_schema(&self) -> Option<serde_json::Value> {
        Some(serde_json::json!({
            "type": "object",
            "properties": {
                "fields": {
                    "type": "array",
                    "items": {
                        "type": "string",
                        "title": "Metadata field",
                        "description": "Metadata field rendered as a bracketed prefix on each message.",
                        "oneOf": [
                            { "const": "timestamp", "title": "Timestamp" }
                        ]
                    },
                    "default": ["timestamp"],
                    "title": "Metadata fields to annotate",
                    "description": "Which metadata fields are annotated onto user and agent messages, in render order. An empty list disables annotations."
                }
            },
            "additionalProperties": false
        }))
    }

    fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
        if config.is_null() {
            return Ok(());
        }
        serde_json::from_value::<MessageMetadataConfig>(config.clone())
            .map(|_| ())
            .map_err(|e| format!("invalid message_metadata config: {e}"))
    }

    fn model_view_provider(&self) -> Option<Arc<dyn ModelViewProvider>> {
        Some(Arc::new(MessageMetadataModelViewProvider))
    }

    fn localizations(&self) -> Vec<CapabilityLocalization> {
        vec![
            CapabilityLocalization {
                locale: "en",
                name: None,
                description: None,
                config_description: Some(
                    "Choose which metadata fields are annotated onto messages sent to the LLM.",
                ),
                config_overlay: None,
            },
            CapabilityLocalization {
                locale: "uk",
                name: Some("Метадані повідомлень"),
                description: Some(
                    "Додає до повідомлень користувача й агента метадані (часову позначку, UTC) \
                     під час формування запиту до LLM, щоб модель могла враховувати час і паузи \
                     між повідомленнями. Збережені повідомлення не змінюються.",
                ),
                config_description: Some(
                    "Визначає, які поля метаданих додаються до повідомлень, що надсилаються LLM.",
                ),
                config_overlay: Some(serde_json::json!({
                    "properties": {
                        "fields": {
                            "title": "Поля метаданих",
                            "description": "Які поля метаданих додаються до повідомлень користувача й агента, у порядку відображення. Порожній список вимикає анотації.",
                            "items": {
                                "title": "Поле метаданих",
                                "description": "Поле метаданих, що відображається як префікс у дужках для кожного повідомлення.",
                                "enum_labels": {
                                    "timestamp": "Часова позначка"
                                }
                            }
                        }
                    }
                })),
            },
        ]
    }
}

struct MessageMetadataModelViewProvider;

impl ModelViewProvider for MessageMetadataModelViewProvider {
    fn apply_model_view(
        &self,
        mut messages: Vec<Message>,
        config: &serde_json::Value,
        _context: &ModelViewContext<'_>,
    ) -> Vec<Message> {
        let config = MessageMetadataConfig::from_json(config);
        for msg in &mut messages {
            if matches!(msg.role, MessageRole::User | MessageRole::Agent) {
                annotate_message(msg, &config.fields);
            }
        }
        messages
    }

    /// After compaction masking (50) so annotations land on the final view.
    fn priority(&self) -> i32 {
        100
    }
}

/// Render the combined metadata annotation for a message, e.g.
/// `[time 2026-06-11T09:15:42Z]`. Returns `None` when no field yields a value.
pub fn render_annotation(msg: &Message, fields: &[MessageMetadataField]) -> Option<String> {
    let segments: Vec<String> = fields.iter().filter_map(|f| f.render(msg)).collect();
    if segments.is_empty() {
        None
    } else {
        Some(segments.join(" "))
    }
}

fn annotate_message(msg: &mut Message, fields: &[MessageMetadataField]) {
    let Some(annotation) = render_annotation(msg, fields) else {
        return;
    };
    if let Some(ContentPart::Text(t)) = msg
        .content
        .iter_mut()
        .find(|p| matches!(p, ContentPart::Text(_)))
    {
        t.text = if t.text.is_empty() {
            annotation
        } else {
            format!("{annotation} {}", t.text)
        };
    } else {
        // No text part (e.g. tool-call-only agent message): carry the
        // annotation as its own leading text part.
        msg.content.insert(0, ContentPart::text(annotation));
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::capabilities::CapabilityRegistry;
    use crate::message::ToolCallContentPart;
    use crate::typed_id::SessionId;

    fn ctx() -> ModelViewContext<'static> {
        ModelViewContext {
            session_id: SessionId::new(),
            prior_usage: None,
        }
    }

    fn apply(messages: Vec<Message>, config: serde_json::Value) -> Vec<Message> {
        MessageMetadataModelViewProvider.apply_model_view(messages, &config, &ctx())
    }

    fn time_annotation(msg: &Message) -> String {
        render_annotation(msg, &[MessageMetadataField::Timestamp]).unwrap()
    }

    #[test]
    fn test_capability_metadata() {
        let cap = MessageMetadataCapability;
        assert_eq!(cap.id(), "message_metadata");
        assert_eq!(cap.name(), "Message Metadata");
        assert_eq!(cap.category(), Some("Utilities"));
        assert!(cap.system_prompt_addition().is_some());
        assert!(cap.tools().is_empty());
    }

    #[test]
    fn test_capability_in_registry() {
        let registry = CapabilityRegistry::with_builtins();
        let cap = registry.get(MESSAGE_METADATA_CAPABILITY_ID).unwrap();
        assert!(cap.model_view_provider().is_some());
    }

    #[test]
    fn test_annotates_user_and_agent_messages() {
        let user = Message::user("hello");
        let agent = Message::assistant("hi there");
        let expected_user = time_annotation(&user);
        let expected_agent = time_annotation(&agent);

        let out = apply(vec![user, agent], serde_json::json!({}));

        assert_eq!(
            out[0].text().unwrap(),
            format!("{expected_user} hello"),
            "user message gets timestamp prefix"
        );
        assert_eq!(out[1].text().unwrap(), format!("{expected_agent} hi there"));
    }

    #[test]
    fn test_skips_system_and_tool_result_messages() {
        let system = Message::system("you are a bot");
        let tool = Message::tool_result("call_1", Some(serde_json::json!({"ok": true})), None);

        let out = apply(vec![system, tool], serde_json::json!({}));

        assert_eq!(out[0].text().unwrap(), "you are a bot");
        assert!(out[1].text().is_none());
    }

    #[test]
    fn test_explicit_fields_config() {
        let user = Message::user("hello");
        let expected = time_annotation(&user);
        let out = apply(vec![user], serde_json::json!({"fields": ["timestamp"]}));
        assert_eq!(out[0].text().unwrap(), format!("{expected} hello"));
    }

    #[test]
    fn test_empty_fields_disable_annotations() {
        let user = Message::user("hello");
        let out = apply(vec![user], serde_json::json!({"fields": []}));
        assert_eq!(out[0].text().unwrap(), "hello");
        assert_eq!(out[0].content.len(), 1);
    }

    #[test]
    fn test_tool_call_only_agent_message_gets_text_part() {
        let mut agent = Message::assistant("");
        agent.content = vec![ContentPart::ToolCall(ToolCallContentPart::new(
            "call_1",
            "get_weather",
            serde_json::json!({}),
        ))];
        let expected = time_annotation(&agent);

        let out = apply(vec![agent], serde_json::json!({}));

        assert_eq!(out[0].content.len(), 2);
        assert_eq!(out[0].text().unwrap(), expected);
        assert!(matches!(out[0].content[1], ContentPart::ToolCall(_)));
    }

    #[test]
    fn test_empty_text_part_gets_annotation_without_trailing_space() {
        let agent = Message::assistant("");
        let expected = time_annotation(&agent);

        let out = apply(vec![agent], serde_json::json!({}));

        assert_eq!(out[0].text().unwrap(), expected);
    }

    #[test]
    fn test_annotation_format_is_rfc3339_utc() {
        let user = Message::user("hello");
        let out = apply(vec![user], serde_json::json!({}));
        let text = out[0].text().unwrap();
        assert!(text.starts_with("[time 2"), "got: {text}");
        assert!(text.contains("Z] hello"), "got: {text}");
    }

    #[test]
    fn test_validate_config() {
        let cap = MessageMetadataCapability;
        assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
        assert!(cap.validate_config(&serde_json::json!({})).is_ok());
        assert!(
            cap.validate_config(&serde_json::json!({"fields": ["timestamp"]}))
                .is_ok()
        );
        assert!(
            cap.validate_config(&serde_json::json!({"fields": []}))
                .is_ok()
        );
        assert!(
            cap.validate_config(&serde_json::json!({"fields": ["llm_model"]}))
                .is_err(),
            "unknown metadata fields are rejected until implemented"
        );
        assert!(
            cap.validate_config(&serde_json::json!({"fields": "timestamp"}))
                .is_err(),
            "fields must be an array"
        );
        assert!(
            cap.validate_config(&serde_json::json!({"user_messages": false}))
                .is_err(),
            "role toggles were removed; user/agent messages are always annotated"
        );
        assert!(
            cap.validate_config(&serde_json::json!({"unknown": true}))
                .is_err()
        );
    }

    /// Keeps `config_schema()` honest against the serde config shape, since
    /// there is no compile-time link between the two.
    #[test]
    fn test_config_schema_matches_config_shape() {
        let cap = MessageMetadataCapability;
        let schema = cap.config_schema().expect("capability exposes a schema");

        assert_eq!(schema["type"], "object");
        assert_eq!(
            schema["additionalProperties"], false,
            "schema must reject unknown keys like validate_config does"
        );

        // Schema properties == serde fields of the default config.
        let schema_keys: std::collections::BTreeSet<&str> = schema["properties"]
            .as_object()
            .expect("properties object")
            .keys()
            .map(String::as_str)
            .collect();
        let config_value = serde_json::to_value(MessageMetadataConfig::default()).unwrap();
        let config_keys: std::collections::BTreeSet<&str> = config_value
            .as_object()
            .expect("config serializes to object")
            .keys()
            .map(String::as_str)
            .collect();
        assert_eq!(schema_keys, config_keys);

        // The schema's labeled oneOf of field names matches
        // MessageMetadataField's serde names, and its default parses as a
        // valid config.
        let enum_values: Vec<serde_json::Value> = schema["properties"]["fields"]["items"]["oneOf"]
            .as_array()
            .expect("fields oneOf")
            .iter()
            .map(|option| option["const"].clone())
            .collect();
        for value in &enum_values {
            assert!(
                serde_json::from_value::<MessageMetadataField>(value.clone()).is_ok(),
                "schema oneOf const {value} is not a known MessageMetadataField"
            );
        }
        assert_eq!(
            enum_values.len(),
            1,
            "add new MessageMetadataField variants to the schema oneOf"
        );
        let schema_default = serde_json::json!({
            "fields": schema["properties"]["fields"]["default"]
        });
        assert!(cap.validate_config(&schema_default).is_ok());
    }
}