add TimeContext: 25 pre-computed time values for the model
midnight-based day boundaries (today, yesterday, 2 days ago),
week/month boundaries, rolling offsets (1h to 30d). injected
into system prompt via {time_block} and per-message via compact
time line. models no longer need to compute epoch timestamps.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
use chrono::Utc;
|
||||
use crate::time_context::TimeContext;
|
||||
|
||||
pub struct Personality {
|
||||
template: String,
|
||||
@@ -18,16 +18,9 @@ impl Personality {
|
||||
memory_notes: Option<&str>,
|
||||
is_dm: bool,
|
||||
) -> String {
|
||||
let now = Utc::now();
|
||||
let date = now.format("%Y-%m-%d").to_string();
|
||||
let epoch_ms = now.timestamp_millis().to_string();
|
||||
let tc = TimeContext::now();
|
||||
let members_str = members.join(", ");
|
||||
|
||||
// Pre-compute reference timestamps so the model doesn't have to do math
|
||||
let ts_1h_ago = (now - chrono::Duration::hours(1)).timestamp_millis().to_string();
|
||||
let ts_yesterday = (now - chrono::Duration::days(1)).timestamp_millis().to_string();
|
||||
let ts_last_week = (now - chrono::Duration::days(7)).timestamp_millis().to_string();
|
||||
|
||||
let room_context_rules = if is_dm {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -40,11 +33,9 @@ impl Personality {
|
||||
};
|
||||
|
||||
self.template
|
||||
.replace("{date}", &date)
|
||||
.replace("{epoch_ms}", &epoch_ms)
|
||||
.replace("{ts_1h_ago}", &ts_1h_ago)
|
||||
.replace("{ts_yesterday}", &ts_yesterday)
|
||||
.replace("{ts_last_week}", &ts_last_week)
|
||||
.replace("{date}", &tc.date)
|
||||
.replace("{epoch_ms}", &tc.now.to_string())
|
||||
.replace("{time_block}", &tc.system_block())
|
||||
.replace("{room_name}", room_name)
|
||||
.replace("{members}", &members_str)
|
||||
.replace("{room_context_rules}", &room_context_rules)
|
||||
@@ -60,7 +51,7 @@ mod tests {
|
||||
fn test_date_substitution() {
|
||||
let p = Personality::new("Today is {date}.".to_string());
|
||||
let result = p.build_system_prompt("general", &[], None, false);
|
||||
let today = Utc::now().format("%Y-%m-%d").to_string();
|
||||
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||
assert_eq!(result, format!("Today is {today}."));
|
||||
}
|
||||
|
||||
@@ -93,7 +84,7 @@ mod tests {
|
||||
let members = vec!["Sienna".to_string(), "Lonni".to_string()];
|
||||
let result = p.build_system_prompt("studio", &members, None, false);
|
||||
|
||||
let today = Utc::now().format("%Y-%m-%d").to_string();
|
||||
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||
assert!(result.starts_with(&format!("Date: {today}")));
|
||||
assert!(result.contains("Room: studio"));
|
||||
assert!(result.contains("People: Sienna, Lonni"));
|
||||
@@ -132,19 +123,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timestamp_variables_substituted() {
|
||||
let p = Personality::new(
|
||||
"now={epoch_ms} 1h={ts_1h_ago} yesterday={ts_yesterday} week={ts_last_week}".to_string(),
|
||||
);
|
||||
fn test_time_block_substituted() {
|
||||
let p = Personality::new("before\n{time_block}\nafter".to_string());
|
||||
let result = p.build_system_prompt("room", &[], None, false);
|
||||
assert!(!result.contains("{time_block}"));
|
||||
assert!(result.contains("epoch_ms:"));
|
||||
assert!(result.contains("today:"));
|
||||
assert!(result.contains("yesterday"));
|
||||
assert!(result.contains("this week"));
|
||||
assert!(result.contains("1h_ago="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_epoch_ms_substituted() {
|
||||
let p = Personality::new("now={epoch_ms}".to_string());
|
||||
let result = p.build_system_prompt("room", &[], None, false);
|
||||
// Should NOT contain the literal placeholders
|
||||
assert!(!result.contains("{epoch_ms}"));
|
||||
assert!(!result.contains("{ts_1h_ago}"));
|
||||
assert!(!result.contains("{ts_yesterday}"));
|
||||
assert!(!result.contains("{ts_last_week}"));
|
||||
// Should contain numeric values
|
||||
assert!(result.starts_with("now="));
|
||||
assert!(result.contains("1h="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::config::Config;
|
||||
use crate::context::ResponseContext;
|
||||
use crate::conversations::ConversationRegistry;
|
||||
use crate::memory;
|
||||
use crate::time_context::TimeContext;
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
/// Run a Mistral chat completion on a blocking thread.
|
||||
@@ -146,7 +147,7 @@ impl Responder {
|
||||
messages.push(ChatMessage::new_user_message(&trigger));
|
||||
}
|
||||
|
||||
let tool_defs = ToolRegistry::tool_definitions(self.tools.has_gitea());
|
||||
let tool_defs = ToolRegistry::tool_definitions(self.tools.has_gitea(), self.tools.has_kratos());
|
||||
let model = Model::new(&self.config.mistral.default_model);
|
||||
let max_iterations = self.config.mistral.max_tool_iterations;
|
||||
|
||||
@@ -280,6 +281,7 @@ impl Responder {
|
||||
conversation_registry: &ConversationRegistry,
|
||||
image_data_uri: Option<&str>,
|
||||
context_hint: Option<String>,
|
||||
event_id: ruma::OwnedEventId,
|
||||
) -> Option<String> {
|
||||
// Apply response delay
|
||||
if !self.config.behavior.instant_responses {
|
||||
@@ -302,24 +304,16 @@ impl Responder {
|
||||
// Pre-response memory query (same as legacy path)
|
||||
let memory_notes = self.load_memory_notes(response_ctx, trigger_body).await;
|
||||
|
||||
// Build the input message with dynamic context header.
|
||||
// Build the input message with dynamic context.
|
||||
// Agent instructions are static (set at creation), so per-message context
|
||||
// (timestamps, room, members, memory) is prepended to each user message.
|
||||
let now = chrono::Utc::now();
|
||||
let epoch_ms = now.timestamp_millis();
|
||||
let ts_1h = (now - chrono::Duration::hours(1)).timestamp_millis();
|
||||
let ts_yesterday = (now - chrono::Duration::days(1)).timestamp_millis();
|
||||
let ts_last_week = (now - chrono::Duration::days(7)).timestamp_millis();
|
||||
let tc = TimeContext::now();
|
||||
|
||||
let mut context_header = format!(
|
||||
"[context: date={}, epoch_ms={}, ts_1h_ago={}, ts_yesterday={}, ts_last_week={}, room={}, room_name={}]",
|
||||
now.format("%Y-%m-%d"),
|
||||
epoch_ms,
|
||||
ts_1h,
|
||||
ts_yesterday,
|
||||
ts_last_week,
|
||||
room_id,
|
||||
"{}\n[room: {} ({})]",
|
||||
tc.message_line(),
|
||||
room_name,
|
||||
room_id,
|
||||
);
|
||||
|
||||
if let Some(ref notes) = memory_notes {
|
||||
@@ -352,9 +346,12 @@ impl Responder {
|
||||
// Check for function calls — execute locally and send results back
|
||||
let function_calls = response.function_calls();
|
||||
if !function_calls.is_empty() {
|
||||
// Agent UX: reactions + threads require the user's event ID
|
||||
// which we don't have in the responder. For now, log tool calls
|
||||
// and skip UX. TODO: pass event_id through ResponseContext.
|
||||
// Agent UX: react with 🔍 and post tool details in a thread
|
||||
let mut progress = crate::agent_ux::AgentProgress::new(
|
||||
room.clone(),
|
||||
event_id.clone(),
|
||||
);
|
||||
progress.start().await;
|
||||
|
||||
let max_iterations = self.config.mistral.max_tool_iterations;
|
||||
let mut current_response = response;
|
||||
@@ -376,13 +373,30 @@ impl Responder {
|
||||
"Executing tool call (conversations)"
|
||||
);
|
||||
|
||||
|
||||
|
||||
let result = self
|
||||
.tools
|
||||
.execute(&fc.name, &fc.arguments, response_ctx)
|
||||
// Post tool call to thread
|
||||
progress
|
||||
.post_step(&crate::agent_ux::AgentProgress::format_tool_call(
|
||||
&fc.name,
|
||||
&fc.arguments,
|
||||
))
|
||||
.await;
|
||||
|
||||
let result = if fc.name == "research" {
|
||||
self.tools
|
||||
.execute_research(
|
||||
&fc.arguments,
|
||||
response_ctx,
|
||||
room,
|
||||
&event_id,
|
||||
0, // depth 0 — orchestrator level
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
self.tools
|
||||
.execute(&fc.name, &fc.arguments, response_ctx)
|
||||
.await
|
||||
};
|
||||
|
||||
let result_str = match result {
|
||||
Ok(s) => {
|
||||
let preview: String = s.chars().take(500).collect();
|
||||
@@ -427,6 +441,9 @@ impl Responder {
|
||||
debug!(iteration, "Tool iteration complete (conversations)");
|
||||
}
|
||||
|
||||
// Done with tool calls
|
||||
progress.done().await;
|
||||
|
||||
// Extract final text from the last response
|
||||
if let Some(text) = current_response.assistant_text() {
|
||||
let text = strip_sol_prefix(&text);
|
||||
|
||||
278
src/time_context.rs
Normal file
278
src/time_context.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
use chrono::{Datelike, Duration, NaiveTime, TimeZone, Utc, Weekday};
|
||||
|
||||
/// Comprehensive time context for the model.
|
||||
/// All epoch values are milliseconds. All day boundaries are midnight UTC.
|
||||
pub struct TimeContext {
|
||||
// ── Current moment ──
|
||||
pub now: i64,
|
||||
pub date: String, // 2026-03-22
|
||||
pub time: String, // 14:35
|
||||
pub datetime: String, // 2026-03-22T14:35:12Z
|
||||
pub day_of_week: String, // Saturday
|
||||
pub day_of_week_short: String, // Sat
|
||||
|
||||
// ── Today ──
|
||||
pub today_start: i64, // midnight today
|
||||
pub today_end: i64, // 23:59:59.999 today
|
||||
|
||||
// ── Yesterday ──
|
||||
pub yesterday_start: i64,
|
||||
pub yesterday_end: i64,
|
||||
pub yesterday_name: String, // Friday
|
||||
|
||||
// ── Day before yesterday ──
|
||||
pub two_days_ago_start: i64,
|
||||
pub two_days_ago_end: i64,
|
||||
pub two_days_ago_name: String,
|
||||
|
||||
// ── This week (Monday start) ──
|
||||
pub this_week_start: i64,
|
||||
|
||||
// ── Last week ──
|
||||
pub last_week_start: i64,
|
||||
pub last_week_end: i64,
|
||||
|
||||
// ── This month ──
|
||||
pub this_month_start: i64,
|
||||
|
||||
// ── Last month ──
|
||||
pub last_month_start: i64,
|
||||
pub last_month_end: i64,
|
||||
|
||||
// ── Rolling offsets from now ──
|
||||
pub ago_1h: i64,
|
||||
pub ago_6h: i64,
|
||||
pub ago_12h: i64,
|
||||
pub ago_24h: i64,
|
||||
pub ago_48h: i64,
|
||||
pub ago_7d: i64,
|
||||
pub ago_14d: i64,
|
||||
pub ago_30d: i64,
|
||||
}
|
||||
|
||||
fn weekday_name(w: Weekday) -> &'static str {
|
||||
match w {
|
||||
Weekday::Mon => "Monday",
|
||||
Weekday::Tue => "Tuesday",
|
||||
Weekday::Wed => "Wednesday",
|
||||
Weekday::Thu => "Thursday",
|
||||
Weekday::Fri => "Friday",
|
||||
Weekday::Sat => "Saturday",
|
||||
Weekday::Sun => "Sunday",
|
||||
}
|
||||
}
|
||||
|
||||
fn weekday_short(w: Weekday) -> &'static str {
|
||||
match w {
|
||||
Weekday::Mon => "Mon",
|
||||
Weekday::Tue => "Tue",
|
||||
Weekday::Wed => "Wed",
|
||||
Weekday::Thu => "Thu",
|
||||
Weekday::Fri => "Fri",
|
||||
Weekday::Sat => "Sat",
|
||||
Weekday::Sun => "Sun",
|
||||
}
|
||||
}
|
||||
|
||||
impl TimeContext {
|
||||
pub fn now() -> Self {
|
||||
let now = Utc::now();
|
||||
let today = now.date_naive();
|
||||
let yesterday = today - Duration::days(1);
|
||||
let two_days_ago = today - Duration::days(2);
|
||||
|
||||
let midnight = NaiveTime::from_hms_opt(0, 0, 0).unwrap();
|
||||
let end_of_day = NaiveTime::from_hms_milli_opt(23, 59, 59, 999).unwrap();
|
||||
|
||||
let today_start = Utc.from_utc_datetime(&today.and_time(midnight)).timestamp_millis();
|
||||
let today_end = Utc.from_utc_datetime(&today.and_time(end_of_day)).timestamp_millis();
|
||||
let yesterday_start = Utc.from_utc_datetime(&yesterday.and_time(midnight)).timestamp_millis();
|
||||
let yesterday_end = Utc.from_utc_datetime(&yesterday.and_time(end_of_day)).timestamp_millis();
|
||||
let two_days_ago_start = Utc.from_utc_datetime(&two_days_ago.and_time(midnight)).timestamp_millis();
|
||||
let two_days_ago_end = Utc.from_utc_datetime(&two_days_ago.and_time(end_of_day)).timestamp_millis();
|
||||
|
||||
// This week (Monday start)
|
||||
let days_since_monday = today.weekday().num_days_from_monday() as i64;
|
||||
let monday = today - Duration::days(days_since_monday);
|
||||
let this_week_start = Utc.from_utc_datetime(&monday.and_time(midnight)).timestamp_millis();
|
||||
|
||||
// Last week
|
||||
let last_monday = monday - Duration::days(7);
|
||||
let last_sunday = monday - Duration::days(1);
|
||||
let last_week_start = Utc.from_utc_datetime(&last_monday.and_time(midnight)).timestamp_millis();
|
||||
let last_week_end = Utc.from_utc_datetime(&last_sunday.and_time(end_of_day)).timestamp_millis();
|
||||
|
||||
// This month
|
||||
let first_of_month = today.with_day(1).unwrap();
|
||||
let this_month_start = Utc.from_utc_datetime(&first_of_month.and_time(midnight)).timestamp_millis();
|
||||
|
||||
// Last month
|
||||
let last_month_last_day = first_of_month - Duration::days(1);
|
||||
let last_month_first = last_month_last_day.with_day(1).unwrap();
|
||||
let last_month_start = Utc.from_utc_datetime(&last_month_first.and_time(midnight)).timestamp_millis();
|
||||
let last_month_end = Utc.from_utc_datetime(&last_month_last_day.and_time(end_of_day)).timestamp_millis();
|
||||
|
||||
let now_ms = now.timestamp_millis();
|
||||
|
||||
Self {
|
||||
now: now_ms,
|
||||
date: now.format("%Y-%m-%d").to_string(),
|
||||
time: now.format("%H:%M").to_string(),
|
||||
datetime: now.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
day_of_week: weekday_name(today.weekday()).to_string(),
|
||||
day_of_week_short: weekday_short(today.weekday()).to_string(),
|
||||
|
||||
today_start,
|
||||
today_end,
|
||||
|
||||
yesterday_start,
|
||||
yesterday_end,
|
||||
yesterday_name: weekday_name(yesterday.weekday()).to_string(),
|
||||
|
||||
two_days_ago_start,
|
||||
two_days_ago_end,
|
||||
two_days_ago_name: weekday_name(two_days_ago.weekday()).to_string(),
|
||||
|
||||
this_week_start,
|
||||
last_week_start,
|
||||
last_week_end,
|
||||
this_month_start,
|
||||
last_month_start,
|
||||
last_month_end,
|
||||
|
||||
ago_1h: now_ms - 3_600_000,
|
||||
ago_6h: now_ms - 21_600_000,
|
||||
ago_12h: now_ms - 43_200_000,
|
||||
ago_24h: now_ms - 86_400_000,
|
||||
ago_48h: now_ms - 172_800_000,
|
||||
ago_7d: now_ms - 604_800_000,
|
||||
ago_14d: now_ms - 1_209_600_000,
|
||||
ago_30d: now_ms - 2_592_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// Full time block for system prompts (~25 values).
|
||||
/// Used in the legacy path template and the conversations API per-message header.
|
||||
pub fn system_block(&self) -> String {
|
||||
format!(
|
||||
"\
|
||||
## time\n\
|
||||
\n\
|
||||
current: {} {} UTC ({}, {})\n\
|
||||
epoch_ms: {}\n\
|
||||
\n\
|
||||
day boundaries (midnight UTC, use these for search_archive after/before):\n\
|
||||
today: {} to {}\n\
|
||||
yesterday ({}): {} to {}\n\
|
||||
{} ago: {} to {}\n\
|
||||
this week (Mon): {} to now\n\
|
||||
last week: {} to {}\n\
|
||||
this month: {} to now\n\
|
||||
last month: {} to {}\n\
|
||||
\n\
|
||||
rolling offsets:\n\
|
||||
1h_ago={} 6h_ago={} 12h_ago={} 24h_ago={}\n\
|
||||
48h_ago={} 7d_ago={} 14d_ago={} 30d_ago={}",
|
||||
self.date, self.time, self.day_of_week, self.datetime,
|
||||
self.now,
|
||||
self.today_start, self.today_end,
|
||||
self.yesterday_name, self.yesterday_start, self.yesterday_end,
|
||||
self.two_days_ago_name, self.two_days_ago_start, self.two_days_ago_end,
|
||||
self.this_week_start,
|
||||
self.last_week_start, self.last_week_end,
|
||||
self.this_month_start,
|
||||
self.last_month_start, self.last_month_end,
|
||||
self.ago_1h, self.ago_6h, self.ago_12h, self.ago_24h,
|
||||
self.ago_48h, self.ago_7d, self.ago_14d, self.ago_30d,
|
||||
)
|
||||
}
|
||||
|
||||
/// Compact time line for per-message injection (~5 key values).
|
||||
pub fn message_line(&self) -> String {
|
||||
format!(
|
||||
"[time: {} {} UTC | today={}-{} | yesterday={}-{} | now={}]",
|
||||
self.date, self.time,
|
||||
self.today_start, self.today_end,
|
||||
self.yesterday_start, self.yesterday_end,
|
||||
self.now,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_time_context_now() {
|
||||
let tc = TimeContext::now();
|
||||
assert!(tc.now > 0);
|
||||
assert!(tc.today_start <= tc.now);
|
||||
assert!(tc.today_end >= tc.now);
|
||||
assert!(tc.yesterday_start < tc.today_start);
|
||||
assert!(tc.yesterday_end < tc.today_start);
|
||||
assert!(tc.this_week_start <= tc.today_start);
|
||||
assert!(tc.last_week_start < tc.this_week_start);
|
||||
assert!(tc.this_month_start <= tc.today_start);
|
||||
assert!(tc.last_month_start < tc.this_month_start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_day_boundaries_are_midnight() {
|
||||
let tc = TimeContext::now();
|
||||
// today_start should be divisible by 86400000 (midnight)
|
||||
// (not exactly, due to timezone, but should end in 00:00:00.000)
|
||||
assert!(tc.today_start % 1000 == 0); // whole second
|
||||
assert!(tc.yesterday_start % 1000 == 0);
|
||||
// end of day should be .999
|
||||
assert!(tc.today_end % 1000 == 999);
|
||||
assert!(tc.yesterday_end % 1000 == 999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yesterday_is_24h_before_today() {
|
||||
let tc = TimeContext::now();
|
||||
assert_eq!(tc.today_start - tc.yesterday_start, 86_400_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rolling_offsets() {
|
||||
let tc = TimeContext::now();
|
||||
assert_eq!(tc.now - tc.ago_1h, 3_600_000);
|
||||
assert_eq!(tc.now - tc.ago_24h, 86_400_000);
|
||||
assert_eq!(tc.now - tc.ago_7d, 604_800_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_day_names() {
|
||||
let tc = TimeContext::now();
|
||||
let valid_days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||
assert!(valid_days.contains(&tc.day_of_week.as_str()));
|
||||
assert!(valid_days.contains(&tc.yesterday_name.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_block_contains_key_values() {
|
||||
let tc = TimeContext::now();
|
||||
let block = tc.system_block();
|
||||
assert!(block.contains("epoch_ms:"));
|
||||
assert!(block.contains("today:"));
|
||||
assert!(block.contains("yesterday"));
|
||||
assert!(block.contains("this week"));
|
||||
assert!(block.contains("last week"));
|
||||
assert!(block.contains("this month"));
|
||||
assert!(block.contains("1h_ago="));
|
||||
assert!(block.contains("30d_ago="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_line_compact() {
|
||||
let tc = TimeContext::now();
|
||||
let line = tc.message_line();
|
||||
assert!(line.starts_with("[time:"));
|
||||
assert!(line.contains("today="));
|
||||
assert!(line.contains("yesterday="));
|
||||
assert!(line.contains("now="));
|
||||
assert!(line.ends_with(']'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user