Skip to content

fix: prevent stale timestamp perception by injecting current time per-turn#15872

Open
quinnmacro wants to merge 1 commit into
NousResearch:mainfrom
quinnmacro:fix/stale-timestamp-perception
Open

fix: prevent stale timestamp perception by injecting current time per-turn#15872
quinnmacro wants to merge 1 commit into
NousResearch:mainfrom
quinnmacro:fix/stale-timestamp-perception

Conversation

@quinnmacro

@quinnmacro quinnmacro commented Apr 26, 2026

Copy link
Copy Markdown

Problem

The system prompt includes a timestamp frozen at session creation time (for prompt cache stability). However, the label "Conversation started: <time>" is ambiguous — agents interpret it as the current time, leading to incorrect time-sensitive behavior.

Observed: In a session started at 5:07 AM, the agent said "good night 🌙" at 9:00 AM because the only timestamp it saw was "Conversation started: Sunday, April 26, 2026 05:07 AM".

Solution

Split the timestamp into two layers, both using user-message injection to preserve prompt cache:

Layer Content Location Frequency
System prompt Session started: <time> (timezone) Frozen Once per session (cache-stable)
User message Current time: <time>\nTimezone: <tz> Dynamic Every turn (cache-safe)

Why user-message injection (not system prompt)?

PR #10448 addresses the same bug but injects Current time: into the system prompt via _build_turn_scoped_system_prompt(). This changes the system prompt content every turn, which breaks prompt cache prefix.

This PR injects into the user message — the same mechanism that plugins use (pre_llm_call context). The Hermes codebase itself documents this principle:

"Context is ALWAYS injected into the user message, never the system prompt. This preserves the prompt cache prefix — the system prompt stays identical across turns so cached tokens are reused."

User-message injection also composes naturally with the existing plugin system — time context merges with plugin context in _plugin_user_context.

Changes

  1. run_agent.py_build_system_prompt(): Rename "Conversation started""Session started" and add timezone name
  2. run_agent.pyrun_conversation(): Inject current time + timezone into _plugin_user_context on every turn
  3. run_agent.py_handle_max_iterations(): Inject current time into the summary request user message (recovery path)
  4. hermes_time.py: Add get_timezone_name() public API
  5. Comment update: Layer 6 comment now documents the split explicitly
  6. Tests: 4 new/updated tests covering cache safety, user-message injection, and max-iterations path

Test results

tests/run_agent/test_run_agent.py::TestBuildSystemPrompt::test_includes_datetime PASSED
tests/run_agent/test_run_agent.py::TestBuildSystemPrompt::test_excludes_current_time_from_cached_prompt PASSED
tests/run_agent/test_run_agent.py::TestHandleMaxIterations::test_summary_injects_current_time_into_user_message PASSED
tests/run_agent/test_run_agent.py::TestRunConversation::test_turn_level_time_injected_into_user_message PASSED

Relation to #10448

Same bug, different injection location. See my comment on #10448 for the full comparison. Key difference: this PR preserves prompt cache by using user-message injection; #10448 breaks cache by modifying the system prompt per turn.

Token impact

~50 tokens per turn (two lines: current time + timezone). This is minimal compared to typical tool schemas and memory blocks, and the information is essential for correct agent behavior.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent loop, run_agent.py, prompt builder labels Apr 26, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Likely duplicate of #10448 — same per-turn live time injection in run_agent.py. Also related to #15866 (prompt cache invalidation from timestamp).

@alt-glitch

Copy link
Copy Markdown
Collaborator

Likely duplicate of #10448

@Bartok9 Bartok9 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I verified against current main (59b56d44): the %I:%M %p in _build_system_prompt at L4567 is indeed evaluated at build time and baked into the system prompt, which invalidates the KV cache prefix on every _build_system_prompt re-invocation (cache-miss paths as the original issue reporter identified).

The approach here — user-message injection via _plugin_user_context — is the correct pattern. The codebase already uses this exact mechanism for plugin context (L9143), external recall, and pre_llm_call hook output, so this is consistent.

