use std::sync::Arc;
use chrono::SecondsFormat;
use serde::{Deserialize, Serialize};
use super::{Capability, CapabilityLocalization, ModelViewContext, ModelViewProvider};
use crate::builtins::message::{ContentPart, RuntimeMessage, RuntimeMessageRole};
pub const MESSAGE_METADATA_CAPABILITY_ID: &str = "message_metadata";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageMetadataField {
Timestamp,
}
impl MessageMetadataField {
fn render(&self, msg: &RuntimeMessage) -> Option<String> {
match self {
Self::Timestamp => Some(format!(
"[time {}]",
msg.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MessageMetadataConfig {
#[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 {
pub fn from_json(value: &serde_json::Value) -> Self {
serde_json::from_value(value.clone()).unwrap_or_default()
}
}
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 filter_response_text(&self, text: String, _config: &serde_json::Value) -> String {
let text = strip_leading_timestamp_annotations(&text);
let text = strip_leading_facts_blocks(&text);
strip_degenerate_time_echo(&text)
}
fn icon(&self) -> Option<&str> {
Some("clock")
}
fn category(&self) -> Option<&str> {
Some("Core")
}
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<RuntimeMessage>,
config: &serde_json::Value,
_context: &ModelViewContext<'_>,
) -> Vec<RuntimeMessage> {
let config = MessageMetadataConfig::from_json(config);
for msg in &mut messages {
if matches!(
msg.role,
RuntimeMessageRole::User | RuntimeMessageRole::Agent
) {
annotate_message(msg, &config.fields);
}
}
messages
}
fn priority(&self) -> i32 {
100
}
}
pub fn strip_leading_timestamp_annotations(text: &str) -> String {
let mut rest = text;
while let Some(after) = strip_one_timestamp_annotation(rest) {
rest = after;
}
rest.to_string()
}
fn strip_one_timestamp_annotation(text: &str) -> Option<&str> {
const PREFIX: &str = "[time ";
let rest = text.strip_prefix(PREFIX)?;
let close = rest.find(']')?;
chrono::DateTime::parse_from_rfc3339(&rest[..close]).ok()?;
let after = &rest[close + 1..];
Some(after.strip_prefix(' ').unwrap_or(after))
}
pub fn strip_leading_facts_blocks(text: &str) -> String {
let mut rest = text;
while let Some(after) = strip_one_facts_block(rest) {
rest = after;
}
rest.to_string()
}
fn strip_one_facts_block(text: &str) -> Option<&str> {
const OPEN: &str = "<facts>";
const CLOSE: &str = "</facts>";
let trimmed = text.trim_start();
let rest = trimmed.strip_prefix(OPEN)?;
let close = rest.find(CLOSE)?;
let after = &rest[close + CLOSE.len()..];
Some(after.strip_prefix('\n').unwrap_or(after).trim_start())
}
pub fn strip_degenerate_time_echo(text: &str) -> String {
let trimmed = text.trim();
if trimmed.is_empty() {
return text.to_string();
}
if let Some(rest) = trimmed.strip_prefix("time") {
let payload = rest.trim_start();
if is_degenerate_time_payload(payload) {
return String::new();
}
}
if let Some(rest) = trimmed.strip_prefix("[time ")
&& let Some(close) = rest.find(']')
{
let payload = &rest[..close];
let after = rest[close + 1..].trim();
if after.is_empty()
&& chrono::DateTime::parse_from_rfc3339(payload).is_err()
&& is_degenerate_time_payload(payload)
{
return String::new();
}
}
text.to_string()
}
fn is_degenerate_time_payload(payload: &str) -> bool {
let payload = payload.trim();
if payload.is_empty() {
return false;
}
if chrono::DateTime::parse_from_rfc3339(payload).is_ok() {
return true;
}
if payload
.chars()
.all(|c| c.is_ascii_digit() || matches!(c, ':' | '.' | ' '))
{
return false;
}
!payload.chars().any(|c| c.is_ascii_alphabetic())
}
pub fn render_annotation(msg: &RuntimeMessage, 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 RuntimeMessage, 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 {
msg.content.insert(0, ContentPart::text(annotation));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::builtins::message::ToolCallContentPart;
use crate::builtins::typed_id::SessionId;
fn ctx() -> ModelViewContext<'static> {
ModelViewContext {
session_id: SessionId::new(),
prior_usage: None,
provider_managed_reduction: false,
}
}
fn apply(messages: Vec<RuntimeMessage>, config: serde_json::Value) -> Vec<RuntimeMessage> {
MessageMetadataModelViewProvider.apply_model_view(messages, &config, &ctx())
}
fn time_annotation(msg: &RuntimeMessage) -> String {
render_annotation(msg, &[MessageMetadataField::Timestamp]).unwrap()
}
#[test]
fn test_capability_in_registry() {
let registry = crate::builtins::portable_capability_registry().unwrap();
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 = RuntimeMessage::user("hello");
let agent = RuntimeMessage::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 = RuntimeMessage::system("you are a bot");
let tool =
RuntimeMessage::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 = RuntimeMessage::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 = RuntimeMessage::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 = RuntimeMessage::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 = RuntimeMessage::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 = RuntimeMessage::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 strip_removes_single_leading_annotation() {
assert_eq!(
strip_leading_timestamp_annotations("[time 2026-07-10T05:38:28Z] cobalt"),
"cobalt"
);
}
#[test]
fn strip_removes_repeated_leading_annotations() {
assert_eq!(
strip_leading_timestamp_annotations(
"[time 2026-07-10T05:38:21Z] [time 2026-07-10T05:38:21Z] Understood"
),
"Understood"
);
}
#[test]
fn strip_handles_annotation_without_trailing_space() {
assert_eq!(
strip_leading_timestamp_annotations("[time 2026-07-10T05:38:28Z]hi"),
"hi"
);
}
#[test]
fn strip_preserves_non_timestamp_bracket_text() {
assert_eq!(
strip_leading_timestamp_annotations("[time to go] home"),
"[time to go] home"
);
assert_eq!(
strip_leading_timestamp_annotations("hello world"),
"hello world"
);
}
#[test]
fn strip_only_touches_leading_annotation() {
assert_eq!(
strip_leading_timestamp_annotations("see [time 2026-07-10T05:38:28Z]"),
"see [time 2026-07-10T05:38:28Z]"
);
}
#[test]
fn strip_inverts_render_annotation() {
let agent = RuntimeMessage::assistant("the answer");
let annotated = format!("{} the answer", time_annotation(&agent));
assert_eq!(
strip_leading_timestamp_annotations(&annotated),
"the answer"
);
}
#[test]
fn strip_removes_leading_facts_blocks_with_current_time() {
let echoed = "<facts>\n- current_time: 2026-10-03T03:47:00Z\n</facts>\nHello";
assert_eq!(strip_leading_facts_blocks(echoed), "Hello");
assert_eq!(
strip_leading_facts_blocks(
"<facts>\n- current_time: 2026-10-03T03:47:00Z\n</facts>\n\
<facts>\n- current_time: 2026-10-03T03:48:00Z\n</facts>\nok"
),
"ok"
);
assert_eq!(
strip_leading_facts_blocks("see <facts>\n- current_time: x\n</facts>"),
"see <facts>\n- current_time: x\n</facts>"
);
assert_eq!(
strip_leading_facts_blocks("<facts>no close"),
"<facts>no close"
);
}
#[test]
fn strip_clears_degenerate_unbracketed_time_echoes() {
assert_eq!(strip_degenerate_time_echo("time天天中彩票不能"), "");
assert_eq!(strip_degenerate_time_echo("time 天天中彩票不能"), "");
assert_eq!(
strip_degenerate_time_echo(" time 2026-10-03T03:47:00Z "),
""
);
assert_eq!(strip_degenerate_time_echo("[time 天天中彩票不能]"), "");
assert_eq!(
strip_degenerate_time_echo("time to go home"),
"time to go home"
);
assert_eq!(strip_degenerate_time_echo("time 2:30"), "time 2:30");
assert_eq!(strip_degenerate_time_echo("time 14:30:00"), "time 14:30:00");
assert_eq!(
strip_degenerate_time_echo("[time to go] home"),
"[time to go] home"
);
assert_eq!(
strip_degenerate_time_echo("time 天天中彩票不能 then continue"),
"time 天天中彩票不能 then continue"
);
}
#[test]
fn filter_response_text_runs_all_echo_strips() {
let cap = MessageMetadataCapability;
let echoed = "[time 2026-10-03T03:47:00Z] <facts>\n- current_time: 2026-10-03T03:47:00Z\n</facts>\ntime天天中彩票不能";
assert_eq!(
cap.filter_response_text(echoed.into(), &serde_json::Value::Null),
""
);
assert_eq!(
cap.filter_response_text(
"[time 2026-10-03T03:47:00Z] cobalt".into(),
&serde_json::Value::Null
),
"cobalt"
);
}
#[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": ["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()
);
}
#[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"
);
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);
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());
}
}