feat: an hour-precision clock that says so, and a cron tool that names the real timezone
Nightly Build / build (push) Successful in 7m30s
Nightly Build / build (push) Successful in 7m30s
The datetime block claimed second precision it never had. It is built once per request and a turn can run for minutes, so `17:54:31` is a lie by the time the model reads it — and the model, having no way to know, wrote cron expressions from it. Rounding was already there but configurable (`round_minutes`, shipped at 60) and justified by the prompt cache. That justification was false: the block is the LAST system message, after the whole conversation, so the cached prefix is identical from turn to turn whatever the timestamp says. Rounding buys nothing for caching today. So the knob goes and the granularity becomes part of the contract: always truncated to the hour, stated in words, with a pointer to `date` for the cases that need the minute. `DatetimeConfig` keeps only `enabled`. - truncation happens in the DISPLAYED zone, not on the UTC epoch: +05:30 zones would otherwise render 20:30 — an hour off and not on an hour boundary, which reads as precise again. - the weekday is spelled out. "next Tuesday" is a far more common ask than the minute, and weekday-from-date is exactly the arithmetic models get wrong. Also fixes a real bug found on the way: `execute_task` told the model, twice, that cron expressions are evaluated in Europe/London — hardcoded, while TaskManager uses the configured timezone. On a non-UK box every scheduled job was written against the wrong clock. The description now names the zone the scheduler actually uses (`TaskManager::timezone_name`), and the assistant's AGENT.md stops repeating the literal. CLAUDE.md: record that the instance is in production. The greenfield licence has expired — schema changes need a versioning mechanism, and per-user SQLCipher files mean it cannot be a boot-time sweep.
This commit is contained in:
@@ -39,13 +39,15 @@ pub struct LlmConfig {
|
||||
}
|
||||
|
||||
/// Controls date/time injection in the dynamic tail of each LLM request.
|
||||
///
|
||||
/// The injected time is **always** truncated to the hour, and the block says so:
|
||||
/// see [`crate::loop_adapters::system`]. There is deliberately no rounding knob —
|
||||
/// the granularity is part of what the model is told, not an instance setting.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DatetimeConfig {
|
||||
/// Inject the current date/time into the LLM context. Default: true.
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// When set, round the injected time down to the nearest N-minute boundary.
|
||||
pub round_minutes: Option<u32>,
|
||||
/// IANA timezone name to use when formatting the injected timestamp.
|
||||
/// Populated at startup from the global `timezone` config field.
|
||||
#[serde(skip)]
|
||||
@@ -54,7 +56,7 @@ pub struct DatetimeConfig {
|
||||
|
||||
impl Default for DatetimeConfig {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true, round_minutes: None, timezone: None }
|
||||
Self { enabled: true, timezone: None }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,17 @@ impl TaskManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// The zone cron expressions are evaluated in — the configured `timezone`,
|
||||
/// else the system's. Exists so the `execute_task` tool description can name
|
||||
/// it instead of hardcoding one: a model told the wrong zone writes a
|
||||
/// correct-looking expression that fires at the wrong hour.
|
||||
pub fn timezone_name(&self) -> String {
|
||||
self.tz
|
||||
.map(|tz| tz.name().to_string())
|
||||
.or_else(|| iana_time_zone::get_timezone().ok())
|
||||
.unwrap_or_else(|| "the server's local timezone".to_string())
|
||||
}
|
||||
|
||||
/// Called once after ChatSessionManager is built, breaking the circular dep.
|
||||
pub fn set_session(&self, session: Arc<ChatSessionManager>) {
|
||||
let _ = self.session.set(session);
|
||||
|
||||
@@ -143,6 +143,18 @@ fn os_description() -> &'static str {
|
||||
OS.get_or_init(|| os_info::get().to_string())
|
||||
}
|
||||
|
||||
/// Formats an instant to hour precision: `Sunday 2026-08-02 17:00 +02:00`.
|
||||
///
|
||||
/// Minutes and seconds are dropped by the format string itself, so the
|
||||
/// truncation always happens in the zone being displayed. The weekday is part
|
||||
/// of the format on purpose — see [`AgentSystemContext::datetime_block`].
|
||||
fn render_hour<Tz: chrono::TimeZone>(dt: chrono::DateTime<Tz>) -> String
|
||||
where
|
||||
Tz::Offset: std::fmt::Display,
|
||||
{
|
||||
dt.format("%A %Y-%m-%d %H:00 %:z").to_string()
|
||||
}
|
||||
|
||||
/// System IANA timezone name, computed once.
|
||||
fn system_timezone() -> Option<&'static str> {
|
||||
static TZ: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
@@ -169,23 +181,25 @@ impl AgentSystemContext {
|
||||
|
||||
/// The current date/time + OS + cwd block (`None` when disabled).
|
||||
///
|
||||
/// Rounding exists for the prompt cache: a timestamp that changes every
|
||||
/// second would invalidate any cached suffix, so the instance can quantize
|
||||
/// it (this block is in the dynamic tail, after the cached prefix, but the
|
||||
/// rounding still helps providers that cache further).
|
||||
/// The time is **truncated to the hour**, always, and the block says so in
|
||||
/// words. Two reasons, neither of which is the prompt cache — this block is
|
||||
/// the last system message, after the whole conversation, so the cached
|
||||
/// prefix is identical from one turn to the next whatever the timestamp says:
|
||||
///
|
||||
/// 1. **Honesty.** A second-precision timestamp reads as exact to the model
|
||||
/// long after it stopped being true (it is built once per request, and a
|
||||
/// turn can run for minutes). An hour-precision one that announces itself
|
||||
/// as such lets the model know what it does *not* know — which matters
|
||||
/// when it is about to write a cron expression from "in ten minutes".
|
||||
/// 2. It keeps the block cache-safe if it ever moves into the prefix.
|
||||
///
|
||||
/// The weekday is spelled out: "next Tuesday" is a far more common ask than
|
||||
/// the minute, and deriving it from a date is exactly the arithmetic models
|
||||
/// get wrong.
|
||||
fn datetime_block(&self) -> Option<String> {
|
||||
if !self.datetime.enabled {
|
||||
return None;
|
||||
}
|
||||
let secs = chrono::Utc::now().timestamp();
|
||||
let secs = match self.datetime.round_minutes {
|
||||
Some(m) if m > 0 => {
|
||||
let bucket = (m as i64) * 60;
|
||||
(secs / bucket) * bucket
|
||||
}
|
||||
_ => secs,
|
||||
};
|
||||
|
||||
let tz = self
|
||||
.datetime
|
||||
.timezone
|
||||
@@ -193,30 +207,15 @@ impl AgentSystemContext {
|
||||
.and_then(|s| s.parse::<chrono_tz::Tz>().ok())
|
||||
.or_else(|| system_timezone().and_then(|s| s.parse::<chrono_tz::Tz>().ok()));
|
||||
|
||||
// Truncate in the *displayed* zone, not on the UTC epoch: a zone at a
|
||||
// 30- or 45-minute offset (Asia/Kolkata, Asia/Kathmandu) would otherwise
|
||||
// render as `17:30`, which is not an hour boundary and reads as precise.
|
||||
let (formatted, tz_name) = match tz {
|
||||
Some(tz) => {
|
||||
use chrono::TimeZone as _;
|
||||
let f = tz
|
||||
.timestamp_opt(secs, 0)
|
||||
.single()
|
||||
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
|
||||
.unwrap_or_else(|| {
|
||||
chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string()
|
||||
});
|
||||
(f, Some(tz.name().to_string()))
|
||||
}
|
||||
None => {
|
||||
let f = chrono::DateTime::from_timestamp(secs, 0)
|
||||
.map(|utc| {
|
||||
utc.with_timezone(&chrono::Local)
|
||||
.format("%Y-%m-%dT%H:%M:%S%:z")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string()
|
||||
});
|
||||
(f, None)
|
||||
}
|
||||
Some(tz) => (
|
||||
render_hour(chrono::Utc::now().with_timezone(&tz)),
|
||||
Some(tz.name().to_string()),
|
||||
),
|
||||
None => (render_hour(chrono::Local::now()), None),
|
||||
};
|
||||
let date_line = match tz_name {
|
||||
Some(name) => format!("Current date and time: {formatted} ({name})"),
|
||||
@@ -227,7 +226,11 @@ impl AgentSystemContext {
|
||||
let cwd = "~";
|
||||
|
||||
Some(format!(
|
||||
"{date_line}\nOperating system: {}\nWorking directory: {cwd}\n\
|
||||
"{date_line}\n\
|
||||
The time above is truncated to the hour — you do not know the current minute. \
|
||||
If you need it exactly (for instance to schedule something within the hour), \
|
||||
run `date` with execute_cmd first.\n\
|
||||
Operating system: {}\nWorking directory: {cwd}\n\
|
||||
Filesystem tools and execute_cmd resolve relative paths against your home directory.",
|
||||
os_description()
|
||||
))
|
||||
@@ -505,6 +508,23 @@ mod tests {
|
||||
assert_eq!(out, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hour_render_names_the_day_and_drops_the_minutes() {
|
||||
let instant = chrono::DateTime::parse_from_rfc3339("2026-08-02T15:54:31Z").unwrap();
|
||||
let rome = instant.with_timezone(&chrono_tz::Europe::Rome);
|
||||
assert_eq!(render_hour(rome), "Sunday 2026-08-02 17:00 +02:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hour_render_truncates_in_the_displayed_zone() {
|
||||
// Asia/Kolkata is +05:30: truncating the UTC epoch instead would render
|
||||
// 20:30 — an hour off AND not on an hour boundary, so it would read as
|
||||
// a precise time. Truncation must happen after the zone conversion.
|
||||
let instant = chrono::DateTime::parse_from_rfc3339("2026-08-02T15:54:31Z").unwrap();
|
||||
let kolkata = instant.with_timezone(&chrono_tz::Asia::Kolkata);
|
||||
assert_eq!(render_hour(kolkata), "Sunday 2026-08-02 21:00 +05:30");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_folders_table_renders_access_and_description() {
|
||||
use crate::db::shared_folders::SharedFolderAccess;
|
||||
|
||||
@@ -181,7 +181,7 @@ pub fn mcp() -> Arc<dyn McpProvider> {
|
||||
/// The datetime block is disabled: it embeds `now()`, which no snapshot can
|
||||
/// pin down.
|
||||
pub fn datetime() -> DatetimeConfig {
|
||||
DatetimeConfig { enabled: false, round_minutes: None, timezone: None }
|
||||
DatetimeConfig { enabled: false, timezone: None }
|
||||
}
|
||||
|
||||
/// The base tool definitions the projection is handed.
|
||||
|
||||
@@ -18,18 +18,23 @@ use crate::tools::{SimpleExecution, Tool, ToolContext, ToolDescriptionLength, To
|
||||
pub struct ExecuteTask(pub Arc<TaskManager>);
|
||||
|
||||
impl ExecuteTask {
|
||||
fn description_text() -> &'static str {
|
||||
"Create and run a task. Three modes:\n\
|
||||
• mode=cron — scheduled by a 7-field cron expression (sec min hour dom month dow year, \
|
||||
Europe/London timezone). Returns task_id and next scheduled run. Recurring unless the \
|
||||
expression can only fire once.\n\
|
||||
• mode=sync — run immediately, block until the agent finishes, and return the result inline. \
|
||||
Best for short tasks (a few seconds to a few minutes).\n\
|
||||
• mode=async — start the task in the background and return the task_id immediately. \
|
||||
When the task completes its result will be delivered back to this chat automatically."
|
||||
/// `tz` is the zone the scheduler actually evaluates expressions in
|
||||
/// (`TaskManager::timezone_name`), never a literal: the description is what
|
||||
/// the model reasons from, so a wrong zone here is an hours-off cron job.
|
||||
fn description_text(tz: &str) -> String {
|
||||
format!(
|
||||
"Create and run a task. Three modes:\n\
|
||||
• mode=cron — scheduled by a 7-field cron expression (sec min hour dom month dow year, \
|
||||
{tz} timezone). Returns task_id and next scheduled run. Recurring unless the \
|
||||
expression can only fire once.\n\
|
||||
• mode=sync — run immediately, block until the agent finishes, and return the result inline. \
|
||||
Best for short tasks (a few seconds to a few minutes).\n\
|
||||
• mode=async — start the task in the background and return the task_id immediately. \
|
||||
When the task completes its result will be delivered back to this chat automatically."
|
||||
)
|
||||
}
|
||||
|
||||
fn schema() -> Value {
|
||||
fn schema(tz: &str) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["mode", "title", "prompt", "agent_id"],
|
||||
@@ -41,7 +46,7 @@ impl ExecuteTask {
|
||||
},
|
||||
"title": { "type": "string", "description": "Short name for this task" },
|
||||
"description": { "type": "string", "description": "What this task does" },
|
||||
"cron": { "type": "string", "description": "7-field cron expression — required when mode=cron (times in Europe/London). E.g. '0 0 9 * * * *' = every day at 09:00" },
|
||||
"cron": { "type": "string", "description": format!("7-field cron expression — required when mode=cron (times in {tz}). E.g. '0 0 9 * * * *' = every day at 09:00") },
|
||||
"prompt": { "type": "string", "description": "Prompt sent to the agent at each run" },
|
||||
"agent_id": { "type": "string", "description": "Task agent to run (required; e.g. software-engineer, researcher, generalist). Must be a `task` agent — chat/system agents are rejected." }
|
||||
}
|
||||
@@ -107,6 +112,7 @@ pub fn build_execute_task_interface_tool(
|
||||
) -> crate::session::handler::InterfaceTool {
|
||||
use crate::session::handler::{InterfaceTool, ToolFuture};
|
||||
|
||||
let tz = task_mgr.timezone_name();
|
||||
let tool = Arc::new(ExecuteTask(task_mgr));
|
||||
|
||||
InterfaceTool {
|
||||
@@ -114,8 +120,8 @@ pub fn build_execute_task_interface_tool(
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "execute_task",
|
||||
"description": ExecuteTask::description_text(),
|
||||
"parameters": ExecuteTask::schema(),
|
||||
"description": ExecuteTask::description_text(&tz),
|
||||
"parameters": ExecuteTask::schema(&tz),
|
||||
}
|
||||
}),
|
||||
handler: Arc::new(move |args: Value| -> ToolFuture {
|
||||
|
||||
Reference in New Issue
Block a user