Reviewed all four changes:

  1. System prompt rename (Conversation started → Session started): Clean semantic distinction. The timezone suffix is a nice touch for multi-timezone users.
  2. Per-turn injection: Correctly prepends to _plugin_user_context (before the main loop), so it composes with existing plugin context. The try/except fallback is appropriate since hermes_time depends on optional timezone configuration.
  3. Max-iterations path: Good catch — the _handle_max_iterations recovery path is a separate code path that also needs current time. Mirrors the main loop injection.
  4. Tests: All four tests cover the right invariants (system prompt must NOT contain Current time:, user message must contain it, max-iterations path included).

One thought: the Session started: line still contains %I:%M %p (minute precision). Since it's now clearly labeled as session start rather than current time, agents won't confuse it for 'now'. But for maximum cache stability, could the minute portion be dropped from the system prompt entirely? The per-turn injection now provides the precise time. The session start only needs date + approximate time for session context. Not blocking — just a potential follow-up optimization.

@quinnmacro

Copy link
Copy Markdown
Author

Thanks for reviewing @alt-glitch! This is not a duplicate of #10448 — they fix the same symptom but with fundamentally different architectures that have real production implications.

Key difference: prompt cache preservation

#10448 (Sapientropic) #15872 (this PR)
Injection target System prompt via _build_turn_scoped_system_prompt() User message via _plugin_user_context
Cache behavior System prompt changes every turn → breaks prompt cache prefix System prompt stays frozen → preserves prompt cache
Per-turn cost Entire system prompt re-processed each turn ~50 new tokens in user message only

The Hermes codebase explicitly documents this principle (in run_conversation, around line 9449):

"Context is ALWAYS injected into the user message, never the system prompt. This preserves the prompt cache prefix — the system prompt stays identical across turns so cached tokens are reused."

This isn't theoretical — Anthropic's 2026 best practice also recommends per-turn context in the user message specifically for cache stability. When the system prompt changes each turn, every API call pays full token cost for the system prompt instead of leveraging the cached prefix.

