fix(agent): ground time-relative statements in the live clock (#3602) (#3752)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-06-18 12:54:03 +05:30
committed by GitHub
co-authored by Claude
parent 3cf3c5443b
commit 975a478eda
12 changed files with 238 additions and 96 deletions
@@ -619,12 +619,41 @@ fn tool_timeout_parse_default_and_boundaries() {
}
// ─────────────────────────────────────────────────────────────────────────────
// Item 8 — DateTimeSection: ISO 8601 timestamp + IANA timezone token.
// Extended coverage beyond the existing mod_tests.rs test.
// Item 8 — Current-time grounding (#3602). The volatile timestamp now rides the
// per-turn user message via `current_datetime_line` (so a long-lived
// session's frozen prompt prefix can't go stale); `DateTimeSection`
// carries only the static grounding *rule*. Pin both halves.
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn datetime_section_output_matches_iso8601_date_and_utc_offset_pattern() {
fn current_datetime_line_matches_iso8601_date_and_utc_offset_pattern() {
// The per-turn stamp is the one carrying the concrete clock — assert its
// ISO-8601 date, UTC offset, and IANA zone (or `UTC` fallback).
let payload = crate::openhuman::agent::prompts::current_datetime_line();
// Parse the concrete `YYYY-MM-DD HH:MM:SS` prefix rather than counting
// loose digits, so a malformed layout can't slip through.
let rest = payload
.strip_prefix("Current Date & Time: ")
.expect("stamp must start with the canonical prefix");
let dt = rest
.get(0..19)
.expect("stamp must include YYYY-MM-DD HH:MM:SS");
chrono::NaiveDateTime::parse_from_str(dt, "%Y-%m-%d %H:%M:%S")
.expect("timestamp must match YYYY-MM-DD HH:MM:SS");
assert!(
payload.contains("UTC"),
"stamp must contain UTC offset marker: {payload}"
);
let has_iana = payload.contains('/') || payload.contains(" UTC ");
assert!(
has_iana,
"stamp must contain an IANA zone (slashed) or UTC fallback: {payload}"
);
}
#[test]
fn datetime_section_is_static_grounding_rule_not_a_volatile_timestamp() {
use crate::openhuman::agent::prompts::{DateTimeSection, PromptContext, PromptSection};
use std::collections::HashSet;
use std::path::Path;
@@ -660,23 +689,21 @@ fn datetime_section_output_matches_iso8601_date_and_utc_offset_pattern() {
.strip_prefix("## Current Date & Time\n\n")
.expect("DateTimeSection must start with the heading");
// Must contain a date token matching YYYY-MM-DD.
let has_date = payload.chars().filter(|c| c.is_ascii_digit()).count() >= 8;
// The section is a static rule: it must carry the greeting-grounding
// guidance and point at the per-turn line, but NOT bake in a date — a
// concrete YYYY-MM-DD here would re-freeze the volatile clock into the
// cached prefix (the #3602 regression this guards against).
assert!(
has_date,
"payload must contain enough digits for a date: {payload}"
payload.contains("match the actual local hour") && payload.contains("Current Date & Time:"),
"section must carry the grounding rule pointing at the per-turn stamp: {payload}"
);
// Must include a UTC offset in the form UTC+HH:MM or UTC-HH:MM or UTC+00:00.
assert!(
payload.contains("UTC"),
"payload must contain UTC offset marker: {payload}"
);
// The IANA timezone string is either a slashed zone or the literal "UTC" fallback.
let has_iana = payload.contains('/') || payload.contains(" UTC ");
assert!(
has_iana,
"payload must contain an IANA zone (slashed) or UTC fallback: {payload}"
// Byte-stability is the real no-volatile-timestamp invariant (a static
// literal like "11 PM" in the rule is fine; a baked `Local::now()` is
// not): two renders a moment apart must be identical, or the cached
// prefix would churn every second.
let again = DateTimeSection.build(&ctx).unwrap();
assert_eq!(
rendered, again,
"datetime section must be byte-stable (no volatile timestamp baked in)"
);
}
@@ -418,6 +418,20 @@ impl Agent {
.inject_triggered_memory_agent_context(user_message, enriched, &parent_context)
.await;
// #3602: stamp every turn's user message with the live local time
// so time-relative phrasing (greetings, "today"/"tonight") is
// grounded on the real clock. Rides the user message — not the
// frozen system-prompt prefix (see core.rs KV-cache note above) — so
// it stays fresh across a long-lived session without busting the
// cached prefix. This path runs for every `turn()` caller, including
// one-shot `run_single` flows (cron/morning-briefing/meet), so those
// get a fresh stamp too. The grounding *rule* lives in the system
// prompt's `## Current Date & Time` section.
let enriched = format!(
"{}\n\n{enriched}",
crate::openhuman::agent::prompts::current_datetime_line()
);
self.history
.push(ConversationMessage::Chat(ChatMessage::user(enriched)));
@@ -1101,7 +1101,19 @@ async fn turn_uses_cached_transcript_prefix_on_first_iteration() {
assert_eq!(requests[0][0].content, "cached-system");
assert_eq!(requests[0][1].content, "cached-assistant");
assert_eq!(requests[0][2].role, "user");
assert_eq!(requests[0][2].content, "fresh");
// #3602: every turn's user message is prefixed with the live
// `Current Date & Time:` stamp, then the raw prompt. Assert the stamp
// leads and the original prompt is preserved at the tail.
assert!(
requests[0][2].content.starts_with("Current Date & Time:"),
"user message must lead with the per-turn time stamp: {}",
requests[0][2].content
);
assert!(
requests[0][2].content.ends_with("fresh"),
"user message must preserve the original prompt: {}",
requests[0][2].content
);
}
#[tokio::test]
@@ -624,12 +624,10 @@ async fn run_typed_mode(
let system_prompt = append_subagent_role_contract(system_prompt, &definition.id);
// ── Build the user message (with optional context prefix) ──────────
let now = chrono::Local::now();
let now_str = format!(
"Current Date & Time: {} ({})",
now.format("%Y-%m-%d %H:%M:%S"),
now.format("%Z")
);
// Shared one-line stamp (#3602) so sub-agents report time in the same
// format as the main agent. Lives on the user message because sub-agent
// system prompts are byte-stable for prefix caching.
let now_str = crate::openhuman::agent::prompts::current_datetime_line();
let mut context_parts: Vec<&str> = Vec::new();
if !definition.omit_memory_context {
+7 -6
View File
@@ -11,12 +11,13 @@ pub use sections::*;
pub mod render_helpers;
pub use render_helpers::{
default_workspace_file_content, inject_inline_content, inject_snapshot_content,
inject_workspace_file, inject_workspace_file_capped, memory_date_label,
render_ambient_environment, render_datetime, render_grounding, render_identity, render_runtime,
render_safety, render_subagent_system_prompt, render_subagent_system_prompt_with_format,
render_tools, render_user_files, render_user_identity, render_user_memory,
render_user_reflections, render_workspace, sync_workspace_file,
current_datetime_line, default_workspace_file_content, inject_inline_content,
inject_snapshot_content, inject_workspace_file, inject_workspace_file_capped,
memory_date_label, render_ambient_environment, render_datetime, render_grounding,
render_identity, render_runtime, render_safety, render_subagent_system_prompt,
render_subagent_system_prompt_with_format, render_tools, render_user_files,
render_user_identity, render_user_memory, render_user_reflections, render_workspace,
sync_workspace_file,
};
#[cfg(test)]
+44 -13
View File
@@ -214,7 +214,13 @@ fn identity_section_creates_missing_workspace_files() {
}
#[test]
fn datetime_section_includes_timestamp_and_timezone() {
fn datetime_section_is_static_grounding_rule_without_volatile_timestamp() {
// #3602: the concrete "now" moved to the per-turn user message
// (`current_datetime_line`) so a long-lived session's frozen
// system-prompt prefix never goes stale. The section must therefore
// carry the greeting/clock grounding *rule* but NOT a volatile
// timestamp — otherwise the prefix is no longer byte-stable and a
// stale clock contradicts the fresh per-turn one.
let tools: Vec<Box<dyn Tool>> = vec![];
let prompt_tools = PromptTool::from_tools(&tools);
let ctx = PromptContext {
@@ -240,20 +246,45 @@ fn datetime_section_includes_timestamp_and_timezone() {
let rendered = DateTimeSection.build(&ctx).unwrap();
assert!(rendered.starts_with("## Current Date & Time\n\n"));
let payload = rendered.trim_start_matches("## Current Date & Time\n\n");
assert!(payload.chars().any(|c| c.is_ascii_digit()));
assert!(payload.contains(" ("));
assert!(payload.ends_with(')'));
// IANA zone is included so agents can reason about the host's
// timezone without parsing a locale-dependent abbreviation. Either
// a slashed zone (`America/Los_Angeles`) or the `UTC` fallback for
// hosts where `iana-time-zone` can't resolve one.
// Greeting/clock grounding rule must be present, ungated (no tools here).
assert!(
payload.contains('/') || payload.contains(" UTC "),
"rendered payload missing IANA timezone: {payload}"
rendered.contains("good morning") && rendered.contains("match the actual local hour"),
"datetime section must carry the greeting-grounding rule; got:\n{rendered}"
);
assert!(
rendered.contains("Current Date & Time:"),
"rule must point at the per-turn `Current Date & Time:` line; got:\n{rendered}"
);
// Byte-stability guard: two renders a moment apart must be identical —
// i.e. no embedded volatile clock. A frozen timestamp would make these
// diverge (and bust the KV-cache prefix).
let again = DateTimeSection.build(&ctx).unwrap();
assert_eq!(
rendered, again,
"datetime section must be byte-stable (no volatile timestamp baked in)"
);
}
#[test]
fn current_datetime_line_is_fresh_local_stamp() {
// The per-turn stamp carries a parseable local date, IANA zone (or the
// `UTC` fallback), a UTC offset, and the weekday — everything the model
// needs to localize a greeting without a tool call (#3602).
let line = super::current_datetime_line();
let rest = line
.strip_prefix("Current Date & Time: ")
.unwrap_or_else(|| panic!("stamp must start with canonical prefix: {line}"));
// The first 19 chars must be a canonical `YYYY-MM-DD HH:MM:SS`.
let dt = rest
.get(0..19)
.unwrap_or_else(|| panic!("stamp too short for YYYY-MM-DD HH:MM:SS: {line}"));
chrono::NaiveDateTime::parse_from_str(dt, "%Y-%m-%d %H:%M:%S")
.unwrap_or_else(|e| panic!("timestamp must match YYYY-MM-DD HH:MM:SS ({e}): {line}"));
assert!(line.contains("UTC"), "missing UTC offset: {line}");
assert!(
line.contains('/') || line.contains(" UTC "),
"missing IANA zone or UTC fallback: {line}"
);
assert!(payload.contains("UTC"), "missing UTC offset: {payload}");
}
#[test]
+44 -4
View File
@@ -82,14 +82,54 @@ pub fn render_runtime(ctx: &PromptContext<'_>) -> Result<String> {
RuntimeSection.build(ctx)
}
/// Render the `## Current Date & Time` block. Intentionally **not**
/// included in byte-stable sub-agent prompts (`for_subagent`) because
/// injecting `Local::now()` defeats prefix caching. Exposed so full-
/// assembly main-agent builders can opt in.
/// Render the `## Current Date & Time` block: the static time-discipline
/// *rules* (greeting/clock grounding + the gated `resolve_time` rule). The
/// concrete "now" is **not** here — it rides the user message per turn via
/// [`current_datetime_line`] so it stays fresh and keeps this section
/// byte-stable for prefix caching (#3602).
pub fn render_datetime(ctx: &PromptContext<'_>) -> Result<String> {
DateTimeSection.build(ctx)
}
/// Canonical one-line "now" stamp, injected per turn alongside the user
/// message by both the main session loop (`session::turn`) and the
/// sub-agent runner so every flow reports the current time identically
/// (#3602). Local time + IANA zone + `%Z`/offset + weekday, so the model
/// can localize greetings and date math without a tool call.
///
/// Deliberately lives on the *user message*, never the cached
/// system-prompt prefix: `Local::now()` is volatile, so freezing it into
/// the prefix both busts the KV cache and goes stale across a long-lived
/// session. The static grounding *rule* that tells the model to read this
/// line lives in [`DateTimeSection`] / [`render_datetime`].
pub fn current_datetime_line() -> String {
// When the host resolves an IANA zone, stamp local time + that zone. When
// it can't (CI, stripped containers), fall back to true UTC — formatting
// `Utc::now()` so the time, offset, and zone label all agree rather than
// pairing a "UTC" label with a local clock/offset.
match iana_time_zone::get_timezone() {
Ok(iana) => {
let now = chrono::Local::now();
format!(
"Current Date & Time: {} {} ({}, UTC{}), {}",
now.format("%Y-%m-%d %H:%M:%S"),
iana,
now.format("%Z"),
now.format("%:z"),
now.format("%A"),
)
}
Err(_) => {
let now = chrono::Utc::now();
format!(
"Current Date & Time: {} UTC (UTC, UTC+00:00), {}",
now.format("%Y-%m-%d %H:%M:%S"),
now.format("%A"),
)
}
}
}
/// Render the `## User` identity block. Empty when
/// [`PromptContext::user_identity`] is unset or has no populated
/// fields. See issue #926.
+23 -17
View File
@@ -594,28 +594,34 @@ impl PromptSection for DateTimeSection {
}
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
// IANA zone first because it's the unambiguous machine-readable
// form (`America/Los_Angeles`) — agents that need to reason about
// timezone rules should grep this, not the locale-dependent
// `%Z` abbreviation. Falls back to "UTC" when the host can't
// resolve a zone (CI, stripped containers).
let iana = iana_time_zone::get_timezone().unwrap_or_else(|_| "UTC".to_string());
let now = chrono::Local::now();
let mut out = format!(
"## Current Date & Time\n\n{} {} ({}, UTC{})",
now.format("%Y-%m-%d %H:%M:%S"),
iana,
now.format("%Z"),
now.format("%:z"),
// No concrete timestamp here. The live "now" is injected per turn
// on the user message via `render_helpers::current_datetime_line`
// (the main session loop and the sub-agent runner both do this), so
// it stays fresh across a long-lived session instead of freezing
// into the cached system-prompt prefix and going stale (#3602).
// This section carries only the *static* discipline rules for using
// that stamp — keeping the prefix byte-stable for KV-cache reuse.
//
// Greeting/clock grounding is ungated: every agent that emits a
// time-relative word (a greeting, "today"/"tonight") needs it,
// whether or not it owns `resolve_time`. Without this rule the model
// treats the time line as passive reference and defaults to a
// learned "good morning" regardless of the actual hour (#3602).
let mut out = String::from(
"## Current Date & Time\n\n> The current local date and time is provided on a \
`Current Date & Time:` line with the latest message (local time, IANA timezone, \
UTC offset, weekday). Before any time-relative wording in your reply — greetings \
like \"good morning\"/\"good evening\", or \"today\"/\"tonight\"/\"tomorrow\" — read \
that line and match the actual local hour. Never assume it is morning. The time is \
already in context; no tool call is needed to greet or to reason about the current day.",
);
// Time-discipline rule, gated on the agent actually having the
// Tool-argument discipline, gated on the agent actually having the
// `resolve_time` tool. LLMs are unreliable at epoch arithmetic — a
// real incident had an agent compute "24h ago" ~10 months off, then
// fetch Slack history ascending from that wrong floor and never reach
// the latest messages. Telling agents to lean on the tool (rather
// than hand-computing) is the fix; rendered right under the volatile
// time block so the two are read together. Auto-scopes: agents
// without the tool never see the rule.
// than hand-computing) is the fix. Auto-scopes: agents without the
// tool never see the rule.
if ctx.tools.iter().any(|t| t.name == "resolve_time") {
out.push_str(
"\n\n> For any date/time you pass as a tool argument \
@@ -16,13 +16,13 @@ Prepare a morning briefing that helps the user start their day with clarity. Pul
## How to gather data
1. **Recent memory (last 24h).** Call the `memory_tree` tool with `mode: "cover_window"`, `since_ms = <now 24h>` and `until_ms = <now>` (epoch-milliseconds — use the current date/time in the system context below to compute these). It returns the **minimum set of nodes** covering the window: condensed summaries where a whole stretch is in-window, and raw recent messages otherwise — grouped by source, oldest→newest. This is your authoritative recent-memory context; the all-time memory blob is intentionally NOT injected, so do not rely on it. Pass a `source_id`/`source_kind` filter if you only need one source.
1. **Recent memory (last 24h).** Call the `memory_tree` tool with `mode: "cover_window"`, `since_ms = <now 24h>` and `until_ms = <now>` (epoch-milliseconds — use the current date/time from the `Current Date & Time:` line provided with the message to compute these). It returns the **minimum set of nodes** covering the window: condensed summaries where a whole stretch is in-window, and raw recent messages otherwise — grouped by source, oldest→newest. This is your authoritative recent-memory context; the all-time memory blob is intentionally NOT injected, so do not rely on it. Pass a `source_id`/`source_kind` filter if you only need one source.
2. **Live data.** Use `composio_list_connections` to see connected integrations; for each relevant one (calendar, email, task manager), `composio_list_tools` then `composio_execute` to pull today's data.
3. Reconcile the two: the 24h memory tells you what *happened*; the live calls tell you what's *scheduled / unread right now*. Don't double-report the same item.
## Tone & format
- **Warm but efficient.** Open with a brief, human greeting — vary it day to day. Don't be robotic ("Good morning! Here is your briefing.") but don't be excessively chatty either.
- **Warm but efficient.** Open with a brief, human greeting — vary it day to day, and **match it to the actual local hour** on the `Current Date & Time:` line (don't say "good morning" if it's afternoon or evening). Don't be robotic ("Good morning! Here is your briefing.") but don't be excessively chatty either.
- **Structured.** Use clear sections with headers or bullets. The user should be able to scan in 30 seconds.
- **Actionable.** End each section with what the user might want to *do*, not just what *exists*.
- **Honest about gaps.** If you couldn't fetch calendar data, say "Calendar not connected" rather than pretending there are no events.
@@ -31,7 +31,7 @@ Prepare a morning briefing that helps the user start their day with clarity. Pul
## Rules
- **Never fabricate events, emails, or tasks.** Only include data you actually retrieved from tools or memory.
- **Respect time zones.** The system prompt below carries the user's local date/time and IANA timezone — read it from there. Do **not** ask the user to repeat their timezone; only fall back to UTC and note it if the system context is genuinely missing the field.
- **Respect time zones.** The `Current Date & Time:` line provided with the message carries the user's local date/time and IANA timezone — read it from there. Do **not** ask the user to repeat their timezone; only fall back to UTC and note it if that line is genuinely missing the field.
- **No stale data.** If a tool call fails or returns empty, say so — don't fall back to yesterday's data.
- **Honor the timeline.** The `memory_tree` `cover_window` query already restricts recent memory to the last 24h, so treat its contents as genuinely recent. But each hit carries a real `time_range` — read it, and present things in the order they happened (oldest→newest). For anything carried over from a longer-lived note or a live tool result, compare its date against today's date in the system context: if it predates the day you're briefing for, name the date explicitly ("from your May 25 note…") rather than presenting it as today's.
- **Honor the timeline.** The `memory_tree` `cover_window` query already restricts recent memory to the last 24h, so treat its contents as genuinely recent. But each hit carries a real `time_range` — read it, and present things in the order they happened (oldest→newest). For anything carried over from a longer-lived note or a live tool result, compare its date against today's date on the `Current Date & Time:` line: if it predates the day you're briefing for, name the date explicitly ("from your May 25 note…") rather than presenting it as today's.
- **Privacy first.** Don't include full email bodies or message contents. Summarize senders and subjects.
@@ -92,12 +92,12 @@ mod tests {
#[test]
fn build_includes_runtime_and_datetime_sections() {
// Issue #926: the morning briefing must carry the host's
// current date/time + IANA timezone + runtime in its system
// prompt so the agent never asks the user "what timezone are
// you in?". This test pins the wiring at the parse layer so a
// future edit that drops `render_ambient_environment` from
// the builder fails loudly here.
// Issue #926 + #3602: the morning briefing must carry the `## Runtime`
// host block and the `## Current Date & Time` grounding section so the
// agent never asks the user "what timezone are you in?" and grounds its
// greeting on the real clock. The concrete "now" itself rides the
// per-turn user message (`current_datetime_line`) — see #3602 — so this
// pins the *section wiring*, not a volatile timestamp.
let body = build(&ctx_with_identity(None)).unwrap();
assert!(
body.contains("## Runtime"),
@@ -108,27 +108,16 @@ mod tests {
body.contains("## Current Date & Time"),
"morning_briefing prompt must carry `## Current Date & Time` (#926); got:\n{body}"
);
// IANA zone — either a slashed zone (`America/Los_Angeles`)
// or the `UTC` fallback for hosts where `iana-time-zone`
// can't resolve one. Keying the assertion on this catches
// any regression that switches `DateTimeSection` back to a
// bare `%Z` abbreviation.
// The grounding rule must reach the model so it matches greetings to
// the actual local hour (#3602). The live clock is injected per turn,
// so we assert the rule, not a baked-in timestamp.
let dt = body
.split("## Current Date & Time")
.nth(1)
.expect("datetime section must follow its heading");
// `" UTC "` (space-bounded) — not bare `"UTC"` — because the
// format string always emits a `UTC{offset}` literal in the
// suffix (`UTC-07:00`), so a substring check on `"UTC"` alone
// is trivially satisfied even by a bare-`%Z` regression.
// Either a slashed IANA zone (`America/Los_Angeles`) or the
// explicit space-bounded `" UTC "` fallback must appear before
// the offset.
assert!(
dt.contains('/') || dt.contains(" UTC "),
"datetime section must include IANA zone or `UTC` fallback (a bare \
`UTC-07:00` offset isn't enough — that's the locale-independent \
offset, not the IANA zone); got:\n{dt}"
dt.contains("match the actual local hour"),
"datetime section must carry the greeting-grounding rule (#3602); got:\n{dt}"
);
}