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:
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
89
tests/tools/test_multiplex_tool_memo_scope.py
Normal file
89
tests/tools/test_multiplex_tool_memo_scope.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ----
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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) ----
|
||||
|
||||
Reference in New Issue
Block a user