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)
This commit is contained in:
Teknium
2026-09-11 09:48:31 -07:00
parent e70db09f51
commit adf23550f5
20 changed files with 370 additions and 104 deletions

View File

@@ -26,14 +26,25 @@ _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 = os.getenv("HERMES_TIMEZONE", "").strip()
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 = os.getenv("HERMES_TIMEZONE", "").strip()
tz_env = _env_timezone()
if tz_env:
return tz_env
try:
@@ -61,13 +72,13 @@ def _resolve_timezone_name() -> str:
return ""
def get_timezone() -> Optional[ZoneInfo]:
"""Return the active profile's configured ZoneInfo, or None (server-local)."""
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[1]
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()
@@ -78,7 +89,18 @@ def get_timezone() -> Optional[ZoneInfo]:
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))[1]
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:

View File

@@ -10,16 +10,26 @@ import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, Callable, Dict, Optional
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
# Resolved at import time: this module is lazy-imported by the comment event handler,
# long after profile/HERMES_HOME overrides have been applied, so freezing is safe.
RULES_FILE = get_hermes_home() / "feishu_comment_rules.json"
PAIRING_FILE = get_hermes_home() / "feishu_comment_pairing.json"
_RULES_FILE_AT_IMPORT, _PAIRING_FILE_AT_IMPORT = RULES_FILE, PAIRING_FILE
def _rules_file() -> Path:
"""Active profile's rules file at call time: the patched ``RULES_FILE`` when a test changed
it, else live profile-scoped HERMES_HOME — the multiplexed gateway serves every profile from
one process, so the import-time constant would apply the launch profile's rules everywhere."""
return RULES_FILE if RULES_FILE != _RULES_FILE_AT_IMPORT else get_hermes_home() / "feishu_comment_rules.json"
def _pairing_file() -> Path:
return PAIRING_FILE if PAIRING_FILE != _PAIRING_FILE_AT_IMPORT else get_hermes_home() / "feishu_comment_pairing.json"
_VALID_POLICIES = ("allowlist", "pairing")
@@ -51,31 +61,40 @@ class ResolvedCommentRule:
class _MtimeCache:
"""Mtime-based JSON file cache: ``stat()`` per access, re-read only on change."""
"""Mtime-based JSON file cache: ``stat()`` per access, re-read only on change. ``path`` is a
``Path`` or a zero-arg callable resolving one; state is keyed per resolved path so profiles
routed through one multiplexed process never share a slot."""
def __init__(self, path: Path):
self._path, self._mtime, self._data = path, 0.0, None
def __init__(self, path: Path | Callable[[], Path]):
self._resolve = path if callable(path) else (lambda: path)
self._entries: Dict[Path, tuple[float, dict]] = {}
def invalidate(self) -> None:
self._entries.pop(self._resolve(), None)
def load(self) -> dict:
path = self._resolve()
try:
mtime = self._path.stat().st_mtime
mtime = path.stat().st_mtime
except FileNotFoundError:
self._mtime, self._data = 0.0, {}
self._entries.pop(path, None)
return {}
if mtime == self._mtime and self._data is not None:
return self._data
cached = self._entries.get(path)
if cached is not None and cached[0] == mtime:
return cached[1]
try:
with open(self._path, "r", encoding="utf-8") as f:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
logger.warning("[Feishu-Rules] Failed to read %s, using empty config", self._path)
logger.warning("[Feishu-Rules] Failed to read %s, using empty config", path)
data = {}
self._mtime, self._data = mtime, (data if isinstance(data, dict) else {})
return self._data
data = data if isinstance(data, dict) else {}
self._entries[path] = (mtime, data)
return data
_rules_cache = _MtimeCache(RULES_FILE)
_pairing_cache = _MtimeCache(PAIRING_FILE)
_rules_cache = _MtimeCache(_rules_file)
_pairing_cache = _MtimeCache(_pairing_file)
def _parse_frozenset(raw: Any) -> Optional[frozenset]:
@@ -137,11 +156,12 @@ def _load_pairing_approved() -> set:
def _save_pairing(data: dict) -> None:
PAIRING_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(PAIRING_FILE.with_suffix(".tmp"), "w", encoding="utf-8") as f:
pairing_file = _pairing_file()
pairing_file.parent.mkdir(parents=True, exist_ok=True)
with open(pairing_file.with_suffix(".tmp"), "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
PAIRING_FILE.with_suffix(".tmp").replace(PAIRING_FILE)
_pairing_cache._mtime, _pairing_cache._data = 0.0, None # invalidate so the next load re-reads
pairing_file.with_suffix(".tmp").replace(pairing_file)
_pairing_cache.invalidate() # same-second rewrite can keep the mtime; force the next load to re-read
def _mutate_pairing(user_open_id: str, add: bool) -> bool:
@@ -186,7 +206,8 @@ def _fmt_allow(allow_from) -> str:
def _print_status() -> None:
cfg = load_config()
print(f"Rules file: {RULES_FILE}\n exists: {RULES_FILE.exists()}\nPairing file: {PAIRING_FILE}\n exists: {PAIRING_FILE.exists()}\n")
rules_file, pairing_file = _rules_file(), _pairing_file()
print(f"Rules file: {rules_file}\n exists: {rules_file.exists()}\nPairing file: {pairing_file}\n exists: {pairing_file.exists()}\n")
print(f"Top-level:\n enabled: {cfg.enabled}\n policy: {cfg.policy}\n allow_from: {_fmt_allow(cfg.allow_from)}\n")
print(f"Document rules ({len(cfg.documents)}):" if cfg.documents else "Document rules: (none)")
for key, rule in sorted(cfg.documents.items()):
@@ -242,7 +263,7 @@ Commands:
pairing remove <user_open_id> Remove user from pairing-approved list
pairing list List pairing-approved users
Rules config file: {RULES_FILE}
Rules config file: {_rules_file()}
Edit this JSON file directly to configure policies and document rules.
Changes take effect on the next comment event (no restart needed).
"""

View File

@@ -154,5 +154,34 @@ class TestPairingStore(unittest.TestCase):
self.assertIn("ou_new", approved)
class TestRulesFollowActiveProfile(unittest.TestCase):
"""The multiplexed gateway serves every profile from one process: the rules/pairing files and
their mtime caches must follow the context-local HERMES_HOME override, one slot per profile."""
def test_rules_and_pairing_follow_home_override(self):
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from plugins.platforms.feishu import feishu_comment_rules as fcr
def under(home, fn):
token = set_hermes_home_override(str(home))
try:
return fn()
finally:
reset_hermes_home_override(token)
with tempfile.TemporaryDirectory() as tmp:
prof_a, prof_b = Path(tmp) / "A", Path(tmp) / "B"
for home, enabled in ((prof_a, True), (prof_b, False)):
home.mkdir()
(home / "feishu_comment_rules.json").write_text(json.dumps({"enabled": enabled}))
self.assertTrue(under(prof_a, fcr.load_config).enabled) # warm A's slot
self.assertFalse(under(prof_b, fcr.load_config).enabled)
self.assertTrue(under(prof_a, fcr.load_config).enabled) # A's slot survives B
self.assertTrue(under(prof_b, lambda: fcr.pairing_add("ou_b")))
self.assertTrue((prof_b / "feishu_comment_pairing.json").exists())
self.assertFalse((prof_a / "feishu_comment_pairing.json").exists())
self.assertNotIn("ou_b", under(prof_a, fcr.pairing_list))
if __name__ == "__main__":
unittest.main()

View File

@@ -108,6 +108,35 @@ class TestGetTimezone:
monkeypatch.setenv("HERMES_HOME", str(first_home))
assert str(hermes_time.get_timezone()) == "Asia/Tokyo"
def test_multiplex_prefers_routed_profile_config_over_env(self, tmp_path, monkeypatch):
"""Under the multiplexed gateway HERMES_TIMEZONE holds only the DEFAULT profile's value
(bridged at startup), so a routed profile must resolve from its own config.yaml."""
from agent.secret_scope import set_multiplex_active
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
default_home, routed_home = tmp_path / "default", tmp_path / "routed"
default_home.mkdir()
routed_home.mkdir()
(default_home / "config.yaml").write_text("timezone: America/New_York\n", encoding="utf-8")
(routed_home / "config.yaml").write_text("timezone: Asia/Tokyo\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(default_home))
monkeypatch.setenv("HERMES_TIMEZONE", "America/New_York")
# Single-profile process: env stays authoritative.
assert hermes_time.get_timezone_name() == "America/New_York"
set_multiplex_active(True)
try:
assert hermes_time.get_timezone_name() == "America/New_York" # default profile turn
token = set_hermes_home_override(str(routed_home))
try:
assert hermes_time.get_timezone_name() == "Asia/Tokyo"
assert str(hermes_time.get_timezone()) == "Asia/Tokyo"
finally:
reset_hermes_home_override(token)
finally:
set_multiplex_active(False)
def test_concurrent_profile_resolution_never_mixes_zones(
self, tmp_path, monkeypatch
):

View File

@@ -720,17 +720,12 @@ class TestConfigOverride(unittest.TestCase):
def setUp(self):
_read_tracker.clear()
# Reset the cached value so each test gets a fresh lookup
import tools.file_tools as _ft
_ft._max_read_chars_cached = None
def tearDown(self):
_read_tracker.clear()
import tools.file_tools as _ft
_ft._max_read_chars_cached = None
@patch("tools.file_tools._get_file_ops")
@patch("hermes_cli.config.load_config", return_value={"file_read_max_chars": 50})
@patch("hermes_cli.config.load_config_readonly", return_value={"file_read_max_chars": 50})
def test_custom_config_lowers_limit(self, _mock_cfg, mock_ops):
"""A config value of 50 should trigger truncation for reads over 50 chars,
with the configured limit reflected in the continuation hint."""
@@ -743,7 +738,7 @@ class TestConfigOverride(unittest.TestCase):
self.assertLessEqual(len(result["content"]), 50)
@patch("tools.file_tools._get_file_ops")
@patch("hermes_cli.config.load_config", return_value={"file_read_max_chars": 500_000})
@patch("hermes_cli.config.load_config_readonly", return_value={"file_read_max_chars": 500_000})
def test_custom_config_raises_limit(self, _mock_cfg, mock_ops):
"""A config value of 500K should allow reads up to 500K chars."""
# 200K chars would be rejected at the default 100K but passes at 500K

View File

@@ -0,0 +1,89 @@
"""Under ``gateway.multiplex_profiles`` one process serves every profile; each routed turn runs with
a context-local HERMES_HOME override. Tool-side state resolved once at import, or cached in a
single unkeyed slot, would hand the launch profile's paths/limits to every other profile.
Each test warms the site under profile A, flips the override to profile B with different
config, and asserts B sees its own values (real temp homes, real config.yaml, no mocks).
"""
import json
import pytest
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
@pytest.fixture
def two_profiles(tmp_path, monkeypatch):
prof_a, prof_b = tmp_path / "profA", tmp_path / "profB"
for home, limits in ((prof_a, (222, 33, 3300)), (prof_b, (888, 77, 7700))):
home.mkdir()
max_bytes, timeout, threshold = limits
(home / "config.yaml").write_text(
f"file_read_max_chars: {max_bytes}\ntool_output:\n max_bytes: {max_bytes}\n"
f"browser:\n command_timeout: {timeout}\n snapshot_threshold: {threshold}\n",
encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(prof_a))
return prof_a, prof_b
def _under(home, fn):
token = set_hermes_home_override(str(home))
try:
return fn()
finally:
reset_hermes_home_override(token)
def test_checkpoint_and_snapshot_paths_follow_active_profile(two_profiles):
import tools.process_registry as pr
from tools.environments import modal, singularity
prof_a, prof_b = two_profiles
_under(prof_a, pr._checkpoint_path) # warm under A
assert _under(prof_b, pr._checkpoint_path) == prof_b / "processes.json"
assert _under(prof_b, modal._snapshot_store) == prof_b / "modal_snapshots.json"
assert _under(prof_b, singularity._snapshot_store) == prof_b / "singularity_snapshots.json"
assert _under(prof_a, pr._checkpoint_path) == prof_a / "processes.json"
def test_config_caches_are_keyed_by_profile(two_profiles):
import tools.browser_camofox as cam
import tools.browser_tool as bt
import tools.file_tools as ft
import tools.tool_output_limits as tol
from tools.browser_tool_lifecycle import cleanup_all_browsers
prof_a, prof_b = two_profiles
tol._reset_tool_output_limits_cache()
cleanup_all_browsers()
cam._cmd_timeout_resolved, cam._cached_cmd_timeout = False, None
def read_all():
return (tol.get_tool_output_limits()["max_bytes"], ft._get_max_read_chars(),
bt._get_command_timeout(), bt.get_browser_snapshot_threshold(), cam._get_command_timeout())
assert _under(prof_a, read_all) == (222, 222, 33, 3300, 33)
assert _under(prof_b, read_all) == (888, 888, 77, 7700, 77)
# Per-profile slots stay hot — switching back is not a single-slot ping-pong.
assert _under(prof_a, read_all) == (222, 222, 33, 3300, 33)
def test_schema_path_hints_follow_active_profile(two_profiles):
import tools.cronjob_tools # noqa: F401 (registers cronjob_manage)
import tools.skill_manager_tool # noqa: F401
import tools.tts_tool # noqa: F401
from tools.registry import registry
prof_a, prof_b = two_profiles
names = {"cronjob_manage", "text_to_speech", "skill_manage"}
def definitions():
return json.dumps(registry.get_definitions(names, quiet=True))
for_a, for_b = _under(prof_a, definitions), _under(prof_b, definitions)
assert "profA" in for_a and "profB" not in for_a
assert "profB" in for_b and "profA" not in for_b
for fn in json.loads(for_b):
name, text = fn["function"]["name"], json.dumps(fn["function"])
assert name in names and "profB" in text, name

View File

@@ -12,10 +12,11 @@ from tools.terminal_tool import terminal_tool
@pytest.fixture
def small_cap(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
from hermes_constants import hermes_home_key
import tools.tool_output_limits as lim
monkeypatch.setattr(lim, "_cached_limits", {
monkeypatch.setattr(lim, "_cached_limits", {hermes_home_key(): {
"max_bytes": 2000, "max_lines": 2000, "max_line_length": 2000,
})
}})
return tmp_path

View File

@@ -25,6 +25,7 @@ import requests
from agent.secret_scope import get_secret
from hermes_cli.config import cfg_get, load_config, read_raw_config
from hermes_constants import hermes_home_key
from tools.browser_camofox_state import get_camofox_identity
from tools.registry import tool_error
@@ -36,24 +37,30 @@ _DEFAULT_TIMEOUT = 30 # fallback when config is unreadable
_NO_SESSION_ERROR = "No browser session. Call browser_navigate first."
_vnc_url: Optional[str] = None # cached from /health response
_vnc_url_checked = False # only probe once per process
_cached_cmd_timeout: Optional[int] = None # browser.command_timeout, resolved lazily like browser_tool
# browser.command_timeout, resolved lazily like browser_tool; keyed by profile home because the
# multiplexed gateway serves every profile from one process.
_cached_cmd_timeout: Optional[Dict[str, int]] = None
_cmd_timeout_resolved = False
def _get_command_timeout() -> int:
"""``browser.command_timeout`` (floor 5s, default 30s), cached after first read."""
"""``browser.command_timeout`` (floor 5s, default 30s), cached per profile home after first read."""
global _cached_cmd_timeout, _cmd_timeout_resolved
if _cmd_timeout_resolved:
return _cached_cmd_timeout # type: ignore[return-value]
_cmd_timeout_resolved = True
_cached_cmd_timeout = _DEFAULT_TIMEOUT
home = hermes_home_key()
if _cached_cmd_timeout is None:
_cached_cmd_timeout = {}
if _cmd_timeout_resolved and home in _cached_cmd_timeout:
return _cached_cmd_timeout[home]
timeout = _DEFAULT_TIMEOUT
try:
val = cfg_get(read_raw_config(), "browser", "command_timeout")
if val is not None:
_cached_cmd_timeout = max(int(val), 5)
timeout = max(int(val), 5)
except Exception as exc:
logger.debug("Could not read browser.command_timeout: %s", exc)
return _cached_cmd_timeout
_cached_cmd_timeout[home] = timeout
_cmd_timeout_resolved = True
return timeout
def _auth_headers() -> Dict[str, str]:

View File

@@ -20,7 +20,7 @@ import time
from typing import Dict, Any, Optional, Union
from pathlib import Path
from agent.redact import redact_cdp_url
from hermes_constants import get_hermes_home
from hermes_constants import get_hermes_home, hermes_home_key
from utils import env_int
from hermes_cli.config import DEFAULT_CONFIG, cfg_get
@@ -125,11 +125,14 @@ AGENT_BROWSER_NPX_SPEC = "agent-browser@^0.26.0"
# Process caches (``_cached_X`` + ``_X_resolved`` pairs) for config-derived lookups;
# reset by ``cleanup_all_browsers``. Written/read by the sibling modules via ``browser_tool_origin``.
_cached_command_timeout: Optional[int] = None
# The config-derived ones are keyed by profile home (``hermes_home_key()``): the multiplexed
# gateway serves every profile from one process, so a single slot would hand the launch
# profile's browser settings to every other profile.
_cached_command_timeout: Optional[Dict[str, int]] = None
# Flip the resolved flag BEFORE nulling the cache so a concurrent reader never sees ``resolved=True`` with
# ``cache=None`` (#14331).
_command_timeout_resolved = False
_cached_snapshot_threshold: Optional[int] = None
_cached_snapshot_threshold: Optional[Dict[str, int]] = None
_snapshot_threshold_resolved = False
_cached_cloud_provider: Optional[BrowserProvider] = None
_cloud_provider_resolved = False
@@ -167,14 +170,18 @@ def _browser_cfg(key: str, default, parse, log_label: str):
def _cached_browser_cfg(cache_name: str, flag_name: str, key: str, default, parse, log_label: str):
"""Process-cached ``_browser_cfg`` read (cleared by ``cleanup_all_browsers``). The value is
stored BEFORE the resolved flag flips so a concurrent reader never sees ``resolved=True``
with a ``None`` cache."""
"""Process-cached ``_browser_cfg`` read, one slot per profile home (cleared by
``cleanup_all_browsers``). The value is stored BEFORE the resolved flag flips so a
concurrent reader never sees ``resolved=True`` with an empty cache."""
g = globals()
if g[flag_name] and g[cache_name] is not None:
return g[cache_name]
home = hermes_home_key()
cache = g[cache_name]
if cache is None:
cache = g[cache_name] = {}
if g[flag_name] and cache.get(home) is not None:
return cache[home]
result = _browser_cfg(key, default, parse, log_label)
g[cache_name] = result
cache[home] = result
g[flag_name] = True
return result

View File

@@ -121,8 +121,11 @@ def _build_child_env(*, rpc_endpoint: str, rpc_token: str, tmpdir: str,
# code page (cp1252) and print("→") raises; harmless under a C/POSIX locale (containers).
child_env["PYTHONIOENCODING"] = "utf-8"
child_env["PYTHONUTF8"] = "1"
# Only TZ reaches the child; HERMES_TIMEZONE is an internal setting.
_tz_name = os.getenv("HERMES_TIMEZONE", "").strip()
# Only TZ reaches the child; HERMES_TIMEZONE is an internal setting (and under the multiplexed
# gateway holds only the default profile's value — hermes_time resolves the routed profile's).
from hermes_time import get_timezone_name
_tz_name = get_timezone_name()
if _tz_name:
child_env["TZ"] = _tz_name
child_env.pop("HERMES_TIMEZONE", None)

View File

@@ -28,6 +28,7 @@ from typing import Any, Dict, List, Optional, Tuple
from tools.thread_context import propagate_context_to_thread
from tools.registry import registry, tool_error
from hermes_time import get_timezone_name
from tools.code_execution_env import _resolve_child_cwd, _resolve_child_python
from tools.code_execution_rpc import _rpc_poll_loop
@@ -578,7 +579,7 @@ def _run_remote_per_call(env, env_type: str, code: str, effective_task_id: str,
rpc_thread.start()
env_prefix = (f"HERMES_RPC_DIR={quoted_rpc_dir} HERMES_RPC_TOKEN={shlex.quote(rpc_token)} "
"PYTHONDONTWRITEBYTECODE=1")
tz = os.getenv("HERMES_TIMEZONE", "").strip()
tz = get_timezone_name() # routed profile's timezone, not the bridged default's
if tz:
env_prefix += f" TZ={shlex.quote(tz)}"
logger.info("Executing code on %s backend (task %s)...", env_type, effective_task_id[:8])

View File

@@ -10,6 +10,8 @@ import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
import copy
from hermes_constants import display_hermes_home
logger = logging.getLogger(__name__)
@@ -904,6 +906,21 @@ def cronjob(
return tool_error(str(e), success=False)
def _script_description(home: str) -> str:
return (f"Optional script run each tick; stdout is injected into the agent's prompt as context (with no_agent=True "
f"the script IS the job). Relative paths resolve under {home}/scripts/; .sh/.bash via bash, else Python. "
"On update, '' clears.")
def _cronjob_schema_overrides() -> dict:
"""Rebuild the ``script`` path hint from the ACTIVE profile at every get_definitions(): the
static schema is built once per process, but the multiplexed gateway serves every profile from
that process, so a path baked in at import would name the launch profile's home (#95685)."""
params = copy.deepcopy(CRONJOB_SCHEMA["parameters"])
params["properties"]["script"]["description"] = _script_description(display_hermes_home())
return {"parameters": params}
CRONJOB_SCHEMA = {
"name": "cronjob_manage",
"description": """Manage scheduled cron jobs: action='create' schedules a job from a prompt and/or skills; 'list' inspects jobs; 'update'/'pause'/'resume'/'remove' manage one by job_id (always list first — never guess job IDs); 'run' fires a job immediately in the BACKGROUND (returns a handle at once, outcome re-enters the conversation when done — do not wait or poll; optional 'prompt' adds transient context for that fire only).
@@ -954,7 +971,7 @@ Jobs run in a fresh session with no current-chat context, so prompts must be sel
},
"script": {
"type": "string",
"description": f"Optional script run each tick; stdout is injected into the agent's prompt as context (with no_agent=True the script IS the job). Relative paths resolve under {display_hermes_home()}/scripts/; .sh/.bash via bash, else Python. On update, '' clears."
"description": _script_description("the profile HERMES_HOME")
},
"monitor": {
"type": "string",
@@ -1037,6 +1054,7 @@ registry.register(
handler=_cronjob_handler,
check_fn=check_cronjob_requirements,
emoji="⏰",
dynamic_schema_overrides=_cronjob_schema_overrides,
)

View File

@@ -21,15 +21,18 @@ from tools.environments.remote_common import bash_argv, ensure_lazy_dep
logger = logging.getLogger(__name__)
_SNAPSHOT_STORE = get_hermes_home() / "modal_snapshots.json"
def _snapshot_store() -> Path:
# Resolved per call: the multiplexed gateway serves every profile from one process, so an
# import-time path would keep every profile's snapshots in the launch profile's home.
return get_hermes_home() / "modal_snapshots.json"
def _load_snapshots() -> dict:
return _load_json_store(_SNAPSHOT_STORE)
return _load_json_store(_snapshot_store())
def _save_snapshots(data: dict) -> None:
_save_json_store(_SNAPSHOT_STORE, data)
_save_json_store(_snapshot_store(), data)
def _get_snapshot_restore_candidate(task_id: str) -> tuple[str | None, bool]:

View File

@@ -21,7 +21,10 @@ from tools.environments.remote_common import bash_argv, run_capture
logger = logging.getLogger(__name__)
_SNAPSHOT_STORE = get_hermes_home() / "singularity_snapshots.json"
def _snapshot_store() -> Path:
# Resolved per call: the multiplexed gateway serves every profile from one process, so an
# import-time path would keep every profile's snapshots in the launch profile's home.
return get_hermes_home() / "singularity_snapshots.json"
def _find_singularity_executable() -> str:
@@ -51,11 +54,11 @@ def _ensure_singularity_available() -> str:
def _load_snapshots() -> dict:
return _load_json_store(_SNAPSHOT_STORE)
return _load_json_store(_snapshot_store())
def _save_snapshots(data: dict) -> None:
_save_json_store(_SNAPSHOT_STORE, data)
_save_json_store(_snapshot_store(), data)
def _get_scratch_dir() -> Path:

View File

@@ -45,21 +45,17 @@ _EXPECTED_WRITE_ERRNOS = {errno.EACCES, errno.EPERM, errno.EROFS}
# Read-size guard. Model-agnostic, so characters proxy tokens: 100K chars is
# ~25-35K tokens across typical tokenisers. Configurable: file_read_max_chars.
_DEFAULT_MAX_READ_CHARS = 100_000
_max_read_chars_cached: int | None = None
def _get_max_read_chars() -> int:
"""Return ``file_read_max_chars`` from config.yaml (cached per process; default on missing/invalid)."""
global _max_read_chars_cached
if _max_read_chars_cached is None:
try:
from hermes_cli.config import load_config
val = load_config().get("file_read_max_chars")
except Exception:
val = None
valid = isinstance(val, (int, float)) and val > 0
_max_read_chars_cached = int(val) if valid else _DEFAULT_MAX_READ_CHARS
return _max_read_chars_cached
"""Return ``file_read_max_chars`` from config.yaml (default on missing/invalid). No module
cache: ``load_config_readonly`` is already mtime+path cached, and a process-lifetime slot
would pin the launch profile's value under the multiplexed gateway."""
try:
from hermes_cli.config import load_config_readonly
val = load_config_readonly().get("file_read_max_chars")
except Exception:
val = None
valid = isinstance(val, (int, float)) and val > 0
return int(val) if valid else _DEFAULT_MAX_READ_CHARS
def _truncate_to_char_budget(content: str, max_chars: int) -> tuple[str, int, bool]:

View File

@@ -39,6 +39,15 @@ logger = logging.getLogger(__name__)
# Crash-recovery checkpoint (gateway only)
CHECKPOINT_PATH = get_hermes_home() / "processes.json"
_CHECKPOINT_PATH_AT_IMPORT = CHECKPOINT_PATH
def _checkpoint_path() -> Path:
"""Active profile's checkpoint file at call time: the patched ``CHECKPOINT_PATH`` when a test
changed it, else live profile-scoped HERMES_HOME — the multiplexed gateway serves every
profile from one process, so the import-time constant would pin every profile's process
checkpoint to the launch home."""
return CHECKPOINT_PATH if CHECKPOINT_PATH != _CHECKPOINT_PATH_AT_IMPORT else get_hermes_home() / "processes.json"
MAX_OUTPUT_CHARS = 200_000 # rolling output buffer
FINISHED_TTL_SECONDS = 1800 # keep finished processes 30 minutes

View File

@@ -15,7 +15,7 @@ class ProcessCheckpointMixin:
def _write_checkpoint(self, extra_entries: Optional[List[Dict[str, Any]]] = None):
"""Write running process metadata to the checkpoint file atomically."""
from tools.process_registry import CHECKPOINT_PATH, _CHECKPOINT_FIELDS
from tools.process_registry import _checkpoint_path, _CHECKPOINT_FIELDS
try:
with self._lock:
@@ -39,7 +39,7 @@ class ProcessCheckpointMixin:
tracked_ids = {item.get("session_id") for item in entries}
entries.extend(item for item in extra_entries if item.get("session_id") not in tracked_ids)
from utils import atomic_json_write
atomic_json_write(CHECKPOINT_PATH, entries)
atomic_json_write(_checkpoint_path(), entries)
except Exception as e:
logger.debug("Failed to write checkpoint file: %s", e, exc_info=True)
@@ -47,14 +47,15 @@ class ProcessCheckpointMixin:
"""On gateway startup, probe PIDs from the checkpoint file; returns how many
were recovered as detached sessions."""
from tools.process_registry import (
CHECKPOINT_PATH, ProcessSession, _CHECKPOINT_FIELDS,
ProcessSession, _CHECKPOINT_FIELDS, _checkpoint_path,
_CHECKPOINT_DEFAULTS, _WATCHER_ROUTE_KEYS, _stop_systemd_unit,
)
if not CHECKPOINT_PATH.exists():
checkpoint_path = _checkpoint_path()
if not checkpoint_path.exists():
return 0
try:
entries = json.loads(CHECKPOINT_PATH.read_text(encoding="utf-8"))
entries = json.loads(checkpoint_path.read_text(encoding="utf-8"))
except Exception:
return 0
recovered = 0

View File

@@ -779,17 +779,13 @@ def skill_manage(
# --- OpenAI Function-Calling Schema -------------------------------------------
SKILL_MANAGE_SCHEMA = {
"name": "skill_manage",
# ONE advertised call shape (memory-tool pattern): the call IS an operations
# array. The legacy flat shape (top-level action/name/content/...) is still
# ACCEPTED for old transcripts and staged-write replay, but not advertised.
"description": (
def _skill_manage_description(create_dir: str) -> str:
return (
"Create, update, or delete skills — your procedural memory for "
"recurring task types. The call is an operations array (a single "
"edit is a list of one); it applies atomically — any failure rolls "
"every touched skill back. Ops: create (full SKILL.md; lands in "
f"{_display_create_dir()}; must precede that skill's other "
f"{create_dir}; must precede that skill's other "
"ops), patch (targeted old_string/new_string fix — preferred; "
"content alone REPLACES the whole file, read it via skill_view() "
"first), write_file/remove_file (supporting files), delete (sole "
@@ -799,7 +795,22 @@ SKILL_MANAGE_SCHEMA = {
"imperative rule + why, no PR numbers/dates/incident narration, one "
"rule per lesson, references/ named by topic (extend before adding). "
"skill_view() shows format conventions."
),
)
def _skill_manage_schema_overrides() -> dict:
"""Rebuild the create-dir hint from the ACTIVE profile at every get_definitions(): the
multiplexed gateway serves every profile from one process, so a path baked in at import
would name the launch profile's skills dir for everyone else (#95685)."""
return {"description": _skill_manage_description(_display_create_dir())}
SKILL_MANAGE_SCHEMA = {
"name": "skill_manage",
# ONE advertised call shape (memory-tool pattern): the call IS an operations
# array. The legacy flat shape (top-level action/name/content/...) is still
# ACCEPTED for old transcripts and staged-write replay, but not advertised.
"description": _skill_manage_description("the profile's skills.create_dir"),
"parameters": {
"type": "object",
"properties": {
@@ -881,7 +892,8 @@ from tools.registry import registry, tool_error
registry.register(
name="skill_manage", toolset="skills", schema=SKILL_MANAGE_SCHEMA, emoji="📝",
handler=lambda args, **kw: _skill_manage_from(
args, task_id=kw.get("task_id"), session_id=kw.get("session_id")))
args, task_id=kw.get("task_id"), session_id=kw.get("session_id")),
dynamic_schema_overrides=_skill_manage_schema_overrides)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----

View File

@@ -8,10 +8,14 @@ from __future__ import annotations
from typing import Any, Dict
from hermes_constants import hermes_home_key
DEFAULT_MAX_BYTES = 50_000 # terminal_tool.MAX_OUTPUT_CHARS
DEFAULT_MAX_LINES = 2000 # file_operations.MAX_LINES
DEFAULT_MAX_LINE_LENGTH = 2000 # file_operations.MAX_LINE_LENGTH
_cached_limits: dict | None = None # process-lifetime: no config.yaml re-read per tool call
# Keyed by profile home: the multiplexed gateway serves every profile from one process, so a
# single slot would hand the launch profile's limits to every other profile.
_cached_limits: Dict[str, Dict[str, int]] = {}
def _coerce_int(value: Any, default: int, minimum: int) -> int:
@@ -28,11 +32,12 @@ def _coerce_positive_int(value: Any, default: int) -> int:
def get_tool_output_limits() -> Dict[str, int]:
"""Resolved ``{max_bytes, max_lines, max_line_length}``; never raises. Cached for the
process — ``_reset_tool_output_limits_cache()`` forces a fresh read."""
global _cached_limits
if _cached_limits is not None:
return _cached_limits
"""Resolved ``{max_bytes, max_lines, max_line_length}``; never raises. Cached per profile
home for the process — ``_reset_tool_output_limits_cache()`` forces a fresh read."""
key = hermes_home_key()
cached = _cached_limits.get(key)
if cached is not None:
return cached
try:
from hermes_cli.config import load_config
cfg = load_config() or {}
@@ -41,18 +46,17 @@ def get_tool_output_limits() -> Dict[str, int]:
section = None
if not isinstance(section, dict):
section = {}
_cached_limits = {
_cached_limits[key] = limits = {
"max_bytes": _coerce_positive_int(section.get("max_bytes"), DEFAULT_MAX_BYTES),
"max_lines": _coerce_positive_int(section.get("max_lines"), DEFAULT_MAX_LINES),
"max_line_length": _coerce_positive_int(
section.get("max_line_length"), DEFAULT_MAX_LINE_LENGTH)}
return _cached_limits
return limits
def _reset_tool_output_limits_cache() -> None:
"""Reset the cached limits — for tests or after config hot-reload."""
global _cached_limits
_cached_limits = None
_cached_limits.clear()
def get_max_bytes() -> int: return get_tool_output_limits()["max_bytes"]

View File

@@ -19,6 +19,8 @@ import tempfile
from pathlib import Path
from typing import Callable, Dict, Any, List, Optional
import copy
from hermes_constants import display_hermes_home
logger = logging.getLogger(__name__)
@@ -522,6 +524,19 @@ def check_tts_requirements() -> bool:
# --- Registry ---
from tools.registry import registry, tool_error
def _output_path_description(home: str) -> str:
return f"Optional custom file path to save the audio. Defaults to {home}/audio_cache/<timestamp>.mp3"
def _tts_schema_overrides() -> dict:
"""Rebuild the ``output_path`` default hint from the ACTIVE profile at every get_definitions():
the multiplexed gateway serves every profile from one process, so a path baked in at import
would name the launch profile's home for everyone else (#95685)."""
params = copy.deepcopy(TTS_SCHEMA["parameters"])
params["properties"]["output_path"]["description"] = _output_path_description(display_hermes_home())
return {"parameters": params}
TTS_SCHEMA = {
"name": "text_to_speech",
"description": "Convert text to speech audio. Returns a MEDIA: path that the platform delivers as native audio. Compatible providers render as a voice bubble on Telegram; otherwise audio is sent as a regular attachment. In CLI mode, saves to ~/voice-memos/. Voice and provider are user-configured (built-in providers like edge/openai or custom command providers under tts.providers.<name>), not model-selected.",
@@ -534,7 +549,7 @@ TTS_SCHEMA = {
},
"output_path": {
"type": "string",
"description": f"Optional custom file path to save the audio. Defaults to {display_hermes_home()}/audio_cache/<timestamp>.mp3"
"description": _output_path_description("the profile HERMES_HOME")
},
"speed": {
"type": "number",
@@ -572,7 +587,8 @@ registry.register(
text=args.get("text", ""),
**{k: args.get(k) for k in ("output_path", "speed", "instructions", "provider")}),
check_fn=check_tts_requirements,
emoji="🔊")
emoji="🔊",
dynamic_schema_overrides=_tts_schema_overrides)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----