From 975a478edaf6104a214135d4eda0f1d293c7b6ce Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Thu, 18 Jun 2026 12:54:03 +0530 Subject: [PATCH] fix(agent): ground time-relative statements in the live clock (#3602) (#3752) Co-authored-by: Claude --- .../agent/harness/harness_gap_tests.rs | 65 +++++++++++++------ .../agent/harness/session/turn/core.rs | 14 ++++ .../agent/harness/session/turn_tests.rs | 14 +++- .../harness/subagent_runner/ops/runner.rs | 10 ++- src/openhuman/agent/prompts/mod.rs | 13 ++-- src/openhuman/agent/prompts/mod_tests.rs | 57 ++++++++++++---- src/openhuman/agent/prompts/render_helpers.rs | 48 ++++++++++++-- src/openhuman/agent/prompts/sections.rs | 40 +++++++----- .../agents/morning_briefing/prompt.md | 8 +-- .../agents/morning_briefing/prompt.rs | 33 ++++------ tests/agent_round26_raw_coverage_e2e.rs | 6 +- tests/calendar_grounding_e2e.rs | 26 +++++++- 12 files changed, 238 insertions(+), 96 deletions(-) diff --git a/src/openhuman/agent/harness/harness_gap_tests.rs b/src/openhuman/agent/harness/harness_gap_tests.rs index afb069b60..aec85768d 100644 --- a/src/openhuman/agent/harness/harness_gap_tests.rs +++ b/src/openhuman/agent/harness/harness_gap_tests.rs @@ -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)" ); } diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index 6b6418776..8decfa0b7 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -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))); diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index 8507f048c..f06b126e9 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -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] diff --git a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs index 3882b5a12..f692d6c98 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs @@ -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 { diff --git a/src/openhuman/agent/prompts/mod.rs b/src/openhuman/agent/prompts/mod.rs index 300c83d39..e8cb626a5 100644 --- a/src/openhuman/agent/prompts/mod.rs +++ b/src/openhuman/agent/prompts/mod.rs @@ -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)] diff --git a/src/openhuman/agent/prompts/mod_tests.rs b/src/openhuman/agent/prompts/mod_tests.rs index 7fca8501c..ede8cb95c 100644 --- a/src/openhuman/agent/prompts/mod_tests.rs +++ b/src/openhuman/agent/prompts/mod_tests.rs @@ -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> = 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] diff --git a/src/openhuman/agent/prompts/render_helpers.rs b/src/openhuman/agent/prompts/render_helpers.rs index ecf44a779..c3c00bada 100644 --- a/src/openhuman/agent/prompts/render_helpers.rs +++ b/src/openhuman/agent/prompts/render_helpers.rs @@ -82,14 +82,54 @@ pub fn render_runtime(ctx: &PromptContext<'_>) -> Result { 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 { 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. diff --git a/src/openhuman/agent/prompts/sections.rs b/src/openhuman/agent/prompts/sections.rs index 658d15743..0634a862e 100644 --- a/src/openhuman/agent/prompts/sections.rs +++ b/src/openhuman/agent/prompts/sections.rs @@ -594,28 +594,34 @@ impl PromptSection for DateTimeSection { } fn build(&self, ctx: &PromptContext<'_>) -> Result { - // 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 \ diff --git a/src/openhuman/agent_registry/agents/morning_briefing/prompt.md b/src/openhuman/agent_registry/agents/morning_briefing/prompt.md index 5c8bd590b..c9493a177 100644 --- a/src/openhuman/agent_registry/agents/morning_briefing/prompt.md +++ b/src/openhuman/agent_registry/agents/morning_briefing/prompt.md @@ -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 = ` and `until_ms = ` (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 = ` and `until_ms = ` (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. diff --git a/src/openhuman/agent_registry/agents/morning_briefing/prompt.rs b/src/openhuman/agent_registry/agents/morning_briefing/prompt.rs index cae83f568..478a4ffe1 100644 --- a/src/openhuman/agent_registry/agents/morning_briefing/prompt.rs +++ b/src/openhuman/agent_registry/agents/morning_briefing/prompt.rs @@ -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}" ); } diff --git a/tests/agent_round26_raw_coverage_e2e.rs b/tests/agent_round26_raw_coverage_e2e.rs index 189db8289..52c550c33 100644 --- a/tests/agent_round26_raw_coverage_e2e.rs +++ b/tests/agent_round26_raw_coverage_e2e.rs @@ -466,11 +466,15 @@ async fn builder_dedupes_visible_native_tools_and_seed_resume_bounds_history() - .messages .iter() .any(|msg| msg.role == "user" && msg.content == "falls back to user")); + // The live turn's user message is stamped with the per-turn + // `Current Date & Time:` line (#3602), so match by suffix rather than + // exact equality — the dedup contract (exactly one "current message" + // user turn after resume) still holds. assert_eq!( requests[0] .messages .iter() - .filter(|msg| msg.role == "user" && msg.content == "current message") + .filter(|msg| msg.role == "user" && msg.content.ends_with("current message")) .count(), 1 ); diff --git a/tests/calendar_grounding_e2e.rs b/tests/calendar_grounding_e2e.rs index 4e796964a..3adcd35d8 100644 --- a/tests/calendar_grounding_e2e.rs +++ b/tests/calendar_grounding_e2e.rs @@ -121,12 +121,32 @@ async fn test_orchestrator_has_current_date_context() -> Result<()> { let _ = agent.turn("what is on my calendar this week?").await?; let messages = captured_messages.lock(); - let system_prompt = messages + // The system prompt carries the static grounding *rule* (#3602) — the + // concrete clock no longer lives here, it rides the per-turn user message + // so a long-lived session can't go stale. + messages .iter() .find(|m| m.role == "system" && m.content.contains("## Current Date & Time")) - .expect("System prompt should contain Current Date & Time"); + .expect("System prompt should carry the Current Date & Time grounding rule"); - assert!(system_prompt.content.contains("202")); + // The live date/time is injected on the user message every turn. Assert it + // carries the stamp and a concrete year token. + let user_msg = messages + .iter() + .find(|m| m.role == "user" && m.content.contains("Current Date & Time:")) + .expect("User message should carry the per-turn Current Date & Time stamp"); + // Assert a concrete `YYYY-MM-DD HH:MM:SS` shape rather than a decade token + // (which would rot as years advance). + let after = user_msg + .content + .split("Current Date & Time: ") + .nth(1) + .expect("stamp must follow the canonical prefix"); + let dt = after + .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("user message stamp must include a parseable YYYY-MM-DD HH:MM:SS"); Ok(()) }