What #10448 does better

  • ✅ Covers the codex responses path (we don't yet)
  • ✅ Clean _build_live_time_context() helper function
  • ✅ More test paths

What this PR does better

  • Preserves prompt cache — critical for production cost and latency
  • ✅ Uses the existing plugin injection mechanism (_plugin_user_context) — composes naturally with plugins
  • ✅ Changes "Conversation started""Session started" — eliminates the ambiguity that caused the bug
  • ✅ Adds timezone name to both timestamps

Proposed resolution

The ideal fix would combine:

  1. User-message injection (from this PR) for cache preservation
  2. Codex responses path coverage (from fix(agent): inject turn-level live time context #10448)
  3. _build_live_time_context() helper (from fix(agent): inject turn-level live time context #10448, but output injected into user message)

I'm happy to add codex responses coverage to this PR if the maintainers prefer the user-message approach, or to close this PR in favor of #10448 if they accept the cache tradeoff. But they are architecturally different and shouldn't be merged as duplicates — the cache question needs a deliberate decision.

@quinnmacro quinnmacro force-pushed the fix/stale-timestamp-perception branch from 161ffd0 to 9dc50cc Compare April 26, 2026 10:12
@quinnmacro

Copy link
Copy Markdown
Author

Thanks for the thorough review @Bartok9! Appreciate the ✅ on all four changes.

Re: your suggestion about dropping the minute portion from Session started: — that's a great point. Since the per-turn injection now provides precise time, the session start only needs to establish when the session began for context. Date + hour (e.g. Session started: Sunday, April 26, 2026 05:xx AM HKT) would be sufficient and would further stabilize the cache prefix across sessions that start in the same hour.

I'll hold off on making that change for now to keep this PR focused on the core fix. Happy to follow up in a separate PR if the maintainers want that optimization.

Also — I've rebased #12630 and #12987 onto the latest main (both were dirty/unstable from the upstream merge wave today). #12630 had one conflict in cli.py where upstream's ModelInfo refactor already removed a fallback branch that the original PR was patching — resolved by accepting upstream's version. #12987 rebased cleanly with zero conflicts.

@markojak

Copy link
Copy Markdown

This PR looks like the preferred salvage path for #17459/#17476 because it separates stable session-start context from live per-turn time context rather than mutating the cached system prompt.

Please align the final version with the fan-out criteria:

Related: #10421, #17459, #17476, #17474, #17475.

@quinnmacro

Copy link
Copy Markdown
Author

Thanks for the clear alignment criteria @markojak. I've addressed all five points in the latest update (single squashed commit 046f2deb):

Alignment checklist

  1. volatile Current time out of cached system prompt — injected into _plugin_user_context (user message), not system prompt. Tests confirm "Current time:" never appears in _cached_system_prompt.

  2. user timezone in ephemeral context — format now aligned with fix(timezone): propagate configured timezone to agent prompt and terminals #10061: Timezone: Asia/Hong_Kong (UTC+08:00) (IANA name + UTC offset). New get_timezone_display() in hermes_time.py computes the offset from hermes_time.now().utcoffset(), so DST-aware zones show the correct offset at runtime. Agents that don't understand IANA names can still determine the offset from the UTC±HH:MM portion.

  3. timezone resolution aligned with fix(timezone): propagate configured timezone to agent prompt and terminals #10061get_timezone_display() uses the same resolution path as fix(timezone): propagate configured timezone to agent prompt and terminals #10061's _resolve_configured_tz_name(): reads both HERMES_TIMEZONE env var and ~/.hermes/config.yaml timezone key via hermes_time.get_timezone(). The display format IANA (UTC±HH:MM) matches fix(timezone): propagate configured timezone to agent prompt and terminals #10061's Timezone: Asia/Shanghai (UTC+08:00) exactly.

  4. tests proving cache stability — 5 tests cover:

    • test_excludes_current_time_from_cached_prompt: system prompt must not contain "Current time:"
    • test_timezone_display_format_aligns_with_10061: system prompt contains "Timezone: Asia/Hong_Kong (UTC+08:00)" on a separate line
    • test_turn_level_time_injected_into_user_message: Current time in user messages, not system
    • test_summary_injects_current_time_into_user_message: max_iterations recovery path also injects time
    • test_run_conversation_codex_injects_current_time_into_user_message: codex_responses mode covered
  5. no gateway-only timestamp-prefix mechanism — all time injection at AIAgent level via _plugin_user_context, same as plugins. No gateway timestamp prefixing.

What changed from previous version

Not changed (by design)

0xnoahzhu added a commit to 0xnoahzhu/hermes-agent that referenced this pull request May 1, 2026
When a user configured a timezone (HERMES_TIMEZONE env var or `timezone:`
in ~/.hermes/config.yaml), `date` and TZ-honouring runtimes inside the
terminal tool reported the host's wall clock instead of the user's zone,
so shell output disagreed with the user's configured timezone (often UTC
vs. the user's actual zone on VPS deployments).

Fixed end-to-end via centralized injection points instead of touching
each backend individually:

- BaseEnvironment._wrap_command injects `export TZ=<name>` once. That
  covers local, docker, ssh, singularity, modal, daytona, and
  vercel_sandbox — all flow through this method. Managed Modal uses a
  different exec path (BaseModalExecutionEnvironment._prepare_modal_exec),
  so it gets the same injection there.
- local.py `_make_run_env` and `_sanitize_subprocess_env` set TZ on the
  subprocess env so PTY/background terminals (spawned via
  process_registry.spawn_local) and the init_session bootstrap that runs
  before the snapshot exists also agree.
- code_execution_tool.py `_run_local_sandbox`: previously read
  HERMES_TIMEZONE directly via os.getenv, silently missing config.yaml-
  only setups (the same bug class). Switched to hermes_time.get_timezone()
  so both env-var and config.yaml paths resolve consistently. The
  redundant inline `TZ=...` prefix on the remote-backend code path was
  dropped — _wrap_command already exports TZ before the script runs.
- New `_resolve_configured_tz_name()` helper in tools/environments/base.py
  reads via hermes_time.get_timezone(), which checks both HERMES_TIMEZONE
  and ~/.hermes/config.yaml. CLI entry points like `hermes chat` don't
  bridge config.yaml -> env var (only gateway/run.py does), so an env-
  var-only check would silently miss config.yaml-only setups.

Scope note: this PR only touches terminal TZ propagation. An earlier
revision also surfaced the timezone in the agent's system prompt; that
piece was removed per review feedback in NousResearch#17459/NousResearch#17476 — live time/
timezone context belongs in the ephemeral per-turn user-message path
(NousResearch#15872), not the cached system prompt prefix. Terminal TZ propagation
is independently useful and tested separately from prompt-cache
behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@quinnmacro quinnmacro force-pushed the fix/stale-timestamp-perception branch from 046f2de to 67723f5 Compare May 2, 2026 13:14
@quinnmacro

Copy link
Copy Markdown
Author

Rebased onto latest main (May 2)

Resolved the merge conflict in tests/run_agent/test_run_agent.py — upstream added two codex-related tests (test_codex_summary_sanitizes_orphan_tool_results, test_api_sanitizer_matches_responses_call_id_when_id_differs) in the same test class as our test_summary_injects_current_time_into_user_message. Both sets are independent; the resolution keeps all tests.

Current status vs. related PRs

This PR (#15872) #10448 #18135
Injection target User message via _plugin_user_context System prompt (breaks cache) User message via prepend_current_time_context()
Cache-safe
Bartok9 approved
#10061 timezone alignment ✅ IANA + UTC offset %Z only
_handle_max_iterations
codex_responses path
Multimodal content
Idempotent injection
Clean transcripts

What I would adopt from #18135

The prepend_current_time_context() helper in hermes_time.py is well-designed — especially the idempotent check and multimodal list-content support. If maintainers prefer, I can refactor this PR to use that helper (keeping _plugin_user_context as the injection channel, timezone aligned with #10061, and max_iterations + codex_responses coverage).

The transcript-cleanliness concern (_persist_user_message_override) is also valid — worth addressing in a follow-up or as part of the helper refactor.

Summary

This PR is now conflict-free, approved, and aligned with all five criteria from @markojak. Ready for merge or further iteration per maintainer preference.

@quinnmacro

Copy link
Copy Markdown
Author

Adopted format_current_time_context() helper from #18135

After reviewing #18135 (WXBR), I've adopted its best idea — a centralized formatting helper in hermes_time.py — while keeping our architectural advantages (cache-safe injection, #10061 timezone alignment, max_iterations + codex coverage).

What changed (commit 31ed790)

New: hermes_time.format_current_time_context() — single source of truth for per-turn time formatting, replacing inline formatting in run_conversation and _handle_max_iterations.

# Before (duplicated in 2 places):
from hermes_time import now as _ht_now, get_timezone_name as _get_tz
_current = _ht_now()
_time_ctx_parts.append(f"Current time: {_current.strftime(...)}")
_tz = _get_tz()
if _tz:
    _time_ctx_parts.append(f"Timezone: {_tz}")

# After (one import, one call):
from hermes_time import format_current_time_context
_time_block = format_current_time_context()

New: 5 unit tests in TestFormatCurrentTimeContext covering the helper directly:

  • Timezone line present when configured, absent when not
  • Explicit datetime passthrough for deterministic testing
  • DST-aware offset verification (EDT vs EST)

What we kept from #18135

  • Centralized helper — no more duplicated formatting logic
  • Explicit datetime parameter — testability without monkeypatching now()
  • Single format contractformat_current_time_context() guarantees consistent output

What we kept from our approach (not from #18135)

What we deliberately did NOT adopt from #18135

  • [System note: Current time is ... .] format — less clear than Current time: ...\nTimezone: ...
  • ❌ Multimodal prepend_current_time_context() — Hermes user-message injection goes through _plugin_user_context (string), not direct content mutation
  • _persist_user_message_override transcript cleaning — valid concern but orthogonal to the core fix; worth a separate PR

Test results

30 passed in 10.13s  (all timezone + run_agent tests)

This PR now has: approved review ✅, no conflicts ✅, #10061 alignment ✅, centralized helper ✅, 11 tests (5 new + 6 existing) ✅.

@quinnmacro quinnmacro requested a review from Bartok9 May 4, 2026 01:46
@Bartok9

Bartok9 commented May 4, 2026

Copy link
Copy Markdown
Contributor

Nice work adopting the format_current_time_context() helper from #18135 — that's exactly the right call for long-term maintainability. The centralized helper in hermes_time.py keeps the volatile/stable split clean without duplicating formatting logic. The rebase onto latest main looks solid too. ✅

@Bartok9

Bartok9 commented May 11, 2026

Copy link
Copy Markdown
Contributor

Approve — user-message injection is the right call

This is the correct fix. The core insight is right: injecting Current time: into the system prompt (as #10448 does) changes the cache prefix every turn and burns cached tokens on every message. User-message injection composes with the existing plugin context mechanism and costs nothing.

The two-layer split is clean:

  • Session started: in the system prompt — frozen, cache-stable, unambiguous label
  • Current time: in the user message — dynamic, injected per-turn via _plugin_user_context

The label rename from Conversation startedSession started matters more than it looks. "Conversation started" is genuinely ambiguous — an agent seeing that at 9 AM after a 5 AM session start has a reasonable basis to interpret it as current time. "Session started" makes the frozen nature of the timestamp explicit.

The max-iterations path injection (_handle_max_iterations) is a good catch — recovery prompts need current time too, easy to miss.

4 tests covering cache safety, user-message injection, and the max-iterations path. All passing.

Relation to #10448: Same bug, wrong injection layer. This PR should supersede it.

@Bartok9 Bartok9 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — user-message injection is the correct approach. Cache prefix preserved, same fix as #10448 but done right. 4 tests covering the critical paths. LGTM.

@UnderLotus

Copy link
Copy Markdown

Thanks for this PR — this is the correct approach.

I have been manually injecting current time into the user message (same location, same reasoning: preserve prompt cache) across two versions of the codebase now, and it works exactly as expected. It solves the stale-timestamp problem without the cache penalty that #10448 would introduce.

This is a ~5-line change with passing tests, and the core issue has been reported across multiple threads (#10421, #9628, #27742, #17459, #17476). It feels like the only thing missing is someone with a merge button making a call.

Just wanted to add a user voice to the discussion — is there anything blocking this that the community can help with?

@quinnmacro quinnmacro force-pushed the fix/stale-timestamp-perception branch from cffb977 to da5fe55 Compare May 30, 2026 07:32
@quinnmacro

Copy link
Copy Markdown
Author

Rebased onto latest upstream/main (v2026.5.29, commit 8738cb9).

Changes adapted to the 3-module refactor:

  • run_agent.py -> agent/system_prompt.py (Conversation->Session rename + timezone)
  • run_agent.py -> agent/conversation_loop.py (per-turn time injection into _plugin_user_context)
  • run_agent.py -> agent/chat_completion_helpers.py (max-iterations time injection)
  • hermes_time.py: get_timezone_name() helper (unchanged)
  • tests/run_agent/test_run_agent.py: 5 tests (updated 3 + added 2 new)

All 5 tests passing, clean single commit (+95/-6).

Maintainer alignment criteria (per @markojak):

  1. Volatile Current time out of cached system prompt
  2. User timezone in ephemeral context (IANA name)
  3. Timezone resolution aligned with fix(timezone): propagate configured timezone to agent prompt and terminals #10061
  4. Tests prove cache stability while per-turn time updates
  5. No gateway-only timestamp-prefix mechanism

Ready for merge.

…-turn

- Rename 'Conversation started' → 'Session started' in system prompt (unambiguous frozen label)
- Add timezone display line to system prompt (IANA + UTC offset, aligned with NousResearch#10061)
- Inject 'Current time:' per-turn into plugin_user_context (user message, not system prompt)
- Inject current time into handle_max_iterations summary prompt
- Add format_current_time_context() and get_timezone_display() helpers to hermes_time.py
- 11 tests covering cache safety, time injection, timezone formatting

Architecture: volatile time goes to user message (preserves prompt cache prefix),
frozen session metadata stays in system prompt.

Adapted to v2026.6.5 refactor: turn_context.py (new file) replaces
the old conversation_loop.py injection point.
@quinnmacro quinnmacro force-pushed the fix/stale-timestamp-perception branch from da5fe55 to a38f6d0 Compare June 11, 2026 08:07
@quinnmacro

Copy link
Copy Markdown
Author

Rebased onto v2026.6.5 (commit c94e93a)

Adapted to the turn_context.py extraction refactor:

Diff: +220/-1 across 5 files. Ready for merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent loop, run_agent.py, prompt builder P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants