Files
hermes-agent/hermes_time.py
Teknium adf23550f5 fix(tools): profile-scoped checkpoint/snapshot paths, tool caches, TZ and schema paths under multiplex
Under `gateway.multiplex_profiles` one gateway process serves every profile
under ~/.hermes/profiles/NAME/; each routed turn runs with a context-local
HERMES_HOME override while `os.environ` still holds the DEFAULT profile's
values. Anything evaluated once at import, or memoised in a single unkeyed
module slot, therefore freezes the LAUNCH profile's value and leaks it into
every other profile's turns. This lands the tools-side half of that class:

- tools/process_registry.py, tools/environments/{modal,singularity}.py:
  `_checkpoint_path()` / `_snapshot_store()` resolve `get_hermes_home()` at
  call time (same seam as `tools/skills_tool._skills_dir`, so the existing
  `monkeypatch.setattr(CHECKPOINT_PATH)` test sites keep working). Completes
  the checkpoint_manager / sticker_cache half cherry-picked from #56315.
- plugins/platforms/feishu/feishu_comment_rules.py: `_MtimeCache` is now
  path-keyed (accepts a Path or a zero-arg resolver, one (mtime, data) slot
  per resolved path) with `invalidate()`; `_rules_file()` / `_pairing_file()`
  resolve the routed profile's files. Proposed in #63962.
- tools/tool_output_limits.py, tools/browser_tool.py, tools/browser_camofox.py:
  the process-lifetime config caches are dicts keyed by `hermes_home_key()`;
  the `_X_resolved` flags and the lifecycle reset keep their shape.
  tools/file_tools.py drops its private `file_read_max_chars` memo and reads
  the already mtime+path-cached `load_config_readonly()`.
- hermes_time.py: `get_timezone_name()`; when `is_multiplex_active()` the
  env `HERMES_TIMEZONE` (bridged from the default profile's config at gateway
  startup) is ignored in favour of the routed profile's config.yaml. Both
  sandbox TZ sites (code_execution_env/_tool) now use it.
- tools/cronjob_tools.py, tools/tts_tool.py, tools/skill_manager_tool.py:
  the static schema text is profile-neutral and `dynamic_schema_overrides=`
  rebuilds the `display_hermes_home()` / create-dir hint per
  `get_definitions()`, so a routed profile's model sees its own paths.

Refs #95685.

Co-authored-by: Nathan Shan <nathanielcrush51@gmail.com>
(cherry picked from commit 6d3fc6b07b3155c6196b1fd61a829283f1d7855c)
2026-09-11 15:44:00 -07:00

116 lines
4.5 KiB
Python

"""Timezone-aware clock for Hermes.
``now()`` returns a tz-aware datetime in the user's configured IANA timezone. Resolution order:
``HERMES_TIMEZONE`` env var, then ``timezone`` in ``~/.hermes/config.yaml``, else server-local
time. Invalid timezone values log a warning and fall back — never crash.
"""
import logging
import os
import threading
from datetime import datetime
from typing import Dict, Optional, Tuple
from zoneinfo import ZoneInfo
from hermes_constants import get_config_path
logger = logging.getLogger(__name__)
# Cache keyed by timezone *source* identity. This process can multiplex profiles by switching
# HERMES_HOME, so one unkeyed global would leak the first profile's timezone into later
# profile-scoped work (e.g. the desktop multiplex cron ticker persisting another profile's
# ``next_run_at``). Entries are published atomically under ``_cache_lock`` as one
# ``identity -> (name, ZoneInfo | None)`` value, so racing resolvers can never publish a mixed
# identity/value pair. Call reset_cache() after in-place config changes.
_cache_lock = threading.Lock()
_tz_cache: Dict[Tuple[str, str], Tuple[str, Optional[ZoneInfo]]] = {}
def _env_timezone() -> str:
"""``HERMES_TIMEZONE`` when it may speak for the active profile. Under the multiplexed
gateway the env var holds only the DEFAULT profile's value (bridged from its config.yaml at
startup), so every routed profile must read its own config.yaml instead."""
from agent.secret_scope import is_multiplex_active # lazy: secret_scope pulls in more than a clock needs
if is_multiplex_active():
return ""
return os.getenv("HERMES_TIMEZONE", "").strip()
def _timezone_cache_identity() -> Tuple[str, str]:
tz_env = _env_timezone()
return ("environment", tz_env) if tz_env else ("config", str(get_config_path()))
def _resolve_timezone_name() -> str:
"""Read the configured IANA timezone string (or ``""``). Does file I/O — callers cache."""
tz_env = _env_timezone()
if tz_env:
return tz_env
try:
# Prefer the shared cached raw-config reader (mtime-keyed + libyaml): a direct safe_load of
# a large config.yaml costs ~100 ms and this ran inside the FIRST system prompt build.
try:
from hermes_cli.config import read_raw_config
cfg = read_raw_config() or {}
except Exception:
import yaml
config_path = get_config_path()
cfg = (yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}) if config_path.exists() else {}
if cfg:
# Managed scope: an administrator can pin ``timezone`` too (fail-open overlay).
try:
from hermes_cli import managed_scope
cfg = managed_scope.apply_managed_overlay(cfg)
except Exception:
pass
tz_cfg = cfg.get("timezone", "")
if isinstance(tz_cfg, str) and tz_cfg.strip():
return tz_cfg.strip()
except Exception:
pass
return ""
def _timezone_entry() -> Tuple[str, Optional[ZoneInfo]]:
"""Cached ``(configured name, ZoneInfo | None)`` for the active profile."""
cache_identity = _timezone_cache_identity()
with _cache_lock:
entry = _tz_cache.get(cache_identity)
if entry is not None:
return entry
# Resolve outside the lock (config file I/O); first writer wins so concurrent resolvers of the
# same identity converge on one ZoneInfo object.
name = _resolve_timezone_name()
tz = None
if name:
try:
tz = ZoneInfo(name)
except Exception as exc:
logger.warning("Invalid timezone '%s': %s. Falling back to server local time.", name, exc)
with _cache_lock:
return _tz_cache.setdefault(cache_identity, (name, tz))
def get_timezone() -> Optional[ZoneInfo]:
"""Return the active profile's configured ZoneInfo, or None (server-local)."""
return _timezone_entry()[1]
def get_timezone_name() -> str:
"""The active profile's configured IANA timezone string, or ``""`` (server-local). Same
resolution and cache as :func:`get_timezone`; for handing ``TZ`` to sandboxed children."""
return _timezone_entry()[0]
def reset_cache() -> None:
"""Clear the cached timezone so the next call re-resolves it (after config/env changes)."""
with _cache_lock:
_tz_cache.clear()
def now() -> datetime:
"""Current time as a tz-aware datetime: configured zone, else server-local."""
tz = get_timezone()
return datetime.now(tz) if tz is not None else datetime.now().astimezone()