feat(gateway): inject stable human-readable message timestamps
Consolidates these related Amy fork patches: - 429830f39 feat(gateway): inject message timestamps into user messages for LLM context - 3c3d6fac0 fix: handle both ISO string and epoch float timestamps in history replay - 2874f7725 feat: human-friendly timestamp format with weekday and timezone name - 3735f4c8b fix: render gateway message timestamps once
This commit is contained in:
committed by
Teknium
parent
b7f0c9cd52
commit
bd7fc8fdcd
@@ -0,0 +1,166 @@
|
||||
"""Helpers for rendering gateway message timestamps exactly once.
|
||||
|
||||
Gateway messages need timestamps in the LLM context for temporal awareness, but
|
||||
persisted message content should stay clean so replay does not accumulate
|
||||
``[timestamp] [timestamp] ...`` prefixes across turns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
|
||||
# Current gateway format: [Tue 2026-04-28 13:40:53 CEST]
|
||||
_HUMAN_TIMESTAMP_RE = re.compile(
|
||||
r"^\[(?P<dow>[A-Z][a-z]{2}) "
|
||||
r"(?P<date>\d{4}-\d{2}-\d{2}) "
|
||||
r"(?P<time>\d{2}:\d{2}:\d{2})"
|
||||
r"(?: (?P<tz>[A-Za-z0-9_+\-/:]+))?\]\s*"
|
||||
)
|
||||
|
||||
# Older gateway format: [2026-04-13T17:02:06+0200] or [+02:00]
|
||||
_ISO_TIMESTAMP_RE = re.compile(
|
||||
r"^\[(?P<iso>\d{4}-\d{2}-\d{2}T[^\]]+)\]\s*"
|
||||
)
|
||||
|
||||
|
||||
def coerce_message_timestamp(ts_value: Any, tz=None) -> Optional[float]:
|
||||
"""Coerce a timestamp-like value to Unix epoch seconds.
|
||||
|
||||
Accepts Unix epoch numbers, datetime objects, ISO strings, and the gateway's
|
||||
bracketed human-readable timestamp format. Returns ``None`` when the value
|
||||
cannot be interpreted.
|
||||
"""
|
||||
if ts_value is None:
|
||||
return None
|
||||
|
||||
if isinstance(ts_value, (int, float)):
|
||||
return float(ts_value)
|
||||
|
||||
if hasattr(ts_value, "timestamp"):
|
||||
try:
|
||||
return float(ts_value.timestamp())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if isinstance(ts_value, str):
|
||||
text = ts_value.strip()
|
||||
if not text:
|
||||
return None
|
||||
parsed = _parse_timestamp_prefix(text, tz=tz)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
try:
|
||||
return float(text)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
dt = datetime.fromisoformat(text)
|
||||
except (TypeError, ValueError):
|
||||
try:
|
||||
dt = datetime.strptime(text, "%Y-%m-%dT%H:%M:%S%z")
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
if tz is not None:
|
||||
dt = dt.replace(tzinfo=tz)
|
||||
else:
|
||||
dt = dt.astimezone()
|
||||
return float(dt.timestamp())
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def format_message_timestamp(ts_value: Any, tz=None) -> str:
|
||||
"""Format a timestamp value as ``[Tue 2026-04-28 13:40:53 CEST]``."""
|
||||
epoch = coerce_message_timestamp(ts_value, tz=tz)
|
||||
if epoch is None:
|
||||
return ""
|
||||
if tz is not None:
|
||||
dt = datetime.fromtimestamp(epoch, tz=tz)
|
||||
else:
|
||||
dt = datetime.fromtimestamp(epoch).astimezone()
|
||||
return "[" + dt.strftime("%a %Y-%m-%d %H:%M:%S %Z") + "]"
|
||||
|
||||
|
||||
def strip_leading_message_timestamps(content: str, tz=None) -> Tuple[str, Optional[float]]:
|
||||
"""Strip one or more leading gateway timestamp prefixes from ``content``.
|
||||
|
||||
Returns ``(clean_content, embedded_epoch)``. If multiple timestamp prefixes
|
||||
are present, the timestamp closest to the actual message text wins. That
|
||||
preserves the original platform-send time for legacy contaminated rows like
|
||||
``[processing time] [platform time] [sender] message``.
|
||||
"""
|
||||
if not isinstance(content, str) or not content:
|
||||
return content, None
|
||||
|
||||
text = content
|
||||
embedded_epoch: Optional[float] = None
|
||||
|
||||
while True:
|
||||
match = _HUMAN_TIMESTAMP_RE.match(text) or _ISO_TIMESTAMP_RE.match(text)
|
||||
if not match:
|
||||
break
|
||||
parsed = _parse_timestamp_match(match, tz=tz)
|
||||
if parsed is not None:
|
||||
embedded_epoch = parsed
|
||||
text = text[match.end():]
|
||||
|
||||
return text, embedded_epoch
|
||||
|
||||
|
||||
def render_user_content_with_timestamp(content: str, ts_value: Any = None, tz=None) -> str:
|
||||
"""Render a user message for LLM context with exactly one timestamp prefix.
|
||||
|
||||
Existing leading timestamp prefixes are removed first. If such a prefix was
|
||||
present, its parsed time wins over ``ts_value``; otherwise ``ts_value`` is
|
||||
formatted and prepended. If no timestamp is available, the cleaned content is
|
||||
returned unchanged.
|
||||
"""
|
||||
clean_content, embedded_epoch = strip_leading_message_timestamps(content, tz=tz)
|
||||
effective_ts = embedded_epoch if embedded_epoch is not None else ts_value
|
||||
prefix = format_message_timestamp(effective_ts, tz=tz)
|
||||
if not prefix:
|
||||
return clean_content
|
||||
if clean_content:
|
||||
return f"{prefix} {clean_content}"
|
||||
return prefix
|
||||
|
||||
|
||||
def _parse_timestamp_prefix(text: str, tz=None) -> Optional[float]:
|
||||
match = _HUMAN_TIMESTAMP_RE.match(text) or _ISO_TIMESTAMP_RE.match(text)
|
||||
if not match:
|
||||
return None
|
||||
return _parse_timestamp_match(match, tz=tz)
|
||||
|
||||
|
||||
def _parse_timestamp_match(match: re.Match, tz=None) -> Optional[float]:
|
||||
if "iso" in match.groupdict() and match.group("iso"):
|
||||
iso_text = match.group("iso")
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso_text)
|
||||
except ValueError:
|
||||
try:
|
||||
dt = datetime.strptime(iso_text, "%Y-%m-%dT%H:%M:%S%z")
|
||||
except ValueError:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
if tz is not None:
|
||||
dt = dt.replace(tzinfo=tz)
|
||||
else:
|
||||
dt = dt.astimezone()
|
||||
return float(dt.timestamp())
|
||||
|
||||
date_part = match.group("date")
|
||||
time_part = match.group("time")
|
||||
try:
|
||||
dt = datetime.strptime(f"{date_part} {time_part}", "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return None
|
||||
if tz is not None:
|
||||
dt = dt.replace(tzinfo=tz)
|
||||
else:
|
||||
dt = dt.astimezone()
|
||||
return float(dt.timestamp())
|
||||
+89
-7
@@ -706,6 +706,12 @@ def _build_gateway_agent_history(
|
||||
the current message behind ``history_offset`` during persistence.
|
||||
"""
|
||||
|
||||
from hermes_time import get_timezone as _get_msg_tz
|
||||
from gateway.message_timestamps import (
|
||||
render_user_content_with_timestamp as _render_msg_ts,
|
||||
)
|
||||
|
||||
_msg_tz = _get_msg_tz()
|
||||
agent_history: List[Dict[str, Any]] = []
|
||||
observed_group_context: List[str] = []
|
||||
separate_observed_context = _uses_telegram_observed_group_context(channel_prompt)
|
||||
@@ -725,6 +731,8 @@ def _build_gateway_agent_history(
|
||||
continue
|
||||
|
||||
content = msg.get("content")
|
||||
if 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())
|
||||
continue
|
||||
@@ -8378,6 +8386,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
|
||||
# Read privacy.redact_pii from config (re-read per message)
|
||||
_redact_pii = False
|
||||
persist_user_message = None
|
||||
persist_user_timestamp = None
|
||||
try:
|
||||
_pcfg = _load_gateway_config()
|
||||
_redact_pii = bool((_pcfg.get("privacy") or {}).get("redact_pii", False))
|
||||
@@ -8902,6 +8912,34 @@ 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.
|
||||
try:
|
||||
from hermes_time import get_timezone as _get_evt_tz
|
||||
from gateway.message_timestamps import (
|
||||
coerce_message_timestamp as _coerce_msg_ts,
|
||||
render_user_content_with_timestamp as _render_msg_ts,
|
||||
strip_leading_message_timestamps as _strip_msg_ts,
|
||||
)
|
||||
_evt_tz = _get_evt_tz()
|
||||
_evt_ts = getattr(event, "timestamp", None)
|
||||
if message_text and isinstance(message_text, str):
|
||||
_clean_message_text, _embedded_ts = _strip_msg_ts(
|
||||
message_text, tz=_evt_tz)
|
||||
persist_user_message = _clean_message_text
|
||||
_event_epoch = _coerce_msg_ts(_evt_ts, tz=_evt_tz)
|
||||
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,
|
||||
)
|
||||
except Exception as _ts_err:
|
||||
logger.debug("Message timestamp injection failed (non-fatal): %s", _ts_err)
|
||||
|
||||
# Bind this gateway run generation to the adapter's active-session
|
||||
# event so deferred post-delivery callbacks can be released by the
|
||||
# same run that registered them.
|
||||
@@ -8935,6 +8973,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
run_generation=run_generation,
|
||||
event_message_id=self._reply_anchor_for_event(event),
|
||||
channel_prompt=event.channel_prompt,
|
||||
persist_user_message=persist_user_message,
|
||||
persist_user_timestamp=persist_user_timestamp,
|
||||
)
|
||||
|
||||
# Stop persistent typing indicator now that the agent is done
|
||||
@@ -9226,7 +9266,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"Your next message will start a fresh session."
|
||||
)
|
||||
|
||||
ts = datetime.now().isoformat()
|
||||
ts = time.time() # Unix epoch float — consistent with DB storage
|
||||
|
||||
# If this is a fresh session (no history), write the full tool
|
||||
# definitions as the first entry so the transcript is self-describing
|
||||
@@ -9262,7 +9302,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# message so the next message can load a transcript that
|
||||
# reflects what was said. Skip the assistant error text since
|
||||
# it's a gateway-generated hint, not model output. (#7100)
|
||||
_user_entry = {"role": "user", "content": message_text, "timestamp": ts}
|
||||
_user_entry = {
|
||||
"role": "user",
|
||||
"content": (
|
||||
persist_user_message
|
||||
if persist_user_message is not None
|
||||
else message_text
|
||||
),
|
||||
"timestamp": (
|
||||
persist_user_timestamp
|
||||
if persist_user_timestamp is not None
|
||||
else ts
|
||||
),
|
||||
}
|
||||
if event.message_id:
|
||||
_user_entry["message_id"] = str(event.message_id)
|
||||
self.session_store.append_to_transcript(
|
||||
@@ -9276,7 +9328,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
|
||||
# If no new messages found (edge case), fall back to simple user/assistant
|
||||
if not new_messages:
|
||||
_user_entry = {"role": "user", "content": message_text, "timestamp": ts}
|
||||
_user_entry = {
|
||||
"role": "user",
|
||||
"content": (
|
||||
persist_user_message
|
||||
if persist_user_message is not None
|
||||
else message_text
|
||||
),
|
||||
"timestamp": (
|
||||
persist_user_timestamp
|
||||
if persist_user_timestamp is not None
|
||||
else ts
|
||||
),
|
||||
}
|
||||
if event.message_id:
|
||||
_user_entry["message_id"] = str(event.message_id)
|
||||
self.session_store.append_to_transcript(
|
||||
@@ -9401,13 +9465,26 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_recent_transcript = []
|
||||
for _msg in reversed(_recent_transcript[-10:]):
|
||||
if _msg.get("role") == "user":
|
||||
_already_persisted = (_msg.get("content") == message_text)
|
||||
_expected_user_content = (
|
||||
persist_user_message
|
||||
if persist_user_message is not None
|
||||
else message_text
|
||||
)
|
||||
_already_persisted = (_msg.get("content") == _expected_user_content)
|
||||
break
|
||||
if not _already_persisted:
|
||||
_user_entry = {
|
||||
"role": "user",
|
||||
"content": message_text,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"content": (
|
||||
persist_user_message
|
||||
if persist_user_message is not None
|
||||
else message_text
|
||||
),
|
||||
"timestamp": (
|
||||
persist_user_timestamp
|
||||
if persist_user_timestamp is not None
|
||||
else time.time()
|
||||
),
|
||||
}
|
||||
if getattr(event, "message_id", None):
|
||||
_user_entry["message_id"] = str(event.message_id)
|
||||
@@ -13602,6 +13679,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_interrupt_depth: int = 0,
|
||||
event_message_id: Optional[str] = None,
|
||||
channel_prompt: Optional[str] = None,
|
||||
persist_user_message: Optional[str] = None,
|
||||
persist_user_timestamp: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run the agent with the given message and context.
|
||||
@@ -15028,7 +15107,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# Keep real user text separate from API-only recovery guidance. If
|
||||
# an auto-continue note is prepended below, persist the original
|
||||
# message so stale guidance never replays as user-authored text.
|
||||
_persist_user_message_override: Optional[Any] = None
|
||||
_persist_user_message_override: Optional[Any] = persist_user_message
|
||||
_persist_user_timestamp_override: Optional[float] = persist_user_timestamp
|
||||
|
||||
# Prepend pending model switch note so the model knows about the switch
|
||||
_pending_notes = getattr(self, '_pending_model_notes', {})
|
||||
@@ -15168,6 +15248,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_conversation_kwargs["persist_user_message"] = _persist_user_message_override
|
||||
elif observed_group_context:
|
||||
_conversation_kwargs["persist_user_message"] = message
|
||||
if _persist_user_timestamp_override is not None:
|
||||
_conversation_kwargs["persist_user_timestamp"] = _persist_user_timestamp_override
|
||||
result = agent.run_conversation(_api_run_message, **_conversation_kwargs)
|
||||
finally:
|
||||
unregister_gateway_notify(_approval_session_key)
|
||||
|
||||
@@ -1322,6 +1322,7 @@ class SessionStore:
|
||||
message.get("platform_message_id") or message.get("message_id")
|
||||
),
|
||||
observed=bool(message.get("observed")),
|
||||
timestamp=message.get("timestamp"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Session DB operation failed: %s", e)
|
||||
|
||||
Reference in New Issue
Block a user