Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ def init_agent(
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
agent._persist_user_message_idx = None
agent._persist_user_message_override = None
agent._persist_user_message_timestamp = None

# Cache anthropic image-to-text fallbacks per image payload/URL so a
# single tool loop does not repeatedly re-run auxiliary vision on the
Expand Down
4 changes: 4 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,7 @@ def run_conversation(
task_id: str = None,
stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None,
persist_user_timestamp: Optional[float] = None,
) -> Dict[str, Any]:
"""
Run a complete conversation with tool calling until completion.
Expand All @@ -456,6 +457,8 @@ def run_conversation(
persist_user_message: Optional clean user message to store in
transcripts/history when user_message contains API-only
synthetic prefixes.
persist_user_timestamp: Optional platform event timestamp to store
as metadata on that persisted user message.
or queuing follow-up prefetch work.

Returns:
Expand All @@ -477,6 +480,7 @@ def run_conversation(
task_id,
stream_callback,
persist_user_message,
persist_user_timestamp,
restore_or_build_system_prompt=_restore_or_build_system_prompt,
install_safe_stdio=_install_safe_stdio,
sanitize_surrogates=_sanitize_surrogates,
Expand Down
2 changes: 2 additions & 0 deletions agent/turn_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def build_turn_context(
task_id: Optional[str],
stream_callback,
persist_user_message: Optional[str],
persist_user_timestamp: Optional[float] = None,
*,
restore_or_build_system_prompt,
install_safe_stdio,
Expand Down Expand Up @@ -121,6 +122,7 @@ def build_turn_context(
agent._stream_callback = stream_callback
agent._persist_user_message_idx = None
agent._persist_user_message_override = persist_user_message
agent._persist_user_message_timestamp = persist_user_timestamp
# Generate unique task_id if not provided to isolate VMs between tasks.
effective_task_id = task_id or str(uuid.uuid4())
agent._current_task_id = effective_task_id
Expand Down
166 changes: 166 additions & 0 deletions gateway/message_timestamps.py
Original file line number Diff line number Diff line change
@@ -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())
96 changes: 89 additions & 7 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,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)
Expand All @@ -674,6 +680,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
Expand Down Expand Up @@ -8325,6 +8333,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g

# 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))
Expand Down Expand Up @@ -8849,6 +8859,34 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
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.
Expand Down Expand Up @@ -8882,6 +8920,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
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
Expand Down Expand Up @@ -9166,7 +9206,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
"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
Expand Down Expand Up @@ -9202,7 +9242,19 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
# 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(
Expand All @@ -9216,7 +9268,19 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g

# 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(
Expand Down Expand Up @@ -9341,13 +9405,26 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
_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)
Expand Down Expand Up @@ -13542,6 +13619,8 @@ async def _run_agent(
_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.
Expand Down Expand Up @@ -14911,7 +14990,8 @@ def _approval_notify_sync(approval_data: dict) -> None:
# 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', {})
Expand Down Expand Up @@ -15051,6 +15131,8 @@ def _approval_notify_sync(approval_data: dict) -> None:
_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)
Expand Down
Loading
Loading