Files
hermes-agent/gateway/message_timestamps.py
tank321 476268f167 fix(gateway): handle early local timestamp strings on Windows
Recover the first Unix day's naive local ISO and bracketed timestamps through a bounded, round-trip-checked mktime fallback. Preserve successful timezone/DST conversions and omit unrepresentable timestamp prefixes rather than aborting gateway history replay.

Add regressions for parsing, rendering, replay with injection on/off, microseconds, and unsupported values. Canonical focused validation: 56 passed; original production code restored as a negative control: 21 regression failures.
2026-10-02 19:22:09 -04:00

137 lines
5.3 KiB
Python

"""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 errno
import re
import sys
import time
from datetime import datetime, timezone
from typing import Any, Optional, Tuple
from hermes_time import safe_strftime
# Leading timestamp prefix, either the current human format
# ``[Tue 2026-04-28 13:40:53 CEST]`` or the older ISO one
# ``[2026-04-13T17:02:06+0200]`` / ``[...+02:00]`` (human tried first).
_TIMESTAMP_PREFIX_RE = re.compile(
r"^\[(?:"
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_+\-/:]+))?"
r"|(?P<iso>\d{4}-\d{2}-\d{2}T[^\]]+)"
r")\]\s*"
)
def _localize(dt: datetime, tz) -> Optional[float]:
"""Epoch for ``dt`` in ``tz`` or the local zone; None outside platform limits."""
naive_local = dt.tzinfo is None and tz is None
try:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=tz) if tz is not None else dt.astimezone()
return float(dt.timestamp())
except (OverflowError, ValueError):
return None
except OSError as exc:
if not (sys.platform == "win32" and naive_local and exc.errno == errno.EINVAL):
return None
# Windows' fold probe can reject the first Unix day. mktime can
# resolve those local wall times without changing their meaning.
# Limit recovery to that fold-free interval and reject normalization.
wall_time = dt.timetuple()
try:
epoch = time.mktime(wall_time)
if 0 <= epoch < 86_400 and time.localtime(epoch)[:6] == wall_time[:6]:
return float(epoch + dt.microsecond / 1_000_000)
except (OSError, OverflowError, ValueError):
pass
return None
def _parse_iso(text: str, tz=None) -> Optional[float]:
"""Parse an ISO-8601 string (incl. ``+0200`` offsets fromisoformat rejects)."""
for parse in (datetime.fromisoformat, lambda t: datetime.strptime(t, "%Y-%m-%dT%H:%M:%S%z")):
try:
return _localize(parse(text), tz)
except (TypeError, ValueError):
continue
return None
def _parse_timestamp_match(match: re.Match, tz=None) -> Optional[float]:
if match.group("iso"):
return _parse_iso(match.group("iso"), tz)
try:
dt = datetime.strptime(f"{match.group('date')} {match.group('time')}", "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
return _localize(dt, tz)
def coerce_message_timestamp(ts_value: Any, tz=None) -> Optional[float]:
"""Epoch seconds from a number, datetime, ISO string, or the gateway's bracketed
format; ``None`` when uninterpretable."""
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
text = ts_value.strip() if isinstance(ts_value, str) else ""
if not text:
return None
match = _TIMESTAMP_PREFIX_RE.match(text)
parsed = _parse_timestamp_match(match, tz=tz) if match is not None else None
if parsed is not None:
return parsed
try:
return float(text)
except (TypeError, ValueError):
return _parse_iso(text, tz)
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 ""
try:
# An early positive epoch can fall in 1969 locally. Starting from aware
# UTC avoids Windows' negative-time fold probe in naive astimezone().
dt = datetime.fromtimestamp(epoch, tz=tz) if tz is not None else datetime.fromtimestamp(epoch, tz=timezone.utc).astimezone()
except (OSError, OverflowError, ValueError):
return ""
return f"[{safe_strftime(dt, '%a %Y-%m-%d %H:%M:%S %Z')}]"
def strip_leading_message_timestamps(content: str, tz=None) -> Tuple[str, Optional[float]]:
"""Strip leading gateway timestamp prefixes → ``(clean_content, embedded_epoch)``.
With several prefixes the one closest to the text wins, preserving the platform-send
time of legacy rows like ``[processing time] [platform time] [sender] message``."""
if not isinstance(content, str) or not content:
return content, None
text, embedded_epoch = content, None
while (match := _TIMESTAMP_PREFIX_RE.match(text)) is not None:
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; an
existing prefix is stripped and its parsed time wins over ``ts_value``."""
clean_content, embedded_epoch = strip_leading_message_timestamps(content, tz=tz)
prefix = format_message_timestamp(ts_value if embedded_epoch is None else embedded_epoch, tz=tz)
return f"{prefix} {clean_content}" if prefix and clean_content else (prefix or clean_content)