From 36ae958473b8530ffb1a395c4944b8cdbcae82fe Mon Sep 17 00:00:00 2001 From: teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:06:39 -0700 Subject: [PATCH] feat(gateway): gate message timestamps behind opt-in (default off) Follow-up to salvaged PR #41633: the timestamp prefix injection was unconditional. Gate the in-context render behind gateway.message_timestamps.enabled (default false) at both the live-message and history-replay sites; timestamp metadata is still captured + persisted regardless so the toggle can be flipped on later. Add DEFAULT_CONFIG entry, docs, and gate tests. --- gateway/run.py | 52 ++++++++++++++++++---- hermes_cli/config.py | 11 +++++ tests/gateway/test_message_timestamps.py | 46 +++++++++++++++++++ website/docs/user-guide/messaging/index.md | 18 ++++++++ 4 files changed, 118 insertions(+), 9 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index d70b7db2969..32b6b017327 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -692,10 +692,31 @@ def _uses_telegram_observed_group_context(channel_prompt: Optional[str]) -> bool return bool(channel_prompt and _TELEGRAM_OBSERVED_CONTEXT_PROMPT_MARKER in channel_prompt) +def _message_timestamps_enabled(user_config: Optional[dict]) -> bool: + """True when gateway.message_timestamps.enabled is opted in. + + Default OFF: injecting a ``[Tue 2026-04-28 13:40:53 CEST]`` prefix onto + every user message changes what the model sees for all gateway users, so + it must be explicitly enabled in config.yaml under + ``gateway.message_timestamps.enabled``. + """ + if not isinstance(user_config, dict): + return False + gw = user_config.get("gateway") + if not isinstance(gw, dict): + return False + mt = gw.get("message_timestamps") + if isinstance(mt, dict): + return bool(mt.get("enabled", False)) + # Allow a bare ``message_timestamps: true`` shorthand. + return bool(mt) + + def _build_gateway_agent_history( history: List[Dict[str, Any]], *, channel_prompt: Optional[str] = None, + inject_timestamps: bool = False, ) -> tuple[List[Dict[str, Any]], Optional[str]]: """Convert stored gateway transcript rows into agent replay messages. @@ -704,6 +725,10 @@ def _build_gateway_agent_history( turns. Keeping that context out of ``conversation_history`` avoids consecutive-user repair merging it with the live user turn and then hiding the current message behind ``history_offset`` during persistence. + + When ``inject_timestamps`` is True (gateway.message_timestamps.enabled), + each replayed user message is rendered with a single human-readable + timestamp prefix from its stored metadata. """ from hermes_time import get_timezone as _get_msg_tz @@ -731,7 +756,7 @@ def _build_gateway_agent_history( continue content = msg.get("content") - if role == "user" and isinstance(content, str): + if inject_timestamps and role == "user" and isinstance(content, str): content = _render_msg_ts(content, msg.get("timestamp"), tz=_msg_tz) if separate_observed_context and msg.get("observed") and role == "user" and content: observed_group_context.append(str(content).strip()) @@ -8912,9 +8937,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if message_text is None: return - # Inject message timestamp so the LLM sees when this message was sent. - # Keep the persisted transcript clean: timestamps are stored as - # metadata and rendered into context exactly once on replay. + # Capture the platform event time as message metadata and keep the + # persisted transcript clean (strip any leading timestamp prefix). + # This runs regardless of the toggle so storage stays clean and the + # send-time is preserved. Only the in-context RENDER (prepending the + # human-readable prefix the model sees) is gated behind + # gateway.message_timestamps.enabled — default OFF. try: from hermes_time import get_timezone as _get_evt_tz from gateway.message_timestamps import ( @@ -8932,11 +8960,16 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew persist_user_timestamp = ( _event_epoch if _event_epoch is not None else _embedded_ts ) - message_text = _render_msg_ts( - _clean_message_text, - persist_user_timestamp, - tz=_evt_tz, - ) + if _message_timestamps_enabled(_load_gateway_config()): + message_text = _render_msg_ts( + _clean_message_text, + persist_user_timestamp, + tz=_evt_tz, + ) + else: + # Toggle off: model sees the clean message; the timestamp + # is still stored as metadata for later opt-in. + message_text = _clean_message_text except Exception as _ts_err: logger.debug("Message timestamp injection failed (non-fatal): %s", _ts_err) @@ -14991,6 +15024,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew agent_history, observed_group_context = _build_gateway_agent_history( history, channel_prompt=channel_prompt, + inject_timestamps=_message_timestamps_enabled(_load_gateway_config()), ) # Collect MEDIA paths already in history so we can exclude them diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 2c17717c86b..f3ad3fbd019 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2270,6 +2270,17 @@ DEFAULT_CONFIG = { # Gateway settings — control how messaging platforms (Telegram, Discord, # Slack, etc.) deliver agent-produced files as native attachments. "gateway": { + # Inject a human-readable timestamp prefix (e.g. + # "[Tue 2026-04-28 13:40:53 CEST]") onto user messages IN THE MODEL'S + # CONTEXT so the agent has temporal awareness of when each message was + # sent. Off by default — when off, the model sees clean message text. + # Persisted transcripts always stay clean (the timestamp is stored as + # message metadata regardless of this toggle), so turning it on later + # surfaces send-times for past messages too. + "message_timestamps": { + "enabled": False, + }, + # When false (default), any file path the agent emits is delivered # as a native attachment as long as it isn't under the credential / # system-path denylist (/etc, /proc, ~/.ssh, ~/.aws, ~/.hermes/.env, diff --git a/tests/gateway/test_message_timestamps.py b/tests/gateway/test_message_timestamps.py index 1da5f302615..2c95bd44594 100644 --- a/tests/gateway/test_message_timestamps.py +++ b/tests/gateway/test_message_timestamps.py @@ -89,3 +89,49 @@ def test_persist_user_message_override_keeps_clean_content_and_timestamp_metadat "timestamp": _epoch(2026, 4, 28, 13, 40, 53), } ] + + +# --------------------------------------------------------------------------- +# Opt-in gate: gateway.message_timestamps.enabled (default OFF) +# --------------------------------------------------------------------------- + + +def test_message_timestamps_enabled_defaults_off(): + from gateway.run import _message_timestamps_enabled + + assert _message_timestamps_enabled(None) is False + assert _message_timestamps_enabled({}) is False + assert _message_timestamps_enabled({"gateway": {}}) is False + assert ( + _message_timestamps_enabled({"gateway": {"message_timestamps": {}}}) is False + ) + + +def test_message_timestamps_enabled_when_opted_in(): + from gateway.run import _message_timestamps_enabled + + assert _message_timestamps_enabled( + {"gateway": {"message_timestamps": {"enabled": True}}} + ) is True + # Bare shorthand also accepted. + assert _message_timestamps_enabled({"gateway": {"message_timestamps": True}}) is True + + +def test_build_history_injects_only_when_enabled(): + from gateway.run import _build_gateway_agent_history + + history = [ + {"role": "user", "content": "hello", "timestamp": _epoch(2026, 4, 28, 13, 40, 53)}, + {"role": "assistant", "content": "hi"}, + ] + + # Default (off): user content stays clean, no timestamp prefix. + agent_history, _ = _build_gateway_agent_history(history) + assert agent_history[0]["content"] == "hello" + + # Enabled: user content gets exactly one timestamp prefix. + agent_history, _ = _build_gateway_agent_history(history, inject_timestamps=True) + assert agent_history[0]["content"].startswith("[") + assert agent_history[0]["content"].endswith("hello") + # Assistant message is never timestamped. + assert agent_history[1]["content"] == "hi" diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index ce61e73488d..9831a4489fb 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -327,6 +327,24 @@ display: tool_progress_grouping: accumulate # accumulate | separate ``` +### Message timestamps in model context + +Off by default. When enabled, Hermes prepends a human-readable timestamp +(e.g. `[Tue 2026-04-28 13:40:53 CEST]`) onto each **user** message *in the +model's context* so the agent knows when messages were sent — useful for +temporal reasoning ("you asked this morning…", noticing a long gap). It is +**not** added to assistant messages or the system prompt. + +```yaml +gateway: + message_timestamps: + enabled: false # set true to show send-times to the model +``` + +Persisted transcripts always stay clean — the timestamp is stored as message +metadata regardless of this toggle, so enabling it later also surfaces +send-times for past messages, and replay never accumulates duplicate prefixes. + When enabled, the bot sends status messages as it works: ```text