diff --git a/hermes_time.py b/hermes_time.py index 26bcea13de..b36a179b72 100644 --- a/hermes_time.py +++ b/hermes_time.py @@ -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: diff --git a/plugins/platforms/feishu/feishu_comment_rules.py b/plugins/platforms/feishu/feishu_comment_rules.py index f9fe10a859..27fa01fbcb 100644 --- a/plugins/platforms/feishu/feishu_comment_rules.py +++ b/plugins/platforms/feishu/feishu_comment_rules.py @@ -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 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). """ diff --git a/tests/gateway/test_feishu_comment_rules.py b/tests/gateway/test_feishu_comment_rules.py index 8c7b9660bc..1b3d4dac4d 100644 --- a/tests/gateway/test_feishu_comment_rules.py +++ b/tests/gateway/test_feishu_comment_rules.py @@ -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() diff --git a/tests/test_timezone.py b/tests/test_timezone.py index 7a729ad24e..c4dfa320a5 100644 --- a/tests/test_timezone.py +++ b/tests/test_timezone.py @@ -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 ): diff --git a/tests/tools/test_file_read_guards.py b/tests/tools/test_file_read_guards.py index 81e19832d1..d7ab382154 100644 --- a/tests/tools/test_file_read_guards.py +++ b/tests/tools/test_file_read_guards.py @@ -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 diff --git a/tests/tools/test_multiplex_tool_memo_scope.py b/tests/tools/test_multiplex_tool_memo_scope.py new file mode 100644 index 0000000000..16befc2c14 --- /dev/null +++ b/tests/tools/test_multiplex_tool_memo_scope.py @@ -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 diff --git a/tests/tools/test_terminal_truncation_spill.py b/tests/tools/test_terminal_truncation_spill.py index f14d0da533..6d313777d9 100644 --- a/tests/tools/test_terminal_truncation_spill.py +++ b/tests/tools/test_terminal_truncation_spill.py @@ -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 diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py index 587af898be..08917a9d64 100644 --- a/tools/browser_camofox.py +++ b/tools/browser_camofox.py @@ -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]: diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 7b33d54fd4..104bc7694d 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -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 diff --git a/tools/code_execution_env.py b/tools/code_execution_env.py index 846b331129..03af965084 100644 --- a/tools/code_execution_env.py +++ b/tools/code_execution_env.py @@ -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) diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index c7d77d23d5..2d29ead869 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -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]) diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 5970275057..df226847ef 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -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, ) diff --git a/tools/environments/modal.py b/tools/environments/modal.py index 94aa2de3aa..5229699572 100644 --- a/tools/environments/modal.py +++ b/tools/environments/modal.py @@ -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]: diff --git a/tools/environments/singularity.py b/tools/environments/singularity.py index 2f0bf5818b..963ccb611c 100644 --- a/tools/environments/singularity.py +++ b/tools/environments/singularity.py @@ -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: diff --git a/tools/file_tools.py b/tools/file_tools.py index 75e8663c50..8eb753245e 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -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]: diff --git a/tools/process_registry.py b/tools/process_registry.py index 33338c0b3d..6487ebecbc 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -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 diff --git a/tools/process_registry_checkpoint.py b/tools/process_registry_checkpoint.py index a128a8a640..74c9f22fff 100644 --- a/tools/process_registry_checkpoint.py +++ b/tools/process_registry_checkpoint.py @@ -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 diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index 81d64b909b..c69bdeca3e 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -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) ---- diff --git a/tools/tool_output_limits.py b/tools/tool_output_limits.py index 38e539c9fb..c909906444 100644 --- a/tools/tool_output_limits.py +++ b/tools/tool_output_limits.py @@ -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"] diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 50ac84b398..925311a396 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -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/.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.), 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/.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) ----