Compare commits

...

3 commits

Author SHA1 Message Date
teknium
36ae958473 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.
2026-06-16 15:49:59 -07:00
Wolfram Ravenwolf
bd7fc8fdcd 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
2026-06-16 15:49:59 -07:00
brooklyn!
b7f0c9cd52
fix(desktop): honor pre-session model pick + restore global reasoning/speed defaults (#47447)
* fix(desktop): keep the pre-session model pick selected in the picker

The composer picker derived its "current" row from `model.options ?? store`,
so model.options always won. Pre-session that query returns the PROFILE
DEFAULT, not the sticky composer pick — so selecting a model before a session
exists left the checkmark (and the picker's "current" line) on the default,
making the pick look ignored even though the pill updated.

Add `currentPickerSelection()`: with a live session the gateway's model.options
is authoritative; pre-session the sticky `$currentModel`/`$currentProvider`
wins, falling back to options. Wire it into ModelMenuPanel and ModelPickerDialog.

* feat(desktop): global reasoning/speed defaults in Settings → Model

The composer picker is now sticky-UI/per-session only and never writes the
profile default (#46959), but Settings → Model had no reasoning/speed control
and `agent.reasoning_effort` wasn't in the curated config surface at all
(`service_tier` was buried in Advanced) — so there was nowhere to set the
profile default that crons/subagents/messaging resolve from.

Add capability-gated Reasoning (effort) + Fast controls beside the main model,
gated by the applied model's reported capabilities (reasoning defaults on, fast
off when unreported — same as the composer). They read/write `agent.reasoning_effort`
and `agent.service_tier` by round-tripping the config record, matching the
gateway's value semantics (service_tier "fast"/"priority"/"on" ⇒ fast).

* refactor(desktop): don't open the reasoning select from its row label

A <label> wrapping the Select forwarded text clicks to the trigger, opening
the dropdown unexpectedly. Plain row for reasoning; Fast stays a <label> so
clicking its text toggles the switch (expected for a checkbox-like control).
2026-06-16 16:22:09 -05:00
24 changed files with 754 additions and 38 deletions

View file

@ -599,6 +599,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

View file

@ -474,6 +474,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.
@ -489,6 +490,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:
@ -510,6 +513,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,

View file

@ -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,
@ -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

View file

@ -16,6 +16,8 @@ const getAuxiliaryModels = vi.fn()
const setModelAssignment = vi.fn()
const getRecommendedDefaultModel = vi.fn()
const setEnvVar = vi.fn()
const getHermesConfigRecord = vi.fn()
const saveHermesConfig = vi.fn()
const startManualProviderOAuth = vi.fn()
vi.mock('@/hermes', () => ({
@ -24,7 +26,9 @@ vi.mock('@/hermes', () => ({
getAuxiliaryModels: () => getAuxiliaryModels(),
setModelAssignment: (body: unknown) => setModelAssignment(body),
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
setEnvVar: (key: string, value: string) => setEnvVar(key, value)
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
getHermesConfigRecord: () => getHermesConfigRecord(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
}))
vi.mock('@/store/onboarding', () => ({
@ -35,7 +39,13 @@ beforeEach(() => {
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
getGlobalModelOptions.mockResolvedValue({
providers: [
{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'], authenticated: true },
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4', 'hermes-4-mini'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
},
// An unconfigured api_key provider — surfaced by the full-universe payload.
{ name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' }
]
@ -47,6 +57,8 @@ beforeEach(() => {
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
setEnvVar.mockResolvedValue({ ok: true })
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
saveHermesConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
@ -100,6 +112,31 @@ describe('ModelSettings', () => {
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
})
it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
const fastSwitch = await screen.findByRole('switch')
fireEvent.click(fastSwitch)
await waitFor(() =>
expect(saveHermesConfig).toHaveBeenCalledWith(
expect.objectContaining({ agent: expect.objectContaining({ service_tier: 'fast' }) })
)
)
})
it('hides the reasoning/speed defaults when the main model reports no capabilities', async () => {
getGlobalModelOptions.mockResolvedValueOnce({
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4'], authenticated: true, capabilities: { 'hermes-4': { reasoning: false, fast: false } } }]
})
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
expect(screen.queryByRole('switch')).toBeNull()
})
it('renders the auxiliary task rows', async () => {
await renderModelSettings()

View file

@ -3,11 +3,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import {
getAuxiliaryModels,
getGlobalModelInfo,
getGlobalModelOptions,
getHermesConfigRecord,
getRecommendedDefaultModel,
saveHermesConfig,
setEnvVar,
setModelAssignment
} from '@/hermes'
@ -15,11 +18,26 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import type { HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { getNested, setNested } from './helpers'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off.
// Empty config = Hermes default (medium), shown as Medium.
const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const
// agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is
// normal (mirrors tui_gateway _load_service_tier).
const isFastTier = (tier: unknown): boolean =>
['fast', 'priority', 'on'].includes(String(tier ?? '').trim().toLowerCase())
// Reuse the composer's effort labels (`xhigh` shows as "Max", else 1:1).
const effortLabelKey = (v: string) => (v === 'xhigh' ? 'max' : v) as 'high' | 'low' | 'max' | 'medium' | 'minimal'
// A provider row is "ready" to pick a model from when it reports models. The
// backend now surfaces the full `hermes model` universe (every canonical
// provider), so unconfigured providers come back with `authenticated:false`
@ -97,6 +115,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const [selectedProvider, setSelectedProvider] = useState('')
const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
// Full profile config, kept so the reasoning/speed defaults round-trip
// (read agent.* → write back the whole record) like the generic config page.
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [applying, setApplying] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
@ -113,10 +134,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setError('')
try {
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
const [modelInfo, modelOptions, auxiliaryModels, cfg] = await Promise.all([
getGlobalModelInfo(),
getGlobalModelOptions(),
getAuxiliaryModels()
getAuxiliaryModels(),
getHermesConfigRecord()
])
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
@ -124,6 +146,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setSelectedProvider(prev => prev || modelInfo.provider)
setSelectedModel(prev => prev || modelInfo.model)
setAuxiliary(auxiliaryModels)
setConfig(cfg)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
@ -181,6 +204,42 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
}, [auxiliary, mainModel])
// Capabilities of the APPLIED main model — gates the profile-default
// reasoning/speed controls the same way the composer picker gates per-model
// edits (reasoning defaults on, fast defaults off when unreported).
const mainCaps = useMemo(() => {
const row = providers.find(provider => provider.slug === mainModel?.provider)
return mainModel ? row?.capabilities?.[mainModel.model] : undefined
}, [providers, mainModel])
const reasoningSupported = mainCaps?.reasoning ?? true
const fastSupported = mainCaps?.fast ?? false
const effortValue = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '').trim().toLowerCase() || 'medium'
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
// Persist a single agent.* default by round-tripping the whole config record
// (PUT /api/config replaces it) — optimistic, with rollback on failure.
const writeAgentDefault = useCallback(
async (key: string, value: string) => {
if (!config) {
return
}
const prev = config
const next = setNested(config, key, value)
setConfig(next)
try {
await saveHermesConfig(next)
} catch (err) {
setConfig(prev)
notifyError(err, m.defaultsFailed)
}
},
[config, m.defaultsFailed]
)
// Paste an API key for the selected `api_key` provider, persist it, then
// refresh so the now-authenticated provider's models populate. Auto-selects
// the recommended default model so the user can Apply in one more click.
@ -433,6 +492,38 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
: `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}
</p>
)}
{config && mainModel && (reasoningSupported || fastSupported) && (
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
<span className="text-xs text-muted-foreground">{m.defaultsLabel}</span>
{reasoningSupported && (
<div className="flex items-center gap-2 text-xs">
{m.reasoning}
<Select onValueChange={value => void writeAgentDefault('agent.reasoning_effort', value)} value={effortValue}>
<SelectTrigger className={cn('min-w-28', CONTROL_TEXT)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EFFORT_VALUES.map(value => (
<SelectItem key={value} value={value}>
{value === 'none' ? m.reasoningOff : t.shell.modelOptions[effortLabelKey(value)]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{fastSupported && (
<label className="flex items-center gap-2 text-xs">
{t.shell.modelOptions.fast}
<Switch
checked={fastOn}
onCheckedChange={checked => void writeAgentDefault('agent.service_tier', checked ? 'fast' : 'normal')}
size="xs"
/>
</label>
)}
</div>
)}
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
{switchStaleAux.length > 0 && (
<div className="mt-2">

View file

@ -18,7 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { currentPickerSelection, displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
import {
@ -84,8 +84,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
}
})
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
!!activeSessionId,
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error

View file

@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useI18n } from '@/i18n'
import { currentPickerSelection } from '@/lib/model-status-label'
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
import type { HermesGateway } from '../hermes'
@ -66,8 +67,13 @@ export function ModelPickerDialog({
})
const providers = modelOptions.data?.providers ?? []
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
!!sessionId,
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error

View file

@ -538,6 +538,10 @@ export const en: Translations = {
provider: 'Provider',
model: 'Model',
applying: 'Applying...',
defaultsLabel: 'Defaults',
reasoning: 'Reasoning',
reasoningOff: 'Off',
defaultsFailed: 'Failed to save model defaults',
auxiliaryTitle: 'Auxiliary models',
resetAllToMain: 'Reset all to main',
auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.',

View file

@ -430,6 +430,10 @@ export interface Translations {
provider: string
model: string
applying: string
defaultsLabel: string
reasoning: string
reasoningOff: string
defaultsFailed: string
auxiliaryTitle: string
resetAllToMain: string
auxiliaryDesc: string

View file

@ -733,6 +733,10 @@ export const zh: Translations = {
provider: '提供方',
model: '模型',
applying: '应用中...',
defaultsLabel: '默认值',
reasoning: '推理',
reasoningOff: '关闭',
defaultsFailed: '保存模型默认值失败',
auxiliaryTitle: '辅助模型',
resetAllToMain: '全部重置为主模型',
auxiliaryDesc: '辅助任务默认使用主模型。你可以为任意任务指定专用模型。',

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
import { currentPickerSelection, displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
describe('model-status-label', () => {
it('formats display names consistently', () => {
@ -35,4 +35,25 @@ describe('model-status-label', () => {
it('returns just the placeholder name when there is no model', () => {
expect(formatModelStatusLabel('')).toBe('No model')
})
describe('currentPickerSelection', () => {
const store = { model: 'opus', provider: 'anthropic' }
const options = { model: 'hermes-4', provider: 'nous' }
it('prefers the sticky composer pick over the profile default pre-session', () => {
expect(currentPickerSelection(false, store, options)).toEqual(store)
})
it('lets the live session model.options win when a session exists', () => {
expect(currentPickerSelection(true, store, options)).toEqual(options)
})
it('falls back to options when the store is empty', () => {
expect(currentPickerSelection(false, { model: '', provider: '' }, options)).toEqual(options)
})
it('falls back to the store while options are still loading', () => {
expect(currentPickerSelection(true, store, undefined)).toEqual(store)
})
})
})

View file

@ -17,6 +17,22 @@ export function reasoningEffortLabel(effort: string): string {
return REASONING_LABELS[key] ?? effort
}
/** Which model/provider a picker should mark "current". With a live session the
* gateway's `model.options` is authoritative; pre-session there is no server
* "current", so the sticky composer pick wins over the profile default the
* global options query returns else the checkmark snaps back to the default
* and the pick looks ignored. */
export function currentPickerSelection(
hasSession: boolean,
store: { model: string; provider: string },
options?: { model?: string; provider?: string }
): { model: string; provider: string } {
return {
model: String((hasSession && options?.model) || store.model || options?.model || ''),
provider: String((hasSession && options?.provider) || store.provider || options?.provider || '')
}
}
/** Strip provider prefix and normalize for display. */
export function modelBaseId(model: string): string {
const trimmed = model.trim()

View file

@ -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())

View file

@ -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,8 +725,18 @@ 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
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 +756,8 @@ def _build_gateway_agent_history(
continue
content = msg.get("content")
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())
continue
@ -8378,6 +8411,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 +8937,42 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if message_text is None:
return
# 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 (
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
)
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)
# 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 +9006,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 +9299,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 +9335,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 +9361,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 +9498,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 +13712,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.
@ -14912,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
@ -15028,7 +15141,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 +15282,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)

View file

@ -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)

View file

@ -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,

View file

@ -2379,6 +2379,7 @@ class SessionDB:
codex_message_items: Any = None,
platform_message_id: str = None,
observed: bool = False,
timestamp: Any = None,
) -> int:
"""
Append a message to a session. Returns the message row ID.
@ -2410,6 +2411,16 @@ class SessionDB:
# cannot bind list/dict parameters directly.
stored_content = self._encode_content(content)
message_timestamp = time.time()
if timestamp is not None:
try:
if hasattr(timestamp, "timestamp"):
message_timestamp = float(timestamp.timestamp())
else:
message_timestamp = float(timestamp)
except (TypeError, ValueError):
logger.debug("Ignoring invalid explicit message timestamp: %r", timestamp)
# Pre-compute tool call count
num_tool_calls = 0
if tool_calls is not None:
@ -2429,7 +2440,7 @@ class SessionDB:
tool_call_id,
tool_calls_json,
tool_name,
time.time(),
message_timestamp,
token_count,
finish_reason,
reasoning,
@ -2482,6 +2493,16 @@ class SessionDB:
for msg in messages:
role = msg.get("role", "unknown")
tool_calls = msg.get("tool_calls")
message_timestamp = now_ts
if msg.get("timestamp") is not None:
try:
ts_value = msg.get("timestamp")
if hasattr(ts_value, "timestamp"):
message_timestamp = float(ts_value.timestamp())
else:
message_timestamp = float(ts_value)
except (TypeError, ValueError):
logger.debug("Ignoring invalid explicit message timestamp: %r", msg.get("timestamp"))
reasoning_details = msg.get("reasoning_details") if role == "assistant" else None
codex_reasoning_items = (
msg.get("codex_reasoning_items") if role == "assistant" else None
@ -2519,7 +2540,7 @@ class SessionDB:
msg.get("tool_call_id"),
tool_calls_json,
msg.get("tool_name"),
now_ts,
message_timestamp,
msg.get("token_count"),
msg.get("finish_reason"),
msg.get("reasoning") if role == "assistant" else None,
@ -2536,7 +2557,7 @@ class SessionDB:
total_tool_calls += (
len(tool_calls) if isinstance(tool_calls, list) else 1
)
now_ts += 1e-6
now_ts = max(now_ts + 1e-6, message_timestamp + 1e-6)
conn.execute(
"UPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?",
@ -2867,9 +2888,9 @@ class SessionDB:
rows = self._conn.execute(
"SELECT role, content, tool_call_id, tool_calls, tool_name, "
"finish_reason, reasoning, reasoning_content, reasoning_details, "
"codex_reasoning_items, codex_message_items, platform_message_id, observed "
"codex_reasoning_items, codex_message_items, platform_message_id, observed, timestamp "
f"FROM messages WHERE session_id IN ({placeholders})"
f"{active_clause} ORDER BY id",
f"{active_clause} ORDER BY timestamp, id",
tuple(session_ids),
).fetchall()
@ -2879,6 +2900,8 @@ class SessionDB:
if row["role"] in {"user", "assistant"} and isinstance(content, str):
content = sanitize_context(content).strip()
msg = {"role": row["role"], "content": content}
if row["timestamp"]:
msg["timestamp"] = row["timestamp"]
if row["tool_call_id"]:
msg["tool_call_id"] = row["tool_call_id"]
if row["tool_name"]:

View file

@ -1472,16 +1472,21 @@ class AIAgent:
that synthetic text leak into persisted transcripts or resumed session
history. When an override is configured for the active turn, mutate the
in-memory messages list in place so both persistence and returned
history stay clean.
history stay clean. A paired timestamp override preserves the platform
event time as message metadata, rather than embedding it in content.
"""
idx = getattr(self, "_persist_user_message_idx", None)
override = getattr(self, "_persist_user_message_override", None)
if override is None or idx is None:
timestamp = getattr(self, "_persist_user_message_timestamp", None)
if idx is None or (override is None and timestamp is None):
return
if 0 <= idx < len(messages):
msg = messages[idx]
if isinstance(msg, dict) and msg.get("role") == "user":
msg["content"] = override
if override is not None:
msg["content"] = override
if timestamp is not None:
msg["timestamp"] = timestamp
def _persist_session(self, messages: List[Dict], conversation_history: List[Dict] = None):
"""Save session state to both JSON log and SQLite on any exit path.
@ -1639,6 +1644,7 @@ class AIAgent:
reasoning_details=msg.get("reasoning_details") if role == "assistant" else None,
codex_reasoning_items=msg.get("codex_reasoning_items") if role == "assistant" else None,
codex_message_items=msg.get("codex_message_items") if role == "assistant" else None,
timestamp=msg.get("timestamp"),
)
flushed_ids.add(msg_id)
self._last_flushed_db_idx = len(messages)
@ -5218,10 +5224,20 @@ class AIAgent:
task_id: str = None,
stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None,
persist_user_timestamp: Optional[float] = None,
) -> Dict[str, Any]:
"""Forwarder — see ``agent.conversation_loop.run_conversation``."""
from agent.conversation_loop import run_conversation
return run_conversation(self, user_message, system_message, conversation_history, task_id, stream_callback, persist_user_message)
return run_conversation(
self,
user_message,
system_message,
conversation_history,
task_id,
stream_callback,
persist_user_message,
persist_user_timestamp,
)
def chat(self, message: str, stream_callback: Optional[callable] = None) -> str:
"""

View file

@ -211,7 +211,10 @@ class TestListAndCleanup:
db = manager._get_db()
messages = db.get_messages_as_conversation(state.session_id)
assert messages == [{"role": "user", "content": "original"}]
assert len(messages) == 1
assert messages[0]["role"] == "user"
assert messages[0]["content"] == "original"
assert isinstance(messages[0].get("timestamp"), (int, float))
def test_cleanup_clears_all(self, manager):
s1 = manager.create_session()
@ -501,6 +504,8 @@ class TestPersistence:
restored = manager.get_session(state.session_id)
assert restored is not None
msg = restored.history[0]
assert isinstance(msg.pop("timestamp", None), (int, float))
assert restored.history == [{
"role": "assistant",
"content": "hello",

View file

@ -23,12 +23,20 @@ class _CapturingAgent:
type(self).last_init = dict(kwargs)
self.tools = []
def run_conversation(self, user_message, conversation_history=None, task_id=None, persist_user_message=None):
def run_conversation(
self,
user_message,
conversation_history=None,
task_id=None,
persist_user_message=None,
persist_user_timestamp=None,
):
type(self).last_run = {
"user_message": user_message,
"conversation_history": conversation_history,
"task_id": task_id,
"persist_user_message": persist_user_message,
"persist_user_timestamp": persist_user_timestamp,
}
return {
"final_response": "ok",

View file

@ -0,0 +1,137 @@
from datetime import datetime
from zoneinfo import ZoneInfo
from gateway.message_timestamps import (
coerce_message_timestamp,
render_user_content_with_timestamp,
strip_leading_message_timestamps,
)
from run_agent import AIAgent
BERLIN = ZoneInfo("Europe/Berlin")
def _epoch(year, month, day, hour, minute, second):
return datetime(year, month, day, hour, minute, second, tzinfo=BERLIN).timestamp()
def test_render_user_content_adds_single_context_timestamp():
ts = _epoch(2026, 4, 28, 13, 40, 53)
rendered = render_user_content_with_timestamp(
"[Example User] Timestamp should be in context",
ts,
tz=BERLIN,
)
assert rendered == (
"[Tue 2026-04-28 13:40:53 CEST] "
"[Example User] Timestamp should be in context"
)
def test_render_user_content_deduplicates_existing_timestamp_and_preserves_embedded_time():
db_processing_ts = _epoch(2026, 4, 27, 15, 55, 36)
stored_content = (
"[Mon 2026-04-27 15:54:44 CEST] "
"[Example User] This should go on our todo list"
)
rendered = render_user_content_with_timestamp(
stored_content,
db_processing_ts,
tz=BERLIN,
)
assert rendered == stored_content
assert rendered.count("2026-04-27") == 1
def test_strip_leading_message_timestamps_removes_multiple_prefixes_and_prefers_inner_time():
content = (
"[Mon 2026-04-27 15:55:36 CEST] "
"[Mon 2026-04-27 15:54:44 CEST] "
"[Example User] This should go on our todo list"
)
stripped, embedded_ts = strip_leading_message_timestamps(content, tz=BERLIN)
assert stripped == "[Example User] This should go on our todo list"
assert embedded_ts == _epoch(2026, 4, 27, 15, 54, 44)
def test_coerce_message_timestamp_accepts_datetime_and_epoch():
dt = datetime(2026, 4, 28, 13, 40, 53, tzinfo=BERLIN)
assert coerce_message_timestamp(dt, tz=BERLIN) == dt.timestamp()
assert coerce_message_timestamp(dt.timestamp(), tz=BERLIN) == dt.timestamp()
def test_persist_user_message_override_keeps_clean_content_and_timestamp_metadata():
agent = AIAgent.__new__(AIAgent)
agent._persist_user_message_idx = 0
agent._persist_user_message_override = "[Example User] Clean content"
agent._persist_user_message_timestamp = _epoch(2026, 4, 28, 13, 40, 53)
messages = [
{
"role": "user",
"content": "[Tue 2026-04-28 13:40:53 CEST] [Example User] Clean content",
}
]
agent._apply_persist_user_message_override(messages)
assert messages == [
{
"role": "user",
"content": "[Example User] Clean content",
"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"

View file

@ -241,7 +241,11 @@ async def test_session_chat_loads_history_and_preserves_session_headers(auth_ada
assert kwargs["session_id"] == session_id
assert kwargs["gateway_session_key"] == "client-42"
assert kwargs["ephemeral_system_prompt"] == "stay focused"
assert kwargs["conversation_history"] == [
history = kwargs["conversation_history"]
assert len(history) == 2
assert isinstance(history[0].pop("timestamp"), (int, float))
assert isinstance(history[1].pop("timestamp"), (int, float))
assert history == [
{"role": "user", "content": "earlier"},
{"role": "assistant", "content": "prior answer"},
]

View file

@ -347,6 +347,15 @@ class TestMessageStorage:
assert messages[0]["content"] == "Hello"
assert messages[1]["role"] == "assistant"
def test_append_message_accepts_explicit_timestamp(self, db):
db.create_session(session_id="s1", source="telegram")
event_ts = 1777383653.0
db.append_message("s1", role="user", content="Hello", timestamp=event_ts)
messages = db.get_messages_as_conversation("s1")
assert messages[0]["timestamp"] == event_ts
def test_message_increments_session_count(self, db):
db.create_session(session_id="s1", source="cli")
db.append_message("s1", role="user", content="Hello")
@ -370,11 +379,10 @@ class TestMessageStorage:
assert messages[1]["observed"] == 0
conversation = db.get_messages_as_conversation("s1")
assert conversation[0] == {
"role": "user",
"content": "[Alice|111]\nside chatter",
"observed": True,
}
assert conversation[0]["role"] == "user"
assert conversation[0]["content"] == "[Alice|111]\nside chatter"
assert conversation[0]["observed"] is True
assert isinstance(conversation[0].get("timestamp"), float)
assert "observed" not in conversation[1]
def test_tool_response_does_not_increment_tool_count(self, db):
@ -458,7 +466,9 @@ class TestMessageStorage:
# get_messages_as_conversation decodes back to the original list
conv = db.get_messages_as_conversation("s1")
assert len(conv) == 1
assert conv[0] == {"role": "user", "content": content}
assert conv[0]["role"] == "user"
assert conv[0]["content"] == content
assert isinstance(conv[0].get("timestamp"), float)
def test_dict_content_round_trip(self, db):
"""Dict-shaped content (e.g. provider wrappers) also round-trips."""
@ -529,8 +539,12 @@ class TestMessageStorage:
conv = db.get_messages_as_conversation("s1")
assert len(conv) == 2
assert conv[0] == {"role": "user", "content": "Hello"}
assert conv[1] == {"role": "assistant", "content": "Hi!"}
assert conv[0]["role"] == "user"
assert conv[0]["content"] == "Hello"
assert isinstance(conv[0]["timestamp"], float)
assert conv[1]["role"] == "assistant"
assert conv[1]["content"] == "Hi!"
assert isinstance(conv[1]["timestamp"], float)
def test_platform_message_id_round_trips(self, db):
"""Platform-side message ids (yuanbao msg_id, telegram update_id, …)
@ -620,7 +634,10 @@ class TestMessageStorage:
)
conv = db.get_messages_as_conversation("s1")
assert conv == [{"role": "assistant", "content": "Visible answer"}]
assert len(conv) == 1
assert conv[0]["role"] == "assistant"
assert conv[0]["content"] == "Visible answer"
assert isinstance(conv[0].get("timestamp"), float)
def test_reasoning_persisted_and_restored(self, db):
"""Reasoning text is stored for assistant messages and restored by

View file

@ -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