From 78a415b6bbdb51f2685902c9ec148a487f3663e1 Mon Sep 17 00:00:00 2001 From: CodeForgeNet Date: Tue, 16 Jun 2026 03:31:17 +0530 Subject: [PATCH 01/28] fix(file-tools): normalize MSYS paths in file tools to prevent ghost tree writes on Windows --- tests/tools/test_windows_native_support.py | 85 ++++++++++++++++++++++ tools/file_tools.py | 27 ++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_windows_native_support.py b/tests/tools/test_windows_native_support.py index 3abf5bf80f25e..67b886fc02a58 100644 --- a/tests/tools/test_windows_native_support.py +++ b/tests/tools/test_windows_native_support.py @@ -1007,3 +1007,88 @@ def test_launch_detached_profile_gateway_restart_outer_popen_has_access_denied_f "CreateProcess and retry without the breakaway bit, matching " "gateway_windows._spawn_detached's fallback pattern." ) + + +# --------------------------------------------------------------------------- +# file_tools MSYS path normalisation (issue #46876) +# --------------------------------------------------------------------------- + + +class TestFileToolsMsysPathNormalization: + """_normalize_msys_path in file_tools must translate /c/Users/... to + C:\\Users\\... on Windows, preventing writes to the ghost tree + C:\\c\\Users\\... that pathlib.Path.resolve() produces when given a + drive-less POSIX path on Windows (issue #46876).""" + + def test_posix_host_noop(self): + """On non-Windows the function must be a no-op for MSYS-style paths.""" + if sys.platform == "win32": + pytest.skip("POSIX no-op test only valid on non-Windows") + from tools.file_tools import _normalize_msys_path + assert _normalize_msys_path("/c/Users/foo") == "/c/Users/foo" + assert _normalize_msys_path("/home/user/project") == "/home/user/project" + assert _normalize_msys_path("relative/path") == "relative/path" + + def test_empty_string(self): + from tools.file_tools import _normalize_msys_path + assert _normalize_msys_path("") == "" + + def test_windows_drive_c(self, monkeypatch): + """Simulate Windows: /c/Users/foo → C:\\Users\\foo.""" + import tools.file_tools as ft + monkeypatch.setattr(ft, "_IS_WINDOWS", True) + assert ft._normalize_msys_path("/c/Users/MarkChristian/project") == r"C:\Users\MarkChristian\project" + assert ft._normalize_msys_path("/C/Users/MarkChristian/project") == r"C:\Users\MarkChristian\project" + + def test_windows_drive_d(self, monkeypatch): + import tools.file_tools as ft + monkeypatch.setattr(ft, "_IS_WINDOWS", True) + assert ft._normalize_msys_path("/d/workspace/repo") == r"D:\workspace\repo" + + def test_windows_bare_drive_root(self, monkeypatch): + """Bare drive root /c → C:\\.""" + import tools.file_tools as ft + monkeypatch.setattr(ft, "_IS_WINDOWS", True) + assert ft._normalize_msys_path("/c") == "C:\\" + + def test_windows_native_path_unchanged(self, monkeypatch): + """Already-native Windows path must pass through unchanged.""" + import tools.file_tools as ft + monkeypatch.setattr(ft, "_IS_WINDOWS", True) + assert ft._normalize_msys_path(r"C:\Users\foo") == r"C:\Users\foo" + assert ft._normalize_msys_path("C:/Users/foo") == "C:/Users/foo" + + def test_windows_non_drive_absolute_unchanged(self, monkeypatch): + """/etc/hosts is not an MSYS drive path — must not be translated.""" + import tools.file_tools as ft + monkeypatch.setattr(ft, "_IS_WINDOWS", True) + assert ft._normalize_msys_path("/etc/hosts") == "/etc/hosts" + + def test_normalize_called_in_sentinel_free_abs_cwd(self): + """Source check: _sentinel_free_abs_cwd must call _normalize_msys_path + so TERMINAL_CWD from config.yaml is translated on Windows.""" + root = Path(__file__).resolve().parents[2] + source = (root / "tools" / "file_tools.py").read_text(encoding="utf-8") + # The call must appear inside _sentinel_free_abs_cwd + fn_start = source.find("def _sentinel_free_abs_cwd(") + fn_end = source.find("\ndef ", fn_start + 1) + fn_body = source[fn_start:fn_end] + assert "_normalize_msys_path" in fn_body, ( + "_sentinel_free_abs_cwd must call _normalize_msys_path so " + "TERMINAL_CWD=/c/Users/... from config.yaml is translated to " + "C:\\Users\\... on Windows before the isabs check (issue #46876)." + ) + + def test_normalize_called_in_resolve_path_for_task(self): + """Source check: _resolve_path_for_task must call _normalize_msys_path + so agent-supplied /c/Users/... paths don't become ghost C:\\c\\Users\\...""" + root = Path(__file__).resolve().parents[2] + source = (root / "tools" / "file_tools.py").read_text(encoding="utf-8") + fn_start = source.find("def _resolve_path_for_task(") + fn_end = source.find("\ndef ", fn_start + 1) + fn_body = source[fn_start:fn_end] + assert "_normalize_msys_path" in fn_body, ( + "_resolve_path_for_task must call _normalize_msys_path so " + "write_file/patch don't silently route to the C:\\c\\... ghost " + "tree when the agent uses Git Bash paths (issue #46876)." + ) diff --git a/tools/file_tools.py b/tools/file_tools.py index 0eb7b2cb174aa..4eafc2f9a161f 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -5,6 +5,8 @@ import json import logging import os +import platform +import re import threading from pathlib import Path @@ -20,6 +22,27 @@ logger = logging.getLogger(__name__) +_IS_WINDOWS = platform.system() == "Windows" + + +def _normalize_msys_path(filepath: str) -> str: + """Translate a Git Bash / MSYS-style POSIX path (``/c/Users/x``) to the + native Windows form (``C:\\Users\\x``). + + No-op on non-Windows hosts or paths that are not in MSYS drive-letter form. + Idempotent — already-native Windows paths pass through unchanged. + Mirrors ``tools.environments.local._msys_to_windows_path`` so file tools + apply the same translation as the terminal backend (fixes #46876). + """ + if not _IS_WINDOWS or not filepath: + return filepath + m = re.match(r'^/([a-zA-Z])(/.*)?$', filepath) + if not m: + return filepath + drive = m.group(1).upper() + tail = (m.group(2) or "").replace('/', '\\') + return f"{drive}:{tail or chr(92)}" # chr(92) == backslash + _EXPECTED_WRITE_ERRNOS = {errno.EACCES, errno.EPERM, errno.EROFS} @@ -107,7 +130,7 @@ def _sentinel_free_abs_cwd(raw: str | None) -> str | None: raw = str(raw or "").strip() if raw.lower() in _TERMINAL_CWD_SENTINELS: return None - expanded = os.path.expanduser(raw) + expanded = os.path.expanduser(_normalize_msys_path(raw)) if not os.path.isabs(expanded): return None return expanded @@ -239,7 +262,7 @@ def _resolve_path_for_task(filepath: str, task_id: str = "default") -> Path: See :func:`_resolve_base_dir` for how the base is chosen. Absolute input paths are returned resolved-but-unanchored. """ - p = Path(filepath).expanduser() + p = Path(_normalize_msys_path(filepath)).expanduser() if p.is_absolute(): return p.resolve() return (_resolve_base_dir(task_id) / p).resolve() From 97bbe33c8117dfe0c2a539df1b6ed09be09bc87b Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 15 Jun 2026 14:35:15 -0400 Subject: [PATCH 02/28] fix(teams): package Microsoft Teams SDK as an installable extra (salvage #43945) (#46764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(teams): package Microsoft Teams SDK as an installable extra The Teams adapter imports the microsoft-teams-apps SDK, but it was never declared as a dependency, so source/local installs hit ImportError and the adapter silently reported the SDK as unavailable. Add a 'teams' extra (microsoft-teams-apps==2.0.13.4 + aiohttp) and document 'uv sync --extra teams'. Per the 2026-05-12 [all] policy, opt-in messaging-platform SDKs are NOT added to [all] (they would break every fresh install on a quarantined release); the teams extra is installed on demand like the other platform backends. Co-authored-by: rio-jeong * chore: map rio-jeong contributor email for attribution (#43945) * feat(teams): lazy-install the Teams SDK on demand (parity with other channels) The teams extra alone left Teams as the only messaging platform that wouldn't auto-install its SDK — every other channel (telegram, discord, slack, matrix, dingtalk, feishu) lazy-installs via tools.lazy_deps on first connect. Bring Teams to parity: - Add 'platform.teams' to LAZY_DEPS (microsoft-teams-apps + aiohttp). - Replace the passive 'check_teams_requirements = check_requirements' alias with a real lazy-installer that calls ensure_and_bind('platform.teams', ...), rebinding all Teams SDK globals on success (mirrors check_slack_requirements). - Call check_teams_requirements() at the top of TeamsAdapter.connect() so enabling Teams installs the SDK on demand. - Keep the passive check_requirements() as the registry check_fn so 'gateway status' probes never trigger a pip install. The 'teams' extra remains for packagers / explicit 'uv sync --extra teams'. Tests: rework the alias test into shortcircuit + lazy-install assertions, and update test_connect_fails_without_sdk to simulate an uninstallable SDK. --------- Co-authored-by: rio-jeong Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- plugins/platforms/teams/adapter.py | 74 +++++++++++++++++- pyproject.toml | 1 + scripts/release.py | 1 + tests/gateway/test_teams.py | 38 +++++++++- tools/lazy_deps.py | 5 ++ uv.lock | 87 +++++++++++++++++++++- website/docs/user-guide/messaging/teams.md | 9 +++ 7 files changed, 211 insertions(+), 4 deletions(-) diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py index a7d024419e15d..f8175a6a6214c 100644 --- a/plugins/platforms/teams/adapter.py +++ b/plugins/platforms/teams/adapter.py @@ -617,7 +617,74 @@ async def _standalone_send( # Keep the old name as an alias so existing test imports don't break. -check_teams_requirements = check_requirements +# NOTE: ``check_requirements`` is the PASSIVE probe (used as the registry +# ``check_fn`` and by ``gateway status``) — it must never trigger a pip +# install. ``check_teams_requirements`` is the ACTIVE lazy-installer called +# from ``connect()``; it installs ``platform.teams`` on demand and rebinds the +# SDK globals, mirroring ``check_slack_requirements`` in gateway/platforms/slack.py. +def check_teams_requirements() -> bool: + """Ensure the Teams SDK is importable, lazy-installing it on first use. + + Lazy-installs ``microsoft-teams-apps`` via + ``tools.lazy_deps.ensure("platform.teams")`` if not present, then rebinds + all module-level SDK globals on success. Returns True once the SDK (and + aiohttp) are importable, False if they couldn't be installed/imported. + """ + if TEAMS_SDK_AVAILABLE and AIOHTTP_AVAILABLE: + return True + + def _import() -> dict: + from aiohttp import web as _web + from microsoft_teams.apps import App, ActivityContext + from microsoft_teams.common.http.client import ClientOptions + from microsoft_teams.api import MessageActivity, ConversationReference + from microsoft_teams.api.activities.typing import TypingActivityInput + from microsoft_teams.api.activities.invoke.adaptive_card import ( + AdaptiveCardInvokeActivity, + ) + from microsoft_teams.api.models.adaptive_card import ( + AdaptiveCardActionCardResponse, + AdaptiveCardActionMessageResponse, + ) + from microsoft_teams.api.models.invoke_response import ( + InvokeResponse, + AdaptiveCardInvokeResponse, + ) + from microsoft_teams.apps.http.adapter import ( + HttpMethod, + HttpRequest, + HttpResponse, + HttpRouteHandler, + ) + from microsoft_teams.cards import AdaptiveCard, ExecuteAction, TextBlock + + return { + "web": _web, + "AIOHTTP_AVAILABLE": True, + "App": App, + "ActivityContext": ActivityContext, + "ClientOptions": ClientOptions, + "MessageActivity": MessageActivity, + "ConversationReference": ConversationReference, + "TypingActivityInput": TypingActivityInput, + "AdaptiveCardInvokeActivity": AdaptiveCardInvokeActivity, + "AdaptiveCardActionCardResponse": AdaptiveCardActionCardResponse, + "AdaptiveCardActionMessageResponse": AdaptiveCardActionMessageResponse, + "InvokeResponse": InvokeResponse, + "AdaptiveCardInvokeResponse": AdaptiveCardInvokeResponse, + "HttpMethod": HttpMethod, + "HttpRequest": HttpRequest, + "HttpResponse": HttpResponse, + "HttpRouteHandler": HttpRouteHandler, + "AdaptiveCard": AdaptiveCard, + "ExecuteAction": ExecuteAction, + "TextBlock": TextBlock, + "TEAMS_SDK_AVAILABLE": True, + } + + from tools.lazy_deps import ensure_and_bind + + return ensure_and_bind("platform.teams", _import, globals(), prompt=False) class TeamsAdapter(BasePlatformAdapter): @@ -642,10 +709,13 @@ def __init__(self, config: PlatformConfig): self._conv_refs: Dict[str, Any] = {} async def connect(self) -> bool: + # Lazy-install the Teams SDK on demand (parity with Slack/Discord/etc.), + # then re-check the module globals it rebinds. + check_teams_requirements() if not TEAMS_SDK_AVAILABLE: self._set_fatal_error( "MISSING_SDK", - "microsoft-teams-apps not installed. Run: pip install microsoft-teams-apps", + "microsoft-teams-apps could not be installed. Run: pip install microsoft-teams-apps", retryable=False, ) return False diff --git a/pyproject.toml b/pyproject.toml index 9520d496107b4..4a2ab1c6b7bcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -179,6 +179,7 @@ mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710 nemo-relay = ["nemo-relay==0.3"] homeassistant = ["aiohttp==3.13.4"] sms = ["aiohttp==3.13.4"] +teams = ["microsoft-teams-apps==2.0.13.4", "aiohttp==3.13.4"] # Computer use — macOS background desktop control via cua-driver (MCP stdio). # The cua-driver binary itself is installed via `hermes tools` post-setup # (curl install script); this extra just pins the MCP client used to talk diff --git a/scripts/release.py b/scripts/release.py index 5058e406cd3b9..318c8c82d2d48 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "rio.jeong@thebytesize.ai": "rio-jeong", "yehaotian@xuanshudeMac-mini.local": "ArcanePivot", "dbeyer7@gmail.com": "benegessarit", "kenmege@yahoo.com": "Kenmege", diff --git a/tests/gateway/test_teams.py b/tests/gateway/test_teams.py index d4f56104a6a0f..1ae10593cc6ab 100644 --- a/tests/gateway/test_teams.py +++ b/tests/gateway/test_teams.py @@ -211,10 +211,39 @@ def test_returns_true_when_deps_available(self, monkeypatch): monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", True) assert check_requirements() is True - def test_alias_matches(self, monkeypatch): + def test_check_teams_requirements_shortcircuits_when_present(self, monkeypatch): + # When the SDK + aiohttp are already importable, the active lazy- + # installer returns True immediately without attempting an install. monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", True) monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", True) + called = {"ensure_and_bind": 0} + + def _fake_ensure_and_bind(*_args, **_kwargs): + called["ensure_and_bind"] += 1 + return True + + monkeypatch.setattr( + "tools.lazy_deps.ensure_and_bind", _fake_ensure_and_bind + ) + assert check_teams_requirements() is True + assert called["ensure_and_bind"] == 0 + + def test_check_teams_requirements_lazy_installs_when_missing(self, monkeypatch): + # When deps are missing, the active installer delegates to + # ensure_and_bind("platform.teams", ...) — parity with Slack/Discord. + monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", False) + monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", False) + seen = {} + + def _fake_ensure_and_bind(feature, importer, target_globals, **kwargs): + seen["feature"] = feature + return True + + monkeypatch.setattr( + "tools.lazy_deps.ensure_and_bind", _fake_ensure_and_bind + ) assert check_teams_requirements() is True + assert seen["feature"] == "platform.teams" def test_validate_config_with_env(self, monkeypatch): monkeypatch.setenv("TEAMS_CLIENT_ID", "test-id") @@ -371,6 +400,13 @@ class TestTeamsConnect: @pytest.mark.anyio async def test_connect_fails_without_sdk(self, monkeypatch): monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", False) + # Simulate the SDK being unavailable AND not installable (offline / + # locked-down env): the lazy-installer can't rebind the globals, so + # TEAMS_SDK_AVAILABLE stays False and connect() must fail. + monkeypatch.setattr( + "tools.lazy_deps.ensure_and_bind", + lambda *_a, **_k: False, + ) adapter = TeamsAdapter(_make_config( client_id="id", client_secret="secret", tenant_id="tenant", )) diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index e4b0a9a57f0e9..cb123caaf9f3e 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -152,6 +152,11 @@ # defusedxml only; aiohttp/httpx are core dependencies of every messaging # adapter and ship via `platform.discord` / `platform.slack` / etc. "platform.wecom_callback": ("defusedxml==0.7.1",), + # Microsoft Teams adapter — microsoft-teams-apps pulls a heavy tree + # (microsoft-teams-api/cards/common, dependency-injector, msal). Lazy- + # installed on demand like every other messaging platform; also exposed + # as the `teams` extra in pyproject for packagers / explicit installs. + "platform.teams": ("microsoft-teams-apps==2.0.13.4", "aiohttp==3.13.4"), # ─── Terminal backends ───────────────────────────────────────────────── "terminal.modal": ("modal==1.3.4",), diff --git a/uv.lock b/uv.lock index 8694951168300..385cffe0dd547 100644 --- a/uv.lock +++ b/uv.lock @@ -960,6 +960,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "dependency-injector" +version = "4.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/be/26bb530d06618fb0bb34244d46b0d0ccc53d0974e680d8653f1b1b313a0e/dependency_injector-4.49.0.tar.gz", hash = "sha256:17a04dbfaa8159f1dc068fc26bc2fa0af9774cdd87f99e3b61bd74c9e7171589", size = 1168930, upload-time = "2026-03-22T21:20:05.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/5d/cc49fb34e0c03aa56d7583de00e2f8f5aa1b8a878b695e970dcdb751a477/dependency_injector-4.49.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:9690192fd5aed07f21dfdfae07696fef12c68bf98e4c0e1af8f8128b255a74a7", size = 1769395, upload-time = "2026-03-22T21:19:14.163Z" }, + { url = "https://files.pythonhosted.org/packages/7f/97/b3b144c96e1f7fff0a7e2e83eb0767bd23b6bacffd0ac8cff397d350e94d/dependency_injector-4.49.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f91f2a191bdb17bd3068f32fe65f04128bc162c6237ea554c117b303c22aaabb", size = 1852089, upload-time = "2026-03-22T21:19:16.354Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e7/33061f427bcb56c8936d5db464d757d926bf752a874683fb64b2ee225463/dependency_injector-4.49.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:733c0d88b26be17a48e5741cc3e3956080112e40c07a38ff38e99dfa772f9772", size = 1765608, upload-time = "2026-03-22T21:19:19.217Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4d/2751a6c055de4a200d65af297ecd926d6b6107f66f3849e8122928abf461/dependency_injector-4.49.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:45720b30a2a3df6e5e2320e242f6dd94540ba27c3da57cafdc37fdeec59d5ce3", size = 1746555, upload-time = "2026-03-22T21:19:21.231Z" }, + { url = "https://files.pythonhosted.org/packages/02/6f/f74fee9629528f0879295b9f89a5c751d3ad931eca0c78407f715e5472a6/dependency_injector-4.49.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b5d2f1be2dc971db47b1305a83b5a8c24d0eba7fb4cea7845679f9c9f24a0a9", size = 1843223, upload-time = "2026-03-22T21:19:23.356Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f0/45948c7c933f063039a44afb4bd61747a7bafd50693e6ccdc972fac0839c/dependency_injector-4.49.0-cp310-abi3-win32.whl", hash = "sha256:0593c8aaade651a5a88ff8ba1271a8364773e76d3aa2efbeacc3be4969cafd1c", size = 1546172, upload-time = "2026-03-22T21:19:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b5/1d8e5627137cb9a6812ecaa468eaf39154f6605c5088da4749e5a8579483/dependency_injector-4.49.0-cp310-abi3-win_amd64.whl", hash = "sha256:fa4b587158b0d65a1f9681ca648da3f9bf90f312f68c2f2e73cc58296ec2bf45", size = 1674743, upload-time = "2026-03-22T21:19:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/92/35/ca21ab897fc193dcdbad1f856361e7614b8e2b69f9f9351e9a87a3c58e51/dependency_injector-4.49.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6c4b49df30f13f5e4361719b21c79445db11869a7a00d80a0486c03fd764ba8f", size = 1744444, upload-time = "2026-03-22T21:19:57.332Z" }, + { url = "https://files.pythonhosted.org/packages/25/44/d108aeee8f2edd3e725ac0e32d16e4339a034a07da9ddaf07f772f425140/dependency_injector-4.49.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b0c637ba230e390631da13bb80c955a9f85487f78c9772c0f6a3b50bfbff3a6", size = 1822320, upload-time = "2026-03-22T21:19:59.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/32/6243ef32c384dda156b053c3df5c8b6c3ac42250ec089a09915f015d38a1/dependency_injector-4.49.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5857b2672512654110dd0371fa965b98255e2f0507dd4732a066767b72e23c4", size = 1741215, upload-time = "2026-03-22T21:20:01.523Z" }, + { url = "https://files.pythonhosted.org/packages/b9/52/a1957d4ef87a52c13f2b790c1cc5fae17eb385fbe2e978c7fd8c1ebb4ea9/dependency_injector-4.49.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8ffa2ac9297446f73bd28ada81aadf4494a52d869159d58923435bcd88b5ef60", size = 1652017, upload-time = "2026-03-22T21:20:03.653Z" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -1542,6 +1564,10 @@ slack = [ sms = [ { name = "aiohttp" }, ] +teams = [ + { name = "aiohttp" }, + { name = "microsoft-teams-apps" }, +] termux = [ { name = "agent-client-protocol" }, { name = "honcho-ai" }, @@ -1591,6 +1617,7 @@ requires-dist = [ { name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.13.4" }, { name = "aiohttp", marker = "extra == 'slack'", specifier = "==3.13.4" }, { name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.13.4" }, + { name = "aiohttp", marker = "extra == 'teams'", specifier = "==3.13.4" }, { name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = "==0.11.0" }, { name = "aiosqlite", marker = "extra == 'matrix'", specifier = "==0.22.1" }, { name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = "==2.2.42" }, @@ -1649,6 +1676,7 @@ requires-dist = [ { name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.26.0" }, { name = "mcp", marker = "extra == 'dev'", specifier = "==1.26.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = "==1.26.0" }, + { name = "microsoft-teams-apps", marker = "extra == 'teams'", specifier = "==2.0.13.4" }, { name = "mistralai", marker = "extra == 'mistral'", specifier = "==2.4.8" }, { name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" }, { name = "nemo-relay", marker = "extra == 'nemo-relay'", specifier = "==0.3" }, @@ -1697,7 +1725,7 @@ requires-dist = [ { name = "websockets", specifier = "==15.0.1" }, { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "computer-use", "acp", "mistral", "bedrock", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] +provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] [[package]] name = "hf-xet" @@ -2176,6 +2204,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "microsoft-teams-api" +version = "2.0.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "microsoft-teams-cards" }, + { name = "microsoft-teams-common" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/7f/dc1995f72a8d23e723b168db20bac67b819ef2fa734bc23f63bc8086c41b/microsoft_teams_api-2.0.13.4.tar.gz", hash = "sha256:d16f88ae90f65bcce83ede9ecc57773f7b1a19cbecde63be624b586b59e34fc9", size = 51779, upload-time = "2026-06-08T19:24:02.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/15/e1a1369a22c265b52da3ac4b3ee67b5c02911300db045894868bd7be932f/microsoft_teams_api-2.0.13.4-py3-none-any.whl", hash = "sha256:be52ef7765ea5851e0982de1ff6b1192869c85fc74e890ae20029bd99064b532", size = 149825, upload-time = "2026-06-08T19:24:13.202Z" }, +] + +[[package]] +name = "microsoft-teams-apps" +version = "2.0.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "dependency-injector" }, + { name = "fastapi" }, + { name = "microsoft-teams-api" }, + { name = "microsoft-teams-common" }, + { name = "msal" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-dotenv" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/0a/733f05f8decee2da6e53ee38757e742520ae56363bc2d006b309cdbf9cfe/microsoft_teams_apps-2.0.13.4.tar.gz", hash = "sha256:d0b12e5e82024cffd3739b329b098b98a08803753eb5484bf96dbb6ce1237e04", size = 91366, upload-time = "2026-06-08T19:24:04.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/d4/3c4205258642035d160c09f598a302260776dcb6d5bdf659eea7c6066d5e/microsoft_teams_apps-2.0.13.4-py3-none-any.whl", hash = "sha256:db16f714ec658b592929c6386a29792e90bb73840732f8ae65a198cda1fea96c", size = 71406, upload-time = "2026-06-08T19:24:15.034Z" }, +] + +[[package]] +name = "microsoft-teams-cards" +version = "2.0.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7f/cce9633f635d9e1b2318ce2146a804a14a46c9e34e855c3784beb8ab39b3/microsoft_teams_cards-2.0.13.4.tar.gz", hash = "sha256:de54956a2afbbcf187f2531459967515b4f4743fa784bd0f454eaff1ac675c90", size = 28108, upload-time = "2026-06-08T19:24:07.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/09/95cad44d4417e33df11a15c82ca1bde442c1f1f77396f936f18896f116c1/microsoft_teams_cards-2.0.13.4-py3-none-any.whl", hash = "sha256:b8b887466c8144675ff5704064daf05ec3ebdf4d322658ab9a25bfc1373d7909", size = 29617, upload-time = "2026-06-08T19:24:17.373Z" }, +] + +[[package]] +name = "microsoft-teams-common" +version = "2.0.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/f1/a32821cfdde6c0d33a1e4022492a2211af670a81ec1fab727c49cddd4f7a/microsoft_teams_common-2.0.13.4.tar.gz", hash = "sha256:ed3175316f77f083a500da0a84ddf53ac31c6de008a252f0cfd86bdb70120bf3", size = 11122, upload-time = "2026-06-08T19:24:09.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/04/859b3d7fadd1d61ab581f79afb6125c16c60cecf2a2e6bbb2ebbcfd34f80/microsoft_teams_common-2.0.13.4-py3-none-any.whl", hash = "sha256:19524ec75587d797d07c5a78e9b72921b6d58f33d39512ca2d33468160fd0d82", size = 16588, upload-time = "2026-06-08T19:24:18.325Z" }, +] + [[package]] name = "mistralai" version = "2.4.8" diff --git a/website/docs/user-guide/messaging/teams.md b/website/docs/user-guide/messaging/teams.md index ae30d4a5856b6..bc59ca342ed11 100644 --- a/website/docs/user-guide/messaging/teams.md +++ b/website/docs/user-guide/messaging/teams.md @@ -24,6 +24,15 @@ Teams delivers @mentions as regular messages with `BotName` tags, which --- +For source or local installs, include the Teams extra so the bundled adapter can +import the Microsoft Teams SDK: + +```bash +uv sync --extra teams +# or, for editable installs: +uv pip install -e ".[teams]" +``` + ## Step 1: Install the Teams CLI The `@microsoft/teams.cli` automates bot registration — no Azure portal needed. From 62ee79b2d6c04a29e8bbba7f35c3de7abd55b138 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 15 Jun 2026 15:02:24 -0400 Subject: [PATCH 03/28] docs: point desktop download links to site root (deprecate /desktop) (#46795) The /desktop page is deprecated and redirects to the home page. The landing page for the desktop app is now simply https://hermes-agent.nousresearch.com/. Update all docs and the Docusaurus nav/footer links accordingly. Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- apps/desktop/README.md | 2 +- website/docs/getting-started/installation.md | 2 +- website/docs/getting-started/quickstart.md | 2 +- website/docs/guides/run-nemotron-3-ultra-free.md | 2 +- website/docs/index.mdx | 4 ++-- website/docusaurus.config.ts | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 301b094592f40..17d1cacee5b9e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -34,7 +34,7 @@ It builds and launches the GUI against your existing install — same config, ke ### Prebuilt installers -Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/desktop). +Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/). --- diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index 09884fa831e39..2cef841fe5f20 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -10,7 +10,7 @@ Get Hermes Agent up and running in under two minutes! ## Quick Install ### With the Hermes Desktop installer on macOS or Windows (recommended) -To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. +To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/) from our website and run it. ### Without Hermes Desktop: For a command-line only install without Hermes Desktop, run: diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 04a63226648ed..630df6e2938ce 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -48,7 +48,7 @@ Pick the row that matches your goal: ## 1. Install Hermes Agent ### With the Hermes Desktop installer on macOS or Windows (recommended) -To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. +To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/) from our website and run it. ### Without Hermes Desktop: For a command-line only install without Hermes Desktop, run: diff --git a/website/docs/guides/run-nemotron-3-ultra-free.md b/website/docs/guides/run-nemotron-3-ultra-free.md index 0192fe105aa22..f50ec0f594e69 100644 --- a/website/docs/guides/run-nemotron-3-ultra-free.md +++ b/website/docs/guides/run-nemotron-3-ultra-free.md @@ -20,7 +20,7 @@ The simplest path: a one-click installer with a guided, point-and-click setup. N ### 1. Download and install -[Download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) for macOS or Windows, then open it. On first launch it finishes setting itself up (usually under a minute). +[Download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/) for macOS or Windows, then open it. On first launch it finishes setting itself up (usually under a minute). ### 2. Connect Nous Portal diff --git a/website/docs/index.mdx b/website/docs/index.mdx index ce7effcbf757d..ea4499f91e692 100644 --- a/website/docs/index.mdx +++ b/website/docs/index.mdx @@ -36,7 +36,7 @@ The self-improving AI agent built by [Nous Research](https://nousresearch.com). Get Started → Date: Thu, 4 Jun 2026 11:22:58 +0800 Subject: [PATCH 04/28] fix(desktop): restore Electron binary before macOS pack rename (salvage #38673) electron-builder 26.8.x can stage an Electron.app without its Contents/MacOS/Electron binary, then fail renaming it to Hermes: ENOENT: no such file or directory, rename .../MacOS/Electron -> .../MacOS/Hermes This breaks `npm run pack` and the installer desktop stage before a launchable Hermes.app exists. - Point build.electronDist at the already-installed Electron dist so electron-builder reuses it instead of re-unpacking from cache. - Add a darwin-only prebuilder patch that restores the missing main binary from the runtime dist before the rename. Idempotent (marker guard), soft-fails on shape mismatch, survives node_modules reinstall. Co-authored-by: ChasLui --- apps/desktop/package.json | 2 + .../patch-electron-builder-mac-binary.cjs | 59 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 apps/desktop/scripts/patch-electron-builder-mac-binary.cjs diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 52be586f013d7..ebc9293668ad2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,6 +20,7 @@ "start": "npm run build && electron .", "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild", "postbuild": "node scripts/assert-dist-built.cjs", + "prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs", "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder", "pack": "npm run build && npm run builder -- --dir", "dist": "npm run build && npm run builder", @@ -134,6 +135,7 @@ }, "build": { "electronVersion": "40.9.3", + "electronDist": "../../node_modules/electron/dist", "appId": "com.nousresearch.hermes", "productName": "Hermes", "executableName": "Hermes", diff --git a/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs b/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs new file mode 100644 index 0000000000000..38315b9c65c25 --- /dev/null +++ b/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs @@ -0,0 +1,59 @@ +const fs = require('node:fs') +const path = require('node:path') + +if (process.platform !== 'darwin') { + process.exit(0) +} + +const desktopRoot = path.resolve(__dirname, '..') +const repoRoot = path.resolve(desktopRoot, '..', '..') +const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js') + +const marker = 'hermes-macos-electron-binary-fallback' +const needle = ` await Promise.all([ + doRename(path.join(contentsPath, "MacOS"), electronBranding.productName, appPlist.CFBundleExecutable), + (0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")), + (0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")), + ]);` +const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes copy + // Electron.app without its main MacOS/Electron binary before this rename. + // Restore it from the installed Electron runtime so local desktop installs + // do not fail with ENOENT during macOS arm64 packaging. + const macosDir = path.join(contentsPath, "MacOS"); + const bundledElectronBinary = path.join(macosDir, electronBranding.productName); + if (!fs.existsSync(bundledElectronBinary)) { + const candidates = [ + path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName), + path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName), + ]; + const sourceBinary = candidates.find(candidate => fs.existsSync(candidate)); + if (sourceBinary == null) { + throw new Error("Electron binary missing from packaged app and Electron runtime: " + bundledElectronBinary); + } + await (0, promises_1.copyFile)(sourceBinary, bundledElectronBinary); + await (0, promises_1.chmod)(bundledElectronBinary, 0o755); + } + await Promise.all([ + doRename(macosDir, electronBranding.productName, appPlist.CFBundleExecutable), + (0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")), + (0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")), + ]);` + +if (!fs.existsSync(electronMacPath)) { + console.warn(`[patch-electron-builder] skipped: ${electronMacPath} not found`) + process.exit(0) +} + +const source = fs.readFileSync(electronMacPath, 'utf8') +if (source.includes(marker)) { + console.log('[patch-electron-builder] macOS Electron binary fallback already applied') + process.exit(0) +} + +if (!source.includes(needle)) { + console.warn('[patch-electron-builder] skipped: expected electronMac.js shape not found') + process.exit(0) +} + +fs.writeFileSync(electronMacPath, source.replace(needle, replacement)) +console.log('[patch-electron-builder] applied macOS Electron binary fallback') From 05bd0fe078413b45a011bdbfe0c24b1655949fc0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 15 Jun 2026 13:53:23 -0500 Subject: [PATCH 05/28] chore: map salvaged contributor email for attribution (#38673) --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 318c8c82d2d48..cdebc8e10af5e 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "chaslui@outlook.com": "ChasLui", "rio.jeong@thebytesize.ai": "rio-jeong", "yehaotian@xuanshudeMac-mini.local": "ArcanePivot", "dbeyer7@gmail.com": "benegessarit", From a8626ace5dab1494245c44a3a8cb88f0425e38fd Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sun, 31 May 2026 00:18:45 +0800 Subject: [PATCH 06/28] fix(doctor): recognize nvidia as vendor-slug-accepting provider NVIDIA NIM API uses vendor-prefixed model IDs (e.g. qwen/qwen3.5-122b-a10b, nvidia/nemotron-3-super-120b-a12b). The doctor command incorrectly warns that vendor-prefixed slugs belong to aggregators like openrouter when nvidia is the configured provider. Add 'nvidia' to the providers_accepting_vendor_slugs set so doctor no longer raises false-positive warnings for valid NVIDIA NIM configurations. Fixes #35425 --- hermes_cli/doctor.py | 1 + tests/hermes_cli/test_doctor.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 79c41b03f15ba..127adefb39c4e 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -796,6 +796,7 @@ def run_doctor(args): "huggingface", "lmstudio", "nous", + "nvidia", } provider_accepts_vendor_slug = ( provider_policy_id in providers_accepting_vendor_slugs diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index c9b2dad06264b..ba2032b8efa50 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -493,6 +493,7 @@ def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(mon ("opencode-zen", "anthropic/claude-sonnet-4.6"), ("kilocode", "anthropic/claude-sonnet-4.6"), ("kimi-coding", "kimi-k2"), + ("nvidia", "qwen/qwen3.5-122b-a10b"), ], ) def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases( @@ -533,7 +534,7 @@ def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases( out = buf.getvalue() assert f"model.provider '{provider}' is not a recognised provider" not in out assert f"model.provider '{provider}' is unknown" not in out - if provider in {"opencode-zen", "kilocode"}: + if provider in {"opencode-zen", "kilocode", "nvidia"}: assert ( f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'" not in out From 012efbfa5844594795ee2a97808273db399f829f Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sun, 14 Jun 2026 13:03:59 +0800 Subject: [PATCH 07/28] fix(inventory): deduplicate models between user-defined and aggregator providers When a user-defined provider (e.g. litellm-proxy) and an aggregator (e.g. openrouter) both advertise the same model name, the Desktop/TUI model picker would show the model under both groups. Selecting it from the aggregator row silently set model.provider to the aggregator, breaking calls because the aggregator doesn't actually serve that model ID. Fix: after list_authenticated_providers() returns, collect all models from user-defined provider rows and filter them out of aggregator rows. Uses is_aggregator() from hermes_cli/providers.py to identify aggregators. Case-insensitive matching. Fixes #45954 --- hermes_cli/inventory.py | 30 +++++++ tests/hermes_cli/test_inventory.py | 124 +++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 48fc4e928d185..43d3150ccdb90 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -157,6 +157,36 @@ def build_models_payload( max_models=max_models, ) + # --- Deduplicate: remove models from aggregators that overlap with + # user-defined providers. When a local proxy (e.g. litellm-proxy) + # serves a model whose name also appears in an aggregator's curated + # catalog, the picker would show the model under both providers. + # Selecting it from the aggregator row sets model.provider to the + # aggregator (e.g. openrouter) instead of the user's proxy — silently + # breaking the call. Filtering at the payload level keeps the + # aggregator rows honest: they only show models the user can't get + # from a more-specific provider. (#45954) + try: + from hermes_cli.providers import is_aggregator as _is_aggregator + except Exception: + _is_aggregator = None # type: ignore[assignment] + + if _is_aggregator is not None: + user_models: set[str] = set() + for row in rows: + if row.get("is_user_defined"): + user_models.update(m.lower() for m in (row.get("models") or [])) + if user_models: + for row in rows: + slug = row.get("slug", "") + if not _is_aggregator(slug): + continue + original = row.get("models") or [] + filtered = [m for m in original if m.lower() not in user_models] + if len(filtered) < len(original): + row["models"] = filtered + row["total_models"] = len(filtered) + if include_unconfigured: rows = list(rows) + _append_unconfigured_rows(rows, ctx) if picker_hints: diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index e51c62a2701d1..e81288f9ab1a9 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -482,3 +482,127 @@ def test_payload_shape_compatible_with_modelpickerdialog_frontend(): for row in payload["providers"]: missing = required_keys - row.keys() assert not missing, f"row {row['slug']} missing keys: {missing}" + + +# ─── Aggregator dedup (issue #45954) ─────────────────────────────────── + + +def _user_provider_row(slug: str, models: list[str]) -> dict: + return { + "slug": slug, + "name": slug.title(), + "models": models, + "total_models": len(models), + "is_current": False, + "is_user_defined": True, + "source": "user-config", + } + + +def _aggregator_row(slug: str, models: list[str]) -> dict: + return { + "slug": slug, + "name": slug.title(), + "models": models, + "total_models": len(models), + "is_current": False, + "is_user_defined": False, + "source": "built-in", + } + + +def test_aggregator_dedup_removes_overlapping_models(): + """Models served by a user-defined provider are removed from + aggregator rows so the picker doesn't show them under the wrong + provider. (#45954)""" + rows = [ + _user_provider_row("litellm-proxy", [ + "nvidia/nim/minimax-m3", + "nvidia/nim/kimi-k2.6", + ]), + _aggregator_row("openrouter", [ + "minimax/minimax-m3", + "nvidia/nim/minimax-m3", # overlaps with litellm-proxy + "anthropic/claude-sonnet-4.6", + ]), + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + + or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter") + proxy_row = next(r for r in payload["providers"] if r["slug"] == "litellm-proxy") + + # User-defined provider keeps all its models + assert proxy_row["models"] == ["nvidia/nim/minimax-m3", "nvidia/nim/kimi-k2.6"] + + # Aggregator lost the overlapping model but kept the rest + assert "nvidia/nim/minimax-m3" not in or_row["models"] + assert "minimax/minimax-m3" in or_row["models"] + assert "anthropic/claude-sonnet-4.6" in or_row["models"] + assert or_row["total_models"] == 2 + + +def test_aggregator_dedup_case_insensitive(): + """Dedup uses case-insensitive matching. (#45954)""" + rows = [ + _user_provider_row("my-proxy", ["NVIDIA/NIM/MiniMax-M3"]), + _aggregator_row("openrouter", ["nvidia/nim/minimax-m3", "other/model"]), + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + + or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter") + assert "nvidia/nim/minimax-m3" not in or_row["models"] + assert or_row["total_models"] == 1 + + +def test_aggregator_dedup_no_overlap_unchanged(): + """When there's no overlap, aggregator models are untouched. (#45954)""" + rows = [ + _user_provider_row("litellm-proxy", ["custom/model-a"]), + _aggregator_row("openrouter", ["anthropic/claude-sonnet-4.6"]), + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + + or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter") + assert or_row["models"] == ["anthropic/claude-sonnet-4.6"] + assert or_row["total_models"] == 1 + + +def test_aggregator_dedup_no_user_providers_unchanged(): + """When there are no user-defined providers, nothing is filtered. + (#45954)""" + rows = [ + _aggregator_row("openrouter", [ + "nvidia/nim/minimax-m3", + "anthropic/claude-sonnet-4.6", + ]), + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + + or_row = payload["providers"][0] + assert len(or_row["models"]) == 2 + + +def test_aggregator_dedup_multiple_user_providers(): + """Models from all user-defined providers are excluded from aggregators. + (#45954)""" + rows = [ + _user_provider_row("proxy-a", ["model-x"]), + _user_provider_row("proxy-b", ["model-y"]), + _aggregator_row("openrouter", ["model-x", "model-y", "model-z"]), + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + + or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter") + assert or_row["models"] == ["model-z"] + assert or_row["total_models"] == 1 + From 2b717c8466ba4a497d289821e7e597a53c42a4e9 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Mon, 15 Jun 2026 19:09:49 +0700 Subject: [PATCH 08/28] fix(dump): report effective terminal backend in `hermes debug` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `terminal.backend` in config.yaml is bridged to the TERMINAL_ENV env var, but a TERMINAL_ENV set in .env / the shell overrides config and is what terminal_tool actually uses. The dump printed only the config value, so a user whose agent was jailed in a docker/podman sandbox via a stale TERMINAL_ENV still saw `terminal: local` — hiding the real cause. Report the effective backend and flag when TERMINAL_ENV overrides config.yaml. --- hermes_cli/dump.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/hermes_cli/dump.py b/hermes_cli/dump.py index 16d6f6069f95f..239a6994b612e 100644 --- a/hermes_cli/dump.py +++ b/hermes_cli/dump.py @@ -252,9 +252,24 @@ def run_dump(args): except Exception: profile = "(default)" - # Terminal backend + # Terminal backend — report the EFFECTIVE backend, not just config.yaml. + # ``terminal.backend`` in config.yaml is bridged to the TERMINAL_ENV env var, + # but a TERMINAL_ENV set directly in .env / the shell overrides config and is + # what terminal_tool actually uses (tools/terminal_tool.py reads TERMINAL_ENV). + # Reporting only the config value hides that override and sends users chasing + # the wrong cause when the agent runs in a docker/podman sandbox even though + # config says "local" (and vice-versa). run_dump() has already loaded .env, + # so os.environ reflects the real override here. terminal_cfg = config.get("terminal", {}) - backend = terminal_cfg.get("backend", "local") + config_backend = terminal_cfg.get("backend", "local") + env_backend = (os.environ.get("TERMINAL_ENV") or "").strip().lower() + if env_backend and env_backend != str(config_backend).strip().lower(): + backend = ( + f"{env_backend} (TERMINAL_ENV overrides config.yaml " + f"terminal.backend={config_backend})" + ) + else: + backend = config_backend # OpenAI SDK version try: From f608c7ec86a98a3441d36f5f4904d40b080c9a0e Mon Sep 17 00:00:00 2001 From: xxxigm Date: Mon, 15 Jun 2026 19:09:55 +0700 Subject: [PATCH 09/28] test(dump): cover terminal backend override reporting Verifies `hermes debug` surfaces a TERMINAL_ENV override of terminal.backend, reports the config value when no override is present, and emits no spurious note when env and config agree. --- .../hermes_cli/test_dump_terminal_backend.py | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/hermes_cli/test_dump_terminal_backend.py diff --git a/tests/hermes_cli/test_dump_terminal_backend.py b/tests/hermes_cli/test_dump_terminal_backend.py new file mode 100644 index 0000000000000..46847a8d17b3b --- /dev/null +++ b/tests/hermes_cli/test_dump_terminal_backend.py @@ -0,0 +1,80 @@ +"""`hermes debug` must report the EFFECTIVE terminal backend. + +``terminal.backend`` in config.yaml is bridged to the ``TERMINAL_ENV`` env var, +but a ``TERMINAL_ENV`` set in .env / the shell overrides config and is what +``terminal_tool`` actually uses. The dump used to print only the config value, +which hid the override and made users believe the agent was running ``local`` +while it was really jailed in a docker/podman sandbox (and vice-versa). +""" + +from pathlib import Path +from types import SimpleNamespace + + +def _terminal_line(out: str) -> str: + for line in out.splitlines(): + if line.startswith("terminal:"): + return line + raise AssertionError(f"no 'terminal:' line in dump output:\n{out}") + + +def _seed(home: Path, *, config_yaml: str, env_text: str) -> None: + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text(config_yaml) + (home / ".env").write_text(env_text) + + +def test_dump_surfaces_terminal_env_override(monkeypatch, capsys, tmp_path): + from hermes_cli import dump + from hermes_cli.config import get_hermes_home + + monkeypatch.delenv("TERMINAL_ENV", raising=False) + # Keep run_dump's project-.env fallback from touching the real repo. + monkeypatch.setattr(dump, "get_project_root", lambda: tmp_path / "noproject") + + home = get_hermes_home() + _seed(home, config_yaml="terminal:\n backend: local\n", env_text="TERMINAL_ENV=docker\n") + + dump.run_dump(SimpleNamespace(show_keys=False)) + + line = _terminal_line(capsys.readouterr().out) + # Effective backend (docker) is what actually runs, not the config 'local'. + assert "docker" in line + assert "overrides config.yaml" in line + # The shadowed config value is still shown so the mismatch is obvious. + assert "terminal.backend=local" in line + + +def test_dump_reports_config_backend_when_no_override(monkeypatch, capsys, tmp_path): + from hermes_cli import dump + from hermes_cli.config import get_hermes_home + + monkeypatch.delenv("TERMINAL_ENV", raising=False) + monkeypatch.setattr(dump, "get_project_root", lambda: tmp_path / "noproject") + + home = get_hermes_home() + _seed(home, config_yaml="terminal:\n backend: docker\n", env_text="") + + dump.run_dump(SimpleNamespace(show_keys=False)) + + line = _terminal_line(capsys.readouterr().out) + assert "docker" in line + assert "overrides" not in line + + +def test_dump_no_override_when_env_matches_config(monkeypatch, capsys, tmp_path): + from hermes_cli import dump + from hermes_cli.config import get_hermes_home + + monkeypatch.delenv("TERMINAL_ENV", raising=False) + monkeypatch.setattr(dump, "get_project_root", lambda: tmp_path / "noproject") + + home = get_hermes_home() + # TERMINAL_ENV agrees with config — no spurious "override" note. + _seed(home, config_yaml="terminal:\n backend: docker\n", env_text="TERMINAL_ENV=docker\n") + + dump.run_dump(SimpleNamespace(show_keys=False)) + + line = _terminal_line(capsys.readouterr().out) + assert "docker" in line + assert "overrides" not in line From 9f58403756a53eaca1bc4494c8c974adc9ae2e59 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 15 Jun 2026 15:36:51 -0400 Subject: [PATCH 10/28] fix(desktop): let explicit model switches escape broken config providers (#42241) (#46796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a desktop/dashboard session had no agent built yet and the user explicitly picked a provider in the model picker, config.set('model', ...) would first try to initialize the agent from the (possibly broken) config default provider — failing before the user's explicit switch could take effect, trapping them on a misconfigured default. config.set now pre-parses the model flags: if an explicit --provider is present and no agent exists yet, it skips the default-provider agent build and routes straight through _apply_model_switch with the explicit provider. _apply_model_switch gained a parsed_flags passthrough (avoids double-parsing) and only falls back to resolve_runtime_provider(requested=None) when no explicit provider was given. The desktop hook now sends config.set instead of slash.exec for active-session model changes, so errors from the selected provider surface to the user instead of being swallowed. Co-authored-by: rodboev --- .../session/hooks/use-model-controls.test.tsx | 97 ++++++++++++++++++- .../app/session/hooks/use-model-controls.ts | 5 +- tests/test_tui_gateway_server.py | 88 +++++++++++++++++ tui_gateway/server.py | 42 +++++--- 4 files changed, 212 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx index 8f52018982a5f..612290800e065 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx @@ -1,5 +1,5 @@ -import { renderHook } from '@testing-library/react' import { QueryClient } from '@tanstack/react-query' +import { cleanup, render, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getGlobalModelInfo } from '@/hermes' @@ -13,12 +13,51 @@ import { import { useModelControls } from './use-model-controls' +const setGlobalModel = vi.fn() +const notifyError = vi.fn() + vi.mock('@/hermes', () => ({ getGlobalModelInfo: vi.fn(), - setGlobalModel: vi.fn() + setGlobalModel: (...args: Parameters) => setGlobalModel(...args) +})) + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: { + desktop: { + modelSwitchFailed: 'Model switch failed' + } + } + }) })) -describe('useModelControls.refreshCurrentModel', () => { +vi.mock('@/store/notifications', () => ({ + notifyError: (...args: Parameters) => notifyError(...args) +})) + +type Controls = ReturnType + +function Harness({ + activeSessionId, + onReady, + requestGateway +}: { + activeSessionId: string | null + onReady: (controls: Controls) => void + requestGateway: (method: string, params?: Record) => Promise +}) { + const controls = useModelControls({ + activeSessionId, + queryClient: new QueryClient(), + requestGateway + }) + + onReady(controls) + + return null +} + +describe('useModelControls', () => { beforeEach(() => { $activeSessionId.set(null) setCurrentModel('') @@ -26,6 +65,7 @@ describe('useModelControls.refreshCurrentModel', () => { }) afterEach(() => { + cleanup() vi.restoreAllMocks() $activeSessionId.set(null) setCurrentModel('') @@ -74,4 +114,55 @@ describe('useModelControls.refreshCurrentModel', () => { expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro') expect($currentProvider.get()).toBe('deepseek') }) + + it('routes active-session picker changes through config.set with an explicit provider', async () => { + const requestGateway = vi.fn(async () => ({ key: 'model', value: 'claude-sonnet-4.6' }) as never) + let controls!: Controls + + render( + (controls = value)} + requestGateway={requestGateway} + /> + ) + + await expect( + controls.selectModel({ + model: 'claude-sonnet-4.6', + persistGlobal: false, + provider: 'anthropic' + }) + ).resolves.toBe(true) + + expect(requestGateway).toHaveBeenCalledWith('config.set', { + session_id: 'session-1', + key: 'model', + value: 'claude-sonnet-4.6 --provider anthropic' + }) + expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) + }) + + it('keeps the global path on setGlobalModel when there is no active session', async () => { + setGlobalModel.mockResolvedValue(undefined) + let controls!: Controls + + render( + (controls = value)} + requestGateway={vi.fn()} + /> + ) + + await expect( + controls.selectModel({ + model: 'claude-sonnet-4.6', + persistGlobal: false, + provider: 'anthropic' + }) + ).resolves.toBe(true) + + expect(setGlobalModel).toHaveBeenCalledWith('anthropic', 'claude-sonnet-4.6') + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts index 525c8d8385b8a..681eac871a21f 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -82,9 +82,10 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway try { if (activeSessionId) { - await requestGateway('slash.exec', { + await requestGateway('config.set', { session_id: activeSessionId, - command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}` + key: 'model', + value: `${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}` }) if (selection.persistGlobal) { diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 90a7f20025520..da85cc26ad649 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -3036,6 +3036,94 @@ def _switch_model(**kwargs): assert saved["model"]["base_url"] == "https://api.anthropic.com" +def test_config_set_model_explicit_provider_skips_broken_default_init(monkeypatch): + seen = {"build": 0, "wait": 0, "requested": []} + session = _session() + session["agent"] = None + server._sessions["sid"] = session + monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"default": "broken/model", "provider": "openrouter"}}) + monkeypatch.setattr(server, "_start_agent_build", lambda *_args: seen.__setitem__("build", seen["build"] + 1)) + monkeypatch.setattr(server, "_wait_agent", lambda *_args: seen.__setitem__("wait", seen["wait"] + 1)) + monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None) + monkeypatch.setattr(server, "_restart_slash_worker", lambda *args, **kwargs: None) + + def fake_runtime_provider(*, requested=None, target_model=None, **_kwargs): + seen["requested"].append((requested, target_model)) + if requested is None: + raise RuntimeError("broken default provider should not be initialized") + if requested == "anthropic": + return { + "api_key": "sk-anthropic", + "api_mode": "anthropic_messages", + "base_url": "https://api.anthropic.com", + } + raise RuntimeError(f"unexpected provider {requested}") + + monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", fake_runtime_provider) + + try: + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": { + "session_id": "sid", + "key": "model", + "value": "claude-sonnet-4.6 --provider anthropic", + }, + } + ) + + assert resp["result"]["value"] == "claude-sonnet-4-6" + assert seen["build"] == 0 + assert seen["wait"] == 0 + assert seen["requested"] == [("anthropic", "claude-sonnet-4.6")] + assert session["model_override"]["provider"] == "anthropic" + assert session["model_override"]["model"] == "claude-sonnet-4-6" + finally: + server._sessions.pop("sid", None) + + +def test_config_set_model_explicit_provider_surfaces_selected_provider_errors(monkeypatch): + seen = {"build": 0, "wait": 0} + session = _session() + session["agent"] = None + server._sessions["sid"] = session + monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"default": "broken/model", "provider": "openrouter"}}) + monkeypatch.setattr(server, "_start_agent_build", lambda *_args: seen.__setitem__("build", seen["build"] + 1)) + monkeypatch.setattr(server, "_wait_agent", lambda *_args: seen.__setitem__("wait", seen["wait"] + 1)) + + def fake_runtime_provider(*, requested=None, **_kwargs): + if requested is None: + raise RuntimeError("broken default provider should not be initialized") + if requested == "anthropic": + raise RuntimeError("missing anthropic API key") + raise RuntimeError(f"unexpected provider {requested}") + + monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", fake_runtime_provider) + + try: + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": { + "session_id": "sid", + "key": "model", + "value": "claude-sonnet-4.6 --provider anthropic", + }, + } + ) + + assert resp["error"]["code"] == 5001 + assert "anthropic" in resp["error"]["message"].lower() + assert "missing anthropic api key" in resp["error"]["message"].lower() + assert seen["build"] == 0 + assert seen["wait"] == 0 + finally: + server._sessions.pop("sid", None) + + def test_config_set_model_does_not_leak_inference_provider_env(monkeypatch): """A /model switch must NOT mutate process-global env vars. The desktop / dashboard tui_gateway backend hosts every same-profile session in one diff --git a/tui_gateway/server.py b/tui_gateway/server.py index d34f558f6cfd0..715ca8b48b63c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1961,11 +1961,14 @@ def _apply_model_switch( *, confirm_expensive_model: bool = False, pin_session_override: bool = True, + parsed_flags: tuple[str, str, bool, bool] | None = None, ) -> dict: from hermes_cli.model_switch import parse_model_flags, switch_model from hermes_cli.runtime_provider import resolve_runtime_provider - model_input, explicit_provider, persist_global, _force_refresh = parse_model_flags(raw_input) + if parsed_flags is None: + parsed_flags = parse_model_flags(raw_input) + model_input, explicit_provider, persist_global, _force_refresh = parsed_flags if not model_input: raise ValueError("model value required") @@ -1976,20 +1979,24 @@ def _apply_model_switch( current_base_url = getattr(agent, "base_url", "") or "" current_api_key = getattr(agent, "api_key", "") or "" else: - runtime = resolve_runtime_provider(requested=None) - current_provider = str(runtime.get("provider", "") or "") current_model = _resolve_model() - current_base_url = str(runtime.get("base_url", "") or "") - # Preserve a callable api_key (Azure Foundry Entra ID bearer - # provider) unchanged — ``str(...)`` would produce - # ``""`` and poison downstream switch_model - # validation. Match the agent-present branch's behavior at the - # top of this block. - _runtime_key = runtime.get("api_key", "") - if callable(_runtime_key) and not isinstance(_runtime_key, str): - current_api_key = _runtime_key - else: - current_api_key = str(_runtime_key or "") + current_provider = explicit_provider.strip() + current_base_url = "" + current_api_key = "" + if not explicit_provider: + runtime = resolve_runtime_provider(requested=None) + current_provider = str(runtime.get("provider", "") or "") + current_base_url = str(runtime.get("base_url", "") or "") + # Preserve a callable api_key (Azure Foundry Entra ID bearer + # provider) unchanged — ``str(...)`` would produce + # ``""`` and poison downstream switch_model + # validation. Match the agent-present branch's behavior at the + # top of this block. + _runtime_key = runtime.get("api_key", "") + if callable(_runtime_key) and not isinstance(_runtime_key, str): + current_api_key = _runtime_key + else: + current_api_key = str(_runtime_key or "") # Load user-defined providers so switch_model can resolve named custom # endpoints (e.g. "ollama-launch") and validate against saved model lists. @@ -6996,7 +7003,11 @@ def _(rid, params: dict) -> dict: 4009, "session busy — /interrupt the current turn before switching models", ) - if session.get("agent") is None: + from hermes_cli.model_switch import parse_model_flags + + parsed_flags = parse_model_flags(value) + _model_input, explicit_provider, _persist_global, _force_refresh = parsed_flags + if session.get("agent") is None and not explicit_provider.strip(): session_id = params.get("session_id", "") _start_agent_build(session_id, session) init_err = _wait_agent(session, rid) @@ -7011,6 +7022,7 @@ def _(rid, params: dict) -> dict: confirm_expensive_model=bool( params.get("confirm_expensive_model", False) ), + parsed_flags=parsed_flags, ) else: result = _apply_model_switch( From 8c8b082ddb093d23dc556c049a0e5ac878f7b9a2 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sun, 14 Jun 2026 17:21:20 +0700 Subject: [PATCH 11/28] fix(install): make `npm install -g` packages reachable on PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the installer falls back to a bundled Node under $HERMES_HOME/node, npm's default global prefix is that Node dir, so `npm install -g ` drops the package binary in $HERMES_HOME/node/bin. Only node/npm/npx are symlinked into the command link dir (~/.local/bin, /usr/local/bin, or $PREFIX/bin) — so user-installed global package binaries are NOT on PATH and can't be run, even though `npm i -g` reports success. They also get wiped on every Node upgrade (the dir is rm -rf'd and re-extracted). Redirect the bundled Node's npm global prefix to the command link dir's parent, so global bins land in the link dir (already on PATH, alongside node/npm/npx) and survive Node upgrades. Scoped to the bundled Node via its prefix-local global npmrc ($HERMES_HOME/node/etc/npmrc), so the user's other Node installs and their ~/.npmrc are untouched. Hermes's own global installs (agent-browser) pass an explicit --prefix and are unaffected. --- scripts/install.sh | 12 ++++++++++++ scripts/lib/node-bootstrap.sh | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/scripts/install.sh b/scripts/install.sh index 7d644fe2d7723..030d57d4c14f2 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -851,6 +851,18 @@ install_node() { ln -sf "$HERMES_HOME/node/bin/npm" "$node_link_dir/npm" ln -sf "$HERMES_HOME/node/bin/npx" "$node_link_dir/npx" + # Point this Node's `npm install -g` at a directory that is actually on + # PATH. By default npm's global prefix is the Node install dir, so user + # globals land in $HERMES_HOME/node/bin — which is NOT on PATH (only the + # link dir is) and is wiped on every Node upgrade. Redirecting the prefix + # to the link dir's parent makes global bins land in the link dir + # (node/npm/npx live there too, and it's already on PATH) and survive + # upgrades. Scoped to this Node via its prefix-local global npmrc, so the + # user's other Node installs and their ~/.npmrc are untouched. Hermes's + # own global installs pass an explicit --prefix and are unaffected. + mkdir -p "$HERMES_HOME/node/etc" + printf 'prefix=%s\n' "$(dirname "$node_link_dir")" > "$HERMES_HOME/node/etc/npmrc" + export PATH="$HERMES_HOME/node/bin:$PATH" local installed_ver diff --git a/scripts/lib/node-bootstrap.sh b/scripts/lib/node-bootstrap.sh index 02e568733f3bc..15763d70486a9 100644 --- a/scripts/lib/node-bootstrap.sh +++ b/scripts/lib/node-bootstrap.sh @@ -206,6 +206,14 @@ _nb_install_bundled_node() { ln -sf "$HERMES_HOME/node/bin/node" "$_link_dir/node" ln -sf "$HERMES_HOME/node/bin/npm" "$_link_dir/npm" ln -sf "$HERMES_HOME/node/bin/npx" "$_link_dir/npx" + + # Redirect this Node's `npm install -g` to the link dir (already on PATH) + # instead of the default $HERMES_HOME/node/bin, which is off PATH and wiped + # on every Node upgrade. Scoped to this Node via its prefix-local global + # npmrc; the user's other Node installs / ~/.npmrc are untouched. + mkdir -p "$HERMES_HOME/node/etc" + printf 'prefix=%s\n' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc" + export PATH="$HERMES_HOME/node/bin:$PATH" _nb_have_modern_node || return 1 From 5785915ffcc54685e26351a9f6fca1f5d09c2a50 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sun, 14 Jun 2026 17:21:25 +0700 Subject: [PATCH 12/28] test(install): cover bundled-Node npm global prefix redirect Guards that install.sh and node-bootstrap.sh redirect the bundled Node's npm global prefix to the command link dir's parent via a prefix-local global npmrc, so `npm install -g` binaries land on PATH instead of the off-PATH $HERMES_HOME/node/bin. --- tests/test_install_sh_node_global_prefix.py | 39 +++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/test_install_sh_node_global_prefix.py diff --git a/tests/test_install_sh_node_global_prefix.py b/tests/test_install_sh_node_global_prefix.py new file mode 100644 index 0000000000000..f604fc97d77a1 --- /dev/null +++ b/tests/test_install_sh_node_global_prefix.py @@ -0,0 +1,39 @@ +"""Regression tests for the Hermes-managed Node's npm global prefix. + +When the installer falls back to a bundled Node under ``$HERMES_HOME/node``, +npm's default global prefix is that Node dir, so ``npm install -g `` +drops the package binary in ``$HERMES_HOME/node/bin`` — which is NOT on PATH +(only the command link dir is) and is wiped on every Node upgrade. Users then +report "I can ``npm i -g`` but the package isn't usable on the command line". + +The fix redirects the bundled Node's global prefix to the command link dir's +parent (so global bins land in the already-on-PATH link dir alongside +node/npm/npx), scoped to the bundled Node via its prefix-local global npmrc. +""" + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" +NODE_BOOTSTRAP = REPO_ROOT / "scripts" / "lib" / "node-bootstrap.sh" + + +def test_install_sh_redirects_bundled_npm_global_prefix_to_link_dir() -> None: + text = INSTALL_SH.read_text() + + # The redirect must target the link dir's PARENT so global bins resolve to + # /bin == the command link dir (node/npm/npx live there and it is + # guaranteed on PATH by the installer's PATH setup). + assert 'printf \'prefix=%s\\n\' "$(dirname "$node_link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text + + # The npmrc lives under the bundled Node so it only affects this npm, not + # the user's other Node installs or their ~/.npmrc. + assert '"$HERMES_HOME/node/etc/npmrc"' in text + + +def test_node_bootstrap_redirects_bundled_npm_global_prefix_to_link_dir() -> None: + text = NODE_BOOTSTRAP.read_text() + + assert 'printf \'prefix=%s\\n\' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text + assert '"$HERMES_HOME/node/etc/npmrc"' in text From 18ff01c3ef6c25a62d617297d8c7ae21babc6ad3 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sun, 14 Jun 2026 17:34:11 +0700 Subject: [PATCH 13/28] fix(install): repair existing managed-Node global prefix on re-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial fix only wrote the prefix npmrc on a fresh Node install, so pre-existing bundled-Node installs (Node already present) were not repaired by re-running the installer — install_node/ensure_node skip when Node is already up to date. Extract the redirect into an idempotent helper (configure_managed_node_npm_prefix / _nb_configure_npm_prefix) that no-ops when there's no Hermes-managed npm, and call it unconditionally from check_node (install.sh) and at the top of ensure_node (node-bootstrap.sh). Re-running the install command now repairs an affected install in place, not just brand-new ones. --- scripts/install.sh | 36 ++++++++++++++------- scripts/lib/node-bootstrap.sh | 24 ++++++++++---- tests/test_install_sh_node_global_prefix.py | 26 ++++++++++++--- 3 files changed, 64 insertions(+), 22 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 030d57d4c14f2..b3b5f104e3d89 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -413,6 +413,25 @@ get_command_link_display_dir() { fi } +# Point a Hermes-managed Node's `npm install -g` at a directory that is on +# PATH. npm's default global prefix for a bundled Node is the Node dir itself, +# so global package binaries land in $HERMES_HOME/node/bin — which is NOT on +# PATH (only the command link dir is) and is wiped on every Node upgrade. +# Redirecting the prefix to the link dir's parent makes global bins resolve to +# the command link dir (node/npm/npx live there too, already on PATH) and +# survive upgrades. Scoped to the managed Node via its prefix-local global +# npmrc, so the user's other Node installs and their ~/.npmrc are untouched. +# Hermes's own global installs pass an explicit --prefix and are unaffected. +# Idempotent and a no-op when there is no Hermes-managed npm, so calling it on +# every install run repairs pre-existing installs, not just fresh ones. +configure_managed_node_npm_prefix() { + [ -x "$HERMES_HOME/node/bin/npm" ] || return 0 + local link_dir + link_dir="$(get_command_link_dir)" + mkdir -p "$HERMES_HOME/node/etc" + printf 'prefix=%s\n' "$(dirname "$link_dir")" > "$HERMES_HOME/node/etc/npmrc" +} + get_hermes_command_path() { local link_dir link_dir="$(get_command_link_dir)" @@ -722,6 +741,11 @@ node_satisfies_build() { check_node() { log_info "Checking Node.js (for browser tools)..." + # Repair pre-existing Hermes-managed installs where `npm install -g` lands + # off PATH. No-op when there's no managed Node, so this is safe to run on + # every install — including re-runs that skip the Node (re)install below. + configure_managed_node_npm_prefix + if command -v node &> /dev/null && node_satisfies_build "$(node --version)"; then log_success "Node.js $(node --version) found" HAS_NODE=true @@ -851,17 +875,7 @@ install_node() { ln -sf "$HERMES_HOME/node/bin/npm" "$node_link_dir/npm" ln -sf "$HERMES_HOME/node/bin/npx" "$node_link_dir/npx" - # Point this Node's `npm install -g` at a directory that is actually on - # PATH. By default npm's global prefix is the Node install dir, so user - # globals land in $HERMES_HOME/node/bin — which is NOT on PATH (only the - # link dir is) and is wiped on every Node upgrade. Redirecting the prefix - # to the link dir's parent makes global bins land in the link dir - # (node/npm/npx live there too, and it's already on PATH) and survive - # upgrades. Scoped to this Node via its prefix-local global npmrc, so the - # user's other Node installs and their ~/.npmrc are untouched. Hermes's - # own global installs pass an explicit --prefix and are unaffected. - mkdir -p "$HERMES_HOME/node/etc" - printf 'prefix=%s\n' "$(dirname "$node_link_dir")" > "$HERMES_HOME/node/etc/npmrc" + configure_managed_node_npm_prefix export PATH="$HERMES_HOME/node/bin:$PATH" diff --git a/scripts/lib/node-bootstrap.sh b/scripts/lib/node-bootstrap.sh index 15763d70486a9..332ad81180ad7 100644 --- a/scripts/lib/node-bootstrap.sh +++ b/scripts/lib/node-bootstrap.sh @@ -57,6 +57,19 @@ _nb_get_link_dir() { fi } +# Redirect a Hermes-managed Node's `npm install -g` to the command link dir +# (already on PATH) instead of the default $HERMES_HOME/node/bin, which is off +# PATH and wiped on every Node upgrade. Scoped to the managed Node via its +# prefix-local global npmrc; the user's other Node installs / ~/.npmrc are +# untouched. Idempotent no-op when there's no managed npm. +_nb_configure_npm_prefix() { + [ -x "$HERMES_HOME/node/bin/npm" ] || return 0 + local _link_dir + _link_dir="$(_nb_get_link_dir)" + mkdir -p "$HERMES_HOME/node/etc" + printf 'prefix=%s\n' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc" +} + _nb_node_major() { local v v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1) @@ -207,12 +220,7 @@ _nb_install_bundled_node() { ln -sf "$HERMES_HOME/node/bin/npm" "$_link_dir/npm" ln -sf "$HERMES_HOME/node/bin/npx" "$_link_dir/npx" - # Redirect this Node's `npm install -g` to the link dir (already on PATH) - # instead of the default $HERMES_HOME/node/bin, which is off PATH and wiped - # on every Node upgrade. Scoped to this Node via its prefix-local global - # npmrc; the user's other Node installs / ~/.npmrc are untouched. - mkdir -p "$HERMES_HOME/node/etc" - printf 'prefix=%s\n' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc" + _nb_configure_npm_prefix export PATH="$HERMES_HOME/node/bin:$PATH" @@ -228,6 +236,10 @@ _nb_install_bundled_node() { ensure_node() { HERMES_NODE_AVAILABLE=false + # Repair pre-existing managed installs where `npm install -g` lands off + # PATH. No-op when there's no managed Node, so it's safe to run first. + _nb_configure_npm_prefix + if _nb_have_modern_node; then _nb_ok "Node $(node --version) found" HERMES_NODE_AVAILABLE=true diff --git a/tests/test_install_sh_node_global_prefix.py b/tests/test_install_sh_node_global_prefix.py index f604fc97d77a1..e43b9201bd165 100644 --- a/tests/test_install_sh_node_global_prefix.py +++ b/tests/test_install_sh_node_global_prefix.py @@ -25,15 +25,31 @@ def test_install_sh_redirects_bundled_npm_global_prefix_to_link_dir() -> None: # The redirect must target the link dir's PARENT so global bins resolve to # /bin == the command link dir (node/npm/npx live there and it is # guaranteed on PATH by the installer's PATH setup). - assert 'printf \'prefix=%s\\n\' "$(dirname "$node_link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text + assert "configure_managed_node_npm_prefix()" in text + assert 'printf \'prefix=%s\\n\' "$(dirname "$link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text - # The npmrc lives under the bundled Node so it only affects this npm, not - # the user's other Node installs or their ~/.npmrc. - assert '"$HERMES_HOME/node/etc/npmrc"' in text + +def test_install_sh_repairs_existing_managed_node_on_rerun() -> None: + """The redirect must run on every install (not just fresh Node installs), + so re-running the installer repairs pre-existing managed installs whose + Node is already up to date and would otherwise skip install_node.""" + text = INSTALL_SH.read_text() + + check_node_body = text.split("check_node()", 1)[1].split("\ninstall_node()", 1)[0] + assert "configure_managed_node_npm_prefix" in check_node_body + + # No-op guard so it's safe to call when there is no managed Node. + assert '[ -x "$HERMES_HOME/node/bin/npm" ] || return 0' in text def test_node_bootstrap_redirects_bundled_npm_global_prefix_to_link_dir() -> None: text = NODE_BOOTSTRAP.read_text() + assert "_nb_configure_npm_prefix()" in text assert 'printf \'prefix=%s\\n\' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text - assert '"$HERMES_HOME/node/etc/npmrc"' in text + + # Runs at the top of ensure_node so existing managed installs are repaired + # even when a modern Node is already present (early return path). + ensure_node_body = text.split("ensure_node()", 1)[1] + assert "_nb_configure_npm_prefix" in ensure_node_body + assert '[ -x "$HERMES_HOME/node/bin/npm" ] || return 0' in text From 8d18033761914be4c3681375c4516164e7b713d0 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 15 Jun 2026 16:16:55 -0400 Subject: [PATCH 14/28] fix(desktop): read HERMES_HOME from the Windows registry when env is stale (#46772) A GUI app launched from Explorer inherits the environment block captured at login, so a HERMES_HOME set via 'setx' AFTER login is invisible in process.env even though the CLI (a fresh shell) sees it. The desktop then silently fell back to %LOCALAPPDATA%\hermes and reported 'No inference provider configured' despite a valid configured home (#45471). resolveHermesHome() now consults the live HKCU\Environment registry value on Windows before the LOCALAPPDATA default. New windows-user-env.cjs helper parses 'reg query' output, expands %VAR% refs, and fails safe (returns null off-Windows, on spawn error, or empty value). The registry value is normalized through the same normalizeHermesHomeRoot() path as the env var for consistency. Co-authored-by: jeffrobodie-glitch --- apps/desktop/electron/main.cjs | 11 +++ apps/desktop/electron/windows-user-env.cjs | 76 ++++++++++++++++ .../electron/windows-user-env.test.cjs | 90 +++++++++++++++++++ apps/desktop/package.json | 2 +- 4 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/electron/windows-user-env.cjs create mode 100644 apps/desktop/electron/windows-user-env.test.cjs diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index c714a46ee467d..98b32f0532cbc 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -39,6 +39,7 @@ const { waitForDashboardPort } = require('./backend-ready.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs') +const { readWindowsUserEnvVar } = require('./windows-user-env.cjs') const { readDirForIpc } = require('./fs-read-dir.cjs') const { gitRootForIpc } = require('./git-root.cjs') const { worktreesForIpc } = require('./git-worktrees.cjs') @@ -242,6 +243,16 @@ if (INSTALL_STAMP) { function resolveHermesHome() { if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME) if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home') + if (IS_WINDOWS) { + // A GUI app launched from Explorer inherits the environment block captured + // at login, so a HERMES_HOME set via `setx` AFTER login is invisible in + // process.env even though the CLI (a fresh shell) sees it. Without this the + // backend silently falls back to %LOCALAPPDATA%\hermes and reports "No + // inference provider configured" despite a valid configured home (#45471). + // Consult the live User-scoped registry value before the default below. + const fromRegistry = readWindowsUserEnvVar('HERMES_HOME') + if (fromRegistry) return normalizeHermesHomeRoot(fromRegistry) + } if (IS_WINDOWS && process.env.LOCALAPPDATA) { const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes') const legacy = path.join(app.getPath('home'), '.hermes') diff --git a/apps/desktop/electron/windows-user-env.cjs b/apps/desktop/electron/windows-user-env.cjs new file mode 100644 index 0000000000000..0ba93d339aaca --- /dev/null +++ b/apps/desktop/electron/windows-user-env.cjs @@ -0,0 +1,76 @@ +// windows-user-env.cjs +// +// Read a User-scoped environment variable straight from the Windows registry +// (HKCU\Environment). +// +// A GUI app launched from Explorer inherits the environment block captured at +// login, so a variable set via `setx` AFTER login is invisible in process.env +// even though a fresh shell — and the Hermes CLI — sees it immediately. The +// desktop's HERMES_HOME resolution relies on process.env, so that stale-snapshot +// gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading +// the live registry value closes the gap. See #45471. + +const { execFileSync } = require('node:child_process') + +// Parse the output of `reg query HKCU\Environment /v `, which looks like: +// +// HKEY_CURRENT_USER\Environment +// HERMES_HOME REG_SZ F:\Hermes\data +// +// Returns the raw value string (spaces inside the value preserved), or null when +// the requested value line isn't present. +function parseRegQueryValue(stdout, name) { + if (!stdout || !name) return null + const typePattern = + /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/ + for (const rawLine of String(stdout).split(/\r?\n/)) { + const line = rawLine.trim() + const match = line.match(typePattern) + if (match && match[1].toLowerCase() === name.toLowerCase()) { + return match[2] + } + } + return null +} + +// Expand %VAR% references against an env map. REG_EXPAND_SZ values store +// unexpanded references; plain REG_SZ paths have none, so this is a no-op for +// the common F:\... case. Unknown references are left verbatim. +function expandWindowsEnvRefs(value, env = process.env) { + if (!value) return value + return value.replace(/%([^%]+)%/g, (whole, name) => { + const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase()) + return key != null && env[key] != null ? env[key] : whole + }) +} + +// Read a User-scoped env var from HKCU\Environment. Windows-only: returns null +// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero +// (the value doesn't exist), or when the value is empty. +function readWindowsUserEnvVar( + name, + { platform = process.platform, env = process.env, exec = execFileSync } = {} +) { + if (platform !== 'win32' || !name) return null + let stdout + try { + stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], { + encoding: 'utf8', + windowsHide: true, + timeout: 5000 + }) + } catch { + // `reg` missing, or value absent (reg exits 1) — caller falls back. + return null + } + const raw = parseRegQueryValue(stdout, name) + if (raw == null) return null + const expanded = expandWindowsEnvRefs(raw, env).trim() + return expanded || null +} + +module.exports = { + expandWindowsEnvRefs, + parseRegQueryValue, + readWindowsUserEnvVar +} diff --git a/apps/desktop/electron/windows-user-env.test.cjs b/apps/desktop/electron/windows-user-env.test.cjs new file mode 100644 index 0000000000000..dcc71d2c95b4e --- /dev/null +++ b/apps/desktop/electron/windows-user-env.test.cjs @@ -0,0 +1,90 @@ +const assert = require('node:assert/strict') +const { test } = require('node:test') + +const { + expandWindowsEnvRefs, + parseRegQueryValue, + readWindowsUserEnvVar +} = require('./windows-user-env.cjs') + +// ── parseRegQueryValue ───────────────────────────────────────────────────── + +test('parseRegQueryValue extracts a REG_SZ value', () => { + const out = [ + '', + 'HKEY_CURRENT_USER\\Environment', + ' HERMES_HOME REG_SZ F:\\Hermes\\data', + '' + ].join('\r\n') + assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'F:\\Hermes\\data') +}) + +test('parseRegQueryValue matches the name case-insensitively', () => { + const out = 'HKEY_CURRENT_USER\\Environment\r\n Hermes_Home REG_EXPAND_SZ %USERPROFILE%\\h\r\n' + assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), '%USERPROFILE%\\h') +}) + +test('parseRegQueryValue preserves spaces inside the value', () => { + const out = ' HERMES_HOME REG_SZ C:\\Program Files\\Hermes\r\n' + assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'C:\\Program Files\\Hermes') +}) + +test('parseRegQueryValue returns null when the value line is absent', () => { + const out = 'HKEY_CURRENT_USER\\Environment\r\n Path REG_SZ C:\\x\r\n' + assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), null) + assert.equal(parseRegQueryValue('', 'HERMES_HOME'), null) + assert.equal(parseRegQueryValue('garbage', 'HERMES_HOME'), null) +}) + +// ── expandWindowsEnvRefs ─────────────────────────────────────────────────── + +test('expandWindowsEnvRefs expands %VAR% case-insensitively', () => { + assert.equal( + expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }), + 'C:\\Users\\jeff\\h' + ) +}) + +test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => { + assert.equal(expandWindowsEnvRefs('F:\\Hermes\\data', {}), 'F:\\Hermes\\data') + assert.equal(expandWindowsEnvRefs('%NOPE%\\x', {}), '%NOPE%\\x') +}) + +// ── readWindowsUserEnvVar ────────────────────────────────────────────────── + +test('readWindowsUserEnvVar returns null off Windows without spawning', () => { + let spawned = false + const exec = () => { + spawned = true + return '' + } + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null) + assert.equal(spawned, false) +}) + +test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => { + const calls = [] + const exec = (cmd, args) => { + calls.push([cmd, args]) + return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n' + } + const value = readWindowsUserEnvVar('HERMES_HOME', { + platform: 'win32', + env: { DRIVE: 'F:' }, + exec + }) + assert.equal(value, 'F:\\Hermes') + assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]]) +}) + +test('readWindowsUserEnvVar returns null when reg exits non-zero (value missing)', () => { + const exec = () => { + throw new Error('reg exited 1') + } + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null) +}) + +test('readWindowsUserEnvVar returns null for an empty value', () => { + const exec = () => ' HERMES_HOME REG_SZ \r\n' + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ebc9293668ad2..08080188a53ac 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", From e4f98d232362eecf66b016372e2daf1d80b978e2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:33:12 -0700 Subject: [PATCH 15/28] feat(delegation): async background subagents via delegate_task(background=true) (#40946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(delegation): async background subagents via delegate_task(background=true) delegate_task(background=true) dispatches a subagent that runs in the background and returns a handle immediately, so the user and model keep working while it runs. The full result — plus the original task source — re-enters the conversation as a new turn when the subagent finishes, riding the same completion-queue rail as terminal background processes. - tools/async_delegation.py: daemon-executor registry, capacity cap, rich self-contained completion event pushed onto the shared process_registry.completion_queue (type='async_delegation'). - delegate_tool.py: background param + single-task dispatch branch; batch async rejected (v1). - process_registry.py: format_process_notification renders the rich task-source block (goal/context/toolsets/model/status/result). - gateway/run.py: dedicated _async_delegation_watcher drains + injects results into the originating session (idle + post-turn), session_key routing enrichment, shutdown interrupt of dangling delegations. - config: delegation.max_async_children (default 3). Reuses the existing idle-drain wiring rather than mutating a running agent loop, preserving message-role alternation and prompt-cache invariants. 13 targeted tests; CLI + gateway paths E2E-verified. * test(delegation): make async non-blocking tests environment-independent CI 'test (5)' flaked on a cold, 8-worker runner: the first delegate_task(background=true) call measured 2.27s of one-time setup (config load + child-agent construction + imports), tripping the elapsed < 1.0 wall-clock assertion. That assertion was testing setup overhead, not blocking. Replace the wall-clock thresholds with the real invariant: dispatch returns while the child is still gated (active_count == 1, completion queue empty), which a synchronous impl could not do. Keep only a loose 4s sanity backstop well under the runner's 5s gate. * fix(delegation): harden async background delegation Follow-up review fixes: - Detach background child from parent._active_children at dispatch — otherwise parent-turn interrupts (Ctrl+C, mid-turn steering), cache evicts (release_clients), and session close (/new) kill/close the detached subagent mid-run, defeating the point of background mode. Lifecycle is owned by the async registry's interrupt_fn. - Make the capacity check atomic with the record insert (TOCTOU: two concurrent dispatches could both pass active_count() and exceed the cap). - TUI dedup: key async_delegation events by delegation_id — the fallthrough keyed them all as ("", type), suppressing every completion after the first in the desktop/TUI status feed. - CLI /stop now interrupts running background delegations and /agents lists them (they live outside the process registry and were invisible). - Drop stray unbalanced ']' line from the re-injection block and the unused _ASYNC_DEFAULT import. Tests: detach-at-dispatch + concurrent-capacity race added (15 total in test_async_delegation.py); 137 delegate + 140 process-registry/notify/watch + 7 TUI dedup tests pass. * fix(delegation): harden async background completion drains --- cli.py | 5 + gateway/run.py | 136 +++++++- hermes_cli/cli_commands_mixin.py | 40 ++- hermes_cli/config.py | 1 + tests/tools/test_async_delegation.py | 473 +++++++++++++++++++++++++++ tools/async_delegation.py | 386 ++++++++++++++++++++++ tools/delegate_tool.py | 149 +++++++++ tools/process_registry.py | 88 +++++ tui_gateway/server.py | 5 + 9 files changed, 1268 insertions(+), 15 deletions(-) create mode 100644 tests/tools/test_async_delegation.py create mode 100644 tools/async_delegation.py diff --git a/cli.py b/cli.py index ca01b82d5ee42..bc4f4a76befb4 100644 --- a/cli.py +++ b/cli.py @@ -977,6 +977,11 @@ def _run_cleanup(*, notify_session_finalize: bool = True): _cleanup_all_terminals() except Exception: pass + try: + from tools.async_delegation import interrupt_all as _interrupt_async_delegations + _interrupt_async_delegations(reason="CLI shutdown") + except Exception: + pass try: _cleanup_all_browsers() except Exception: diff --git a/gateway/run.py b/gateway/run.py index 4541e0fa67704..1650851fb756d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1921,9 +1921,42 @@ def _format_gateway_process_notification(evt: dict) -> "str | None": text += "]" return text + if evt_type == "async_delegation": + # Reuse the shared rich formatter (self-contained task-source block). + from tools.process_registry import format_process_notification + return format_process_notification(evt) + return None +def _drain_gateway_watch_events(completion_queue) -> "list[dict]": + """Drain gateway-owned watch events without spinning on requeued events. + + Watch events are handled by the post-turn gateway drain. Process + completions are owned by their per-process watcher task, and async + delegation completions are owned by ``_async_delegation_watcher``. + Requeueing async events inside ``while not queue.empty()`` would make the + loop non-terminating, so detach the current batch first, then requeue any + events this drain does not own after the queue is empty. + """ + watch_events: list[dict] = [] + requeue: list[dict] = [] + while not completion_queue.empty(): + try: + evt = completion_queue.get_nowait() + except Exception: + break + evt_type = evt.get("type", "completion") + if evt_type in {"watch_match", "watch_disabled"}: + watch_events.append(evt) + elif evt_type == "async_delegation": + requeue.append(evt) + # else: process completion events are handled by the watcher task + for evt in requeue: + completion_queue.put(evt) + return watch_events + + # Module-level weak reference to the active GatewayRunner instance. # Used by tools (e.g. send_message) that need to route through a live # adapter for plugin platforms. Set in GatewayRunner.__init__(). @@ -5353,6 +5386,12 @@ async def start(self) -> bool: # turn so the agent kicks off the new chat. asyncio.create_task(self._handoff_watcher()) + # Start background async-delegation watcher — drains completion events + # from delegate_task(background=true) subagents and injects each + # result back into its originating session as a new turn, covering the + # idle case where the subagent finishes with no agent turn running. + asyncio.create_task(self._async_delegation_watcher()) + logger.info("Press Ctrl+C to stop") return True @@ -5989,6 +6028,16 @@ def _kill_tool_subprocesses(phase: str) -> None: ) except Exception as _e: logger.debug("process_registry.kill_all (%s) error: %s", phase, _e) + try: + from tools.async_delegation import interrupt_all as _interrupt_async + _async_n = _interrupt_async(reason=f"gateway shutdown ({phase})") + if _async_n: + logger.info( + "Shutdown (%s): interrupted %d background delegation(s)", + phase, _async_n, + ) + except Exception as _e: + logger.debug("async interrupt_all (%s) error: %s", phase, _e) try: from tools.terminal_tool import cleanup_all_environments cleanup_all_environments() @@ -8995,18 +9044,17 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g logger.error("Process watcher setup error: %s", e) # Drain watch pattern notifications that arrived during the agent run. - # Watch events and completions share the same queue; completions are - # already handled by the per-process watcher task above, so we only - # inject watch-type events here. + # Watch events and completions share the same queue; process + # completions are already handled by the per-process watcher task + # above, so we only inject watch-type events here. + # + # Async-delegation completions ALSO ride this shared queue but are + # owned by the dedicated _async_delegation_watcher (started at + # boot), which covers both the idle and post-turn cases with a + # single consumer — so we leave them on the queue here. try: from tools.process_registry import process_registry as _pr - _watch_events = [] - while not _pr.completion_queue.empty(): - evt = _pr.completion_queue.get_nowait() - evt_type = evt.get("type", "completion") - if evt_type in {"watch_match", "watch_disabled"}: - _watch_events.append(evt) - # else: completion events are handled by the watcher task + _watch_events = _drain_gateway_watch_events(_pr.completion_queue) for evt in _watch_events: synth_text = _format_gateway_process_notification(evt) if synth_text: @@ -12265,6 +12313,74 @@ async def _inject_watch_notification(self, synth_text: str, evt: dict) -> None: except Exception as e: logger.error("Watch notification injection error: %s", e) + def _enrich_async_delegation_routing(self, evt: dict) -> None: + """Fill platform/chat_id/thread_id/chat_type on an async-delegation event. + + Async-delegation completion events only carry ``session_key`` (the + daemon worker has no access to the per-message routing metadata the + terminal background watcher captures at spawn time). Parse the + session_key into the routing fields ``_build_process_event_source`` + expects. Best-effort: a CLI-origin event (empty session_key) is left + as-is and simply won't route on the gateway. + """ + if evt.get("platform"): + return # already enriched + parsed = _parse_session_key(evt.get("session_key", "") or "") + if not parsed: + return + evt["platform"] = parsed.get("platform", "") + evt["chat_type"] = parsed.get("chat_type", "") + evt["chat_id"] = parsed.get("chat_id", "") + if parsed.get("thread_id"): + evt["thread_id"] = parsed["thread_id"] + + async def _async_delegation_watcher(self, interval: float = 2.0) -> None: + """Drain async-delegation completions and inject them as new turns. + + Background subagents (``delegate_task(background=true)``) run on the + async-delegation daemon executor — they have no per-process watcher + task, so their completion events would only be seen by the post-turn + queue drain. This watcher covers the IDLE case: when a background + subagent finishes while no agent turn is running, its result still + re-enters the originating session promptly. + + Mirrors the CLI's idle ``process_loop`` drain. Stays silent when the + queue has nothing for us; ignores non-async event types (those are + handled by ``_run_process_watcher`` / the post-turn drain). + """ + await asyncio.sleep(3) # let platforms finish connecting + from tools.process_registry import process_registry as _pr + while self._running: + try: + # Peek the queue for async-delegation events. We must NOT + # consume watch/completion events here (other drains own them), + # so requeue anything that isn't ours. + requeue = [] + async_events = [] + while not _pr.completion_queue.empty(): + try: + evt = _pr.completion_queue.get_nowait() + except Exception: + break + if evt.get("type") == "async_delegation": + async_events.append(evt) + else: + requeue.append(evt) + for evt in requeue: + _pr.completion_queue.put(evt) + for evt in async_events: + self._enrich_async_delegation_routing(evt) + synth_text = _format_gateway_process_notification(evt) + if not synth_text: + continue + try: + await self._inject_watch_notification(synth_text, evt) + except Exception as e: + logger.error("Async delegation injection error: %s", e) + except Exception as e: + logger.debug("Async delegation watcher error: %s", e) + await asyncio.sleep(interval) + async def _run_process_watcher(self, watcher: dict) -> None: """ Periodically check a background process and push updates to the user. diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index b52c6de802e98..499f8e9a1a5aa 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -225,7 +225,8 @@ def _handle_snapshot_command(self, command: str): print(" Usage: /snapshot [list|create [label]|restore |prune [N]]") def _handle_stop_command(self): - """Handle /stop — kill all running background processes. + """Handle /stop — kill all running background processes and + background (async) delegations. Inspired by OpenAI Codex's separation of interrupt (stop current turn) from /stop (clean up background processes). See openai/codex#14602. @@ -235,13 +236,26 @@ def _handle_stop_command(self): processes = process_registry.list_sessions() running = [p for p in processes if p.get("status") == "running"] - if not running: + # Background subagents dispatched via delegate_task(background=true) + # live in their own registry, not the process registry. + try: + from tools.async_delegation import active_count, interrupt_all + n_async = active_count() + except Exception: + n_async = 0 + interrupt_all = None + + if not running and not n_async: print(" No running background processes.") return - print(f" Stopping {len(running)} background process(es)...") - killed = process_registry.kill_all() - print(f" ✅ Stopped {killed} process(es).") + if running: + print(f" Stopping {len(running)} background process(es)...") + killed = process_registry.kill_all() + print(f" ✅ Stopped {killed} process(es).") + if n_async and interrupt_all is not None: + stopped = interrupt_all(reason="/stop") + print(f" ✅ Interrupted {stopped} background delegation(s).") def _handle_agents_command(self): """Handle /agents — show background processes and agent status.""" @@ -261,6 +275,22 @@ def _handle_agents_command(self): if finished: _cprint(f" Recently finished: {len(finished)}") + # Background (async) delegations — delegate_task(background=true) + try: + from tools.async_delegation import list_async_delegations + delegations = list_async_delegations() + except Exception: + delegations = [] + running_d = [d for d in delegations if d.get("status") == "running"] + if delegations: + _cprint(f" Background delegations: {len(running_d)} running") + for d in delegations: + goal = (d.get("goal") or "")[:60] + _cprint( + f" {d.get('delegation_id', '?')} · " + f"{d.get('status', '?')} · {goal}" + ) + agent_running = getattr(self, "_agent_running", False) _cprint(f" Agent: {'running' if agent_running else 'idle'}") diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7ee1f8690c6fe..3a09825620450 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1775,6 +1775,7 @@ def _ensure_hermes_home_managed(home: Path): "reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium", # "low", "minimal", "none" (empty = inherit parent's level) "max_concurrent_children": 3, # max parallel children per batch; floor of 1 enforced, no ceiling + "max_async_children": 3, # max concurrent background (background=true) subagents; new dispatches rejected at capacity # Orchestrator role controls (see tools/delegate_tool.py:_get_max_spawn_depth # and _get_orchestrator_enabled). Floored at 1, no upper ceiling — # raise deliberately, each level multiplies API cost. diff --git a/tests/tools/test_async_delegation.py b/tests/tools/test_async_delegation.py new file mode 100644 index 0000000000000..5dbecfc4bf591 --- /dev/null +++ b/tests/tools/test_async_delegation.py @@ -0,0 +1,473 @@ +"""Tests for async (background) delegation — tools/async_delegation.py. + +Covers the dispatch handle, non-blocking behavior, completion-event delivery +onto the shared process_registry.completion_queue, the rich re-injection block +formatting, capacity rejection, and crash handling. +""" + +import queue +import threading +import time + +import pytest + +from tools import async_delegation as ad +from tools.process_registry import process_registry, format_process_notification + + +@pytest.fixture(autouse=True) +def _clean_state(): + ad._reset_for_tests() + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + yield + ad._reset_for_tests() + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + +def _drain_one(timeout=5.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not process_registry.completion_queue.empty(): + return process_registry.completion_queue.get_nowait() + time.sleep(0.02) + return None + + +def test_dispatch_returns_immediately_without_blocking(): + gate = threading.Event() + + def runner(): + gate.wait(timeout=5) + return {"status": "completed", "summary": "done", "api_calls": 1, + "duration_seconds": 0.1, "model": "m"} + + t0 = time.monotonic() + res = ad.dispatch_async_delegation( + goal="g", context=None, toolsets=None, role="leaf", model="m", + session_key="", runner=runner, max_async_children=3, + ) + elapsed = time.monotonic() - t0 + + assert res["status"] == "dispatched" + assert res["delegation_id"].startswith("deleg_") + # Non-blocking invariant: dispatch returned while the runner is still + # gated (active), so it cannot have waited on the gate. The active_count + # check is the environment-independent proof; the generous wall-clock + # bound is a loose sanity backstop, not the primary assertion (a loaded + # CI runner can be slow but never anywhere near the runner's 5s gate). + assert ad.active_count() == 1 + assert elapsed < 4.0, f"dispatch blocked {elapsed:.2f}s (gate is 5s)" + gate.set() + + +def test_async_executor_workers_are_daemon_threads(): + gate = threading.Event() + + def runner(): + gate.wait(timeout=5) + return {"status": "completed", "summary": "done"} + + res = ad.dispatch_async_delegation( + goal="daemon check", context=None, toolsets=None, role="leaf", model="m", + session_key="", runner=runner, max_async_children=1, + ) + assert res["status"] == "dispatched" + + deadline = time.monotonic() + 2 + worker = None + while time.monotonic() < deadline: + worker = next( + (t for t in threading.enumerate() if t.name.startswith("async-delegate")), + None, + ) + if worker is not None: + break + time.sleep(0.02) + assert worker is not None + assert worker.daemon is True + gate.set() + assert _drain_one() is not None + + +def test_completion_event_lands_on_shared_queue_with_session_key(): + def runner(): + return {"status": "completed", "summary": "the result", + "api_calls": 3, "duration_seconds": 2.0, "model": "test-model"} + + res = ad.dispatch_async_delegation( + goal="compute X", context="some context", toolsets=["web", "file"], + role="leaf", model="test-model", session_key="agent:main:cli:dm:local", + runner=runner, max_async_children=3, + ) + assert res["status"] == "dispatched" + + evt = _drain_one() + assert evt is not None + assert evt["type"] == "async_delegation" + assert evt["summary"] == "the result" + assert evt["session_key"] == "agent:main:cli:dm:local" + assert evt["delegation_id"] == res["delegation_id"] + + +def test_rich_reinjection_block_is_self_contained(): + def runner(): + return {"status": "completed", "summary": "The answer is 42.", + "api_calls": 7, "duration_seconds": 3.5, "model": "test-model"} + + ad.dispatch_async_delegation( + goal="Compute the meaning of life", + context="User is a philosopher. Respond tersely.", + toolsets=["web"], role="leaf", model="test-model", + session_key="", runner=runner, max_async_children=3, + ) + evt = _drain_one() + assert evt is not None + text = format_process_notification(evt) + assert text is not None + for needle in [ + "ASYNC DELEGATION COMPLETE", + "Compute the meaning of life", + "User is a philosopher", + "Toolsets: web", + "The answer is 42.", + "Status: completed", + "API calls: 7", + ]: + assert needle in text, f"missing {needle!r}" + + +def test_dispatch_rejected_at_capacity(): + ev = threading.Event() + + def blocker(): + ev.wait(timeout=5) + return {"status": "completed", "summary": "x"} + + for i in range(2): + r = ad.dispatch_async_delegation( + goal=f"task{i}", context=None, toolsets=None, role="leaf", + model="m", session_key="", runner=blocker, max_async_children=2, + ) + assert r["status"] == "dispatched" + + r3 = ad.dispatch_async_delegation( + goal="task3", context=None, toolsets=None, role="leaf", model="m", + session_key="", runner=blocker, max_async_children=2, + ) + assert r3["status"] == "rejected" + assert "capacity reached" in r3["error"] + ev.set() + + +def test_crashed_runner_produces_error_completion(): + def boom(): + raise RuntimeError("subagent exploded") + + r = ad.dispatch_async_delegation( + goal="risky", context=None, toolsets=None, role="leaf", model="m", + session_key="", runner=boom, max_async_children=3, + ) + assert r["status"] == "dispatched" + evt = _drain_one() + assert evt is not None + assert evt["status"] == "error" + text = format_process_notification(evt) + assert text is not None + assert "did not complete successfully" in text + assert "subagent exploded" in text + + +def test_interrupt_all_signals_running_children(): + ev = threading.Event() + interrupted = {"count": 0} + + def blocker(): + ev.wait(timeout=5) + return {"status": "interrupted", "summary": None, + "error": "cancelled"} + + def interrupt_fn(): + interrupted["count"] += 1 + ev.set() + + ad.dispatch_async_delegation( + goal="long task", context=None, toolsets=None, role="leaf", + model="m", session_key="", runner=blocker, + interrupt_fn=interrupt_fn, max_async_children=3, + ) + n = ad.interrupt_all(reason="test") + assert n == 1 + assert interrupted["count"] == 1 + # child still emits a completion event after interrupt + evt = _drain_one() + assert evt is not None + assert evt["status"] == "interrupted" + + +def test_completed_records_pruned_to_cap(): + # Run more than the retention cap quickly; ensure list doesn't grow forever. + for i in range(ad._MAX_RETAINED_COMPLETED + 10): + ad.dispatch_async_delegation( + goal=f"t{i}", context=None, toolsets=None, role="leaf", model="m", + session_key="", runner=lambda: {"status": "completed", "summary": "ok"}, + max_async_children=ad._MAX_RETAINED_COMPLETED + 20, + ) + # let workers finish + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and ad.active_count() > 0: + time.sleep(0.05) + assert len(ad.list_async_delegations()) <= ad._MAX_RETAINED_COMPLETED + + +# --------------------------------------------------------------------------- +# Integration: delegate_task(background=True) routing +# --------------------------------------------------------------------------- + +def test_delegate_task_background_routes_async_and_does_not_block(monkeypatch): + """delegate_task(background=True) returns a handle without running the + child synchronously, and the child completes on the background thread.""" + from unittest.mock import MagicMock, patch + import tools.delegate_tool as dt + + parent = MagicMock() + parent._delegate_depth = 0 + parent.session_id = "sess" + parent._interrupt_requested = False + fake_child = MagicMock() + fake_child._delegate_role = "leaf" + fake_child._subagent_id = "s1" + + gate = threading.Event() + + def slow_child(task_index, goal, child=None, parent_agent=None, **kw): + gate.wait(timeout=5) # a sync impl would hang delegate_task here + return { + "task_index": 0, "status": "completed", "summary": f"done: {goal}", + "api_calls": 1, "duration_seconds": 0.1, "model": "m", + "exit_reason": "completed", + } + + creds = { + "model": "m", "provider": None, "base_url": None, "api_key": None, + "api_mode": None, "command": None, "args": None, + } + with patch.object(dt, "_build_child_agent", return_value=fake_child), \ + patch.object(dt, "_run_single_child", side_effect=slow_child), \ + patch.object(dt, "_resolve_delegation_credentials", return_value=creds): + out = dt.delegate_task( + goal="the real task", context="ctx", toolsets=["web"], + background=True, parent_agent=parent, + ) + + import json + parsed = json.loads(out) + assert parsed["status"] == "dispatched" + assert parsed["mode"] == "background" + assert parsed["delegation_id"].startswith("deleg_") + # The real non-blocking invariant (environment-independent — no wall-clock + # threshold that flakes on a loaded CI runner): delegate_task returned + # while the child is STILL blocked on the closed gate, so no completion + # event exists yet. A synchronous impl could not have returned here — it + # would still be inside slow_child waiting on the gate. + assert process_registry.completion_queue.empty() + assert ad.active_count() == 1 # child running in background, not finished + + gate.set() + evt = _drain_one() + assert evt is not None + assert evt["type"] == "async_delegation" + assert evt["summary"] == "done: the real task" + text = format_process_notification(evt) + assert text is not None + assert "the real task" in text and "ctx" in text + + +def test_delegate_task_background_rejects_batch(monkeypatch): + """background=True with a multi-item tasks batch is rejected (v1: single-task only).""" + import json + from unittest.mock import MagicMock + import tools.delegate_tool as dt + + parent = MagicMock() + parent._delegate_depth = 0 + parent.session_id = "sess" + + out = dt.delegate_task( + tasks=[{"goal": "a"}, {"goal": "b"}], + background=True, + parent_agent=parent, + ) + parsed = json.loads(out) + assert "error" in parsed + assert "single-task only" in parsed["error"] + + +def test_delegate_task_background_detaches_child_from_parent(monkeypatch): + """A background child must NOT remain in parent._active_children — + otherwise parent-turn interrupts / cache evicts / session close would + kill the detached subagent mid-run.""" + from unittest.mock import MagicMock, patch + import tools.delegate_tool as dt + + parent = MagicMock() + parent._delegate_depth = 0 + parent.session_id = "sess" + parent._active_children = [] + parent._active_children_lock = threading.Lock() + fake_child = MagicMock() + fake_child._delegate_role = "leaf" + fake_child._subagent_id = "s1" + + gate = threading.Event() + + def slow_child(task_index, goal, child=None, parent_agent=None, **kw): + gate.wait(timeout=5) + return {"task_index": 0, "status": "completed", "summary": "ok"} + + def build_and_register(**kw): + # Mirror what the real _build_child_agent does: register the child + # for interrupt propagation. + parent._active_children.append(fake_child) + return fake_child + + creds = { + "model": "m", "provider": None, "base_url": None, "api_key": None, + "api_mode": None, "command": None, "args": None, + } + with patch.object(dt, "_build_child_agent", side_effect=build_and_register), \ + patch.object(dt, "_run_single_child", side_effect=slow_child), \ + patch.object(dt, "_resolve_delegation_credentials", return_value=creds): + out = dt.delegate_task(goal="bg task", background=True, parent_agent=parent) + + import json + assert json.loads(out)["status"] == "dispatched" + # Child detached immediately at dispatch, while it is still running. + assert fake_child not in parent._active_children + gate.set() + assert _drain_one() is not None + + +def test_concurrent_dispatch_respects_capacity(): + """Two threads racing dispatch with cap=1 must yield exactly one accept + (capacity check and record insert are atomic under the records lock).""" + gate = threading.Event() + + def blocker(): + gate.wait(timeout=5) + return {"status": "completed", "summary": "x"} + + results = [] + barrier = threading.Barrier(2) + + def racer(): + barrier.wait(timeout=5) + results.append( + ad.dispatch_async_delegation( + goal="race", context=None, toolsets=None, role="leaf", + model="m", session_key="", runner=blocker, + max_async_children=1, + ) + ) + + threads = [threading.Thread(target=racer) for _ in range(2)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10) + statuses = sorted(r["status"] for r in results) + assert statuses == ["dispatched", "rejected"] + gate.set() + + +# --------------------------------------------------------------------------- +# Gateway routing: session_key -> platform/chat_id, rich formatting, injection +# --------------------------------------------------------------------------- + +def _make_async_evt(**over): + evt = { + "type": "async_delegation", + "delegation_id": "deleg_x1", + "session_key": "agent:main:telegram:dm:12345:678", + "goal": "Investigate flaky test", + "context": "repo /tmp/p", + "toolsets": ["terminal"], + "role": "leaf", + "model": "m", + "status": "completed", + "summary": "Found the bug in test_foo", + "api_calls": 4, + "duration_seconds": 12.0, + "dispatched_at": 1000.0, + "completed_at": 1012.0, + } + evt.update(over) + return evt + + +def test_gateway_enriches_routing_from_session_key(): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + evt = _make_async_evt() + runner._enrich_async_delegation_routing(evt) + assert evt["platform"] == "telegram" + assert evt["chat_id"] == "12345" + assert evt["thread_id"] == "678" + + +def test_gateway_formatter_renders_async_block(): + from gateway.run import _format_gateway_process_notification + + txt = _format_gateway_process_notification(_make_async_evt()) + assert txt is not None + assert "ASYNC DELEGATION COMPLETE" in txt + assert "Found the bug in test_foo" in txt + assert "Investigate flaky test" in txt + + +def test_gateway_watch_drain_requeues_async_without_looping(): + from gateway.run import _drain_gateway_watch_events + + q = queue.Queue() + async_evt = _make_async_evt() + watch_evt = { + "type": "watch_match", + "session_id": "proc_1", + "command": "pytest", + "pattern": "READY", + "output": "READY", + } + q.put(async_evt) + q.put(watch_evt) + + watch_events = _drain_gateway_watch_events(q) + + assert watch_events == [watch_evt] + assert q.qsize() == 1 + assert q.get_nowait() == async_evt + + +def test_gateway_builds_routable_source_from_enriched_event(): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + evt = _make_async_evt() + runner._enrich_async_delegation_routing(evt) + src = runner._build_process_event_source(evt) + assert src is not None + assert src.platform.value == "telegram" + assert src.chat_id == "12345" + + +def test_gateway_cli_origin_event_left_unrouted(): + """An empty session_key (CLI origin) is left without routing fields.""" + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + evt = _make_async_evt(session_key="") + runner._enrich_async_delegation_routing(evt) + assert "platform" not in evt + + diff --git a/tools/async_delegation.py b/tools/async_delegation.py new file mode 100644 index 0000000000000..5975e9b1385f6 --- /dev/null +++ b/tools/async_delegation.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +""" +Async (background) delegation registry. + +Backs ``delegate_task(background=true)``: the parent agent dispatches a +subagent that runs on a module-level daemon executor and returns a handle +immediately, so the user and the model can keep working while the child runs. + +When the child finishes, a completion event is pushed onto the SHARED +``process_registry.completion_queue`` with ``type="async_delegation"``. The +CLI (``cli.py`` process_loop) and gateway (``_run_process_watcher`` / +``completion_queue`` drain) already poll that queue while the agent is idle +and forge a fresh user/internal turn from each event. We deliberately reuse +that rail rather than reaching into a running agent loop: + + - completions surface as a NEW turn when the agent is idle, never spliced + between a tool result and an assistant message. That keeps strict + message-role alternation legal and the prompt cache intact (hard + invariant: never mutate past context). + - we inherit the queue's de-dup, crash-recovery checkpoint, and the + existing CLI + gateway drain wiring for free — no new drain loops in the + two largest files in the repo. + +The completion payload carries a RICH, self-contained task-source block (the +original goal, the context the parent supplied, toolsets, model, dispatch +time, status, and the full result summary). When the result re-enters the +conversation the parent may be deep in unrelated context and won't remember +why the subagent existed; the block lets it either use the result or +re-dispatch if the world has moved on. + +This module owns ONLY the async lifecycle. The actual child build + run is +delegated back to ``delegate_tool._run_single_child`` via an injected +runner, so all the credential leasing, heartbeat, timeout, and result-shaping +logic stays in one place. +""" + +from __future__ import annotations + +import logging +import threading +import time +import uuid +import weakref +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures.thread import _worker +from typing import Any, Callable, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +class _DaemonThreadPoolExecutor(ThreadPoolExecutor): + """ThreadPoolExecutor variant whose workers do not block process exit. + + Stdlib ``ThreadPoolExecutor`` workers are non-daemon. Background + delegation is explicitly best-effort detached work, so a long child should + be interruptible by ``/stop``/shutdown but must not keep a CLI process alive + after the user exits. + """ + + def _adjust_thread_count(self) -> None: + if self._idle_semaphore.acquire(timeout=0): + return + + def weakref_cb(_, q=self._work_queue): + q.put(None) + + num_threads = len(self._threads) + if num_threads < self._max_workers: + thread_name = "%s_%d" % (self._thread_name_prefix or self, num_threads) + t = threading.Thread( + name=thread_name, + target=_worker, + args=( + weakref.ref(self, weakref_cb), + self._work_queue, + self._initializer, + self._initargs, + ), + daemon=True, + ) + t.start() + self._threads.add(t) + + +# --------------------------------------------------------------------------- +# Module-level state +# --------------------------------------------------------------------------- +# A persistent daemon executor (NOT a `with ThreadPoolExecutor()` block, which +# would join on exit and defeat the whole point of async). Workers are daemon +# threads so a hard process exit doesn't hang on an in-flight child. +_executor: Optional[ThreadPoolExecutor] = None +_executor_lock = threading.Lock() +_executor_max_workers: int = 0 + +_records_lock = threading.Lock() +# delegation_id -> record dict. Kept for the lifetime of the run plus a short +# tail after completion so `list_async_delegations()` can show recent results. +_records: Dict[str, Dict[str, Any]] = {} + +_DEFAULT_MAX_ASYNC_CHILDREN = 3 +# How many completed records to retain for status queries before pruning. +_MAX_RETAINED_COMPLETED = 50 + + +def _get_executor(max_workers: int) -> ThreadPoolExecutor: + """Lazily create (or grow) the shared daemon executor. + + We never shrink — ThreadPoolExecutor can't resize — but if the configured + cap grows between calls we rebuild a larger pool. Existing in-flight + futures keep running on the old pool until it's garbage collected. + """ + global _executor, _executor_max_workers + with _executor_lock: + if _executor is None or max_workers > _executor_max_workers: + # Daemon threads: thread_name_prefix aids debugging in stack dumps. + _executor = _DaemonThreadPoolExecutor( + max_workers=max_workers, + thread_name_prefix="async-delegate", + ) + _executor_max_workers = max_workers + return _executor + + +def active_count() -> int: + """Number of async delegations currently running.""" + with _records_lock: + return sum(1 for r in _records.values() if r.get("status") == "running") + + +def _new_delegation_id() -> str: + return f"deleg_{uuid.uuid4().hex[:8]}" + + +def _prune_completed_locked() -> None: + """Drop the oldest completed records beyond the retention cap. + + Caller must hold ``_records_lock``. + """ + completed = [ + (rid, r) + for rid, r in _records.items() + if r.get("status") != "running" + ] + if len(completed) <= _MAX_RETAINED_COMPLETED: + return + # Oldest-first by completion time (fall back to dispatch time). + completed.sort(key=lambda kv: kv[1].get("completed_at") or kv[1].get("dispatched_at") or 0) + for rid, _ in completed[: len(completed) - _MAX_RETAINED_COMPLETED]: + _records.pop(rid, None) + + +def dispatch_async_delegation( + *, + goal: str, + context: Optional[str], + toolsets: Optional[List[str]], + role: str, + model: Optional[str], + session_key: str, + runner: Callable[[], Dict[str, Any]], + interrupt_fn: Optional[Callable[[], None]] = None, + max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN, +) -> Dict[str, Any]: + """Spawn ``runner`` on the daemon executor and return a handle immediately. + + Parameters + ---------- + goal, context, toolsets, role, model + The dispatch-time task spec, captured verbatim for the rich + completion block. + session_key + The gateway session_key (from ``tools.approval.get_current_session_key``) + captured on the parent thread BEFORE dispatch, because the daemon + worker thread won't carry the contextvar. Used to route the + completion back to the originating session. + runner + Zero-arg callable that builds + runs the child and returns the same + result dict ``_run_single_child`` produces. Runs on the worker thread. + interrupt_fn + Optional callable to signal the child to stop (used on shutdown / + explicit cancel). + max_async_children + Concurrency cap. When at capacity the dispatch is REJECTED (the caller + should fall back to sync or tell the user) rather than queued, so a + runaway model can't pile up unbounded background work. + + Returns + ------- + dict + ``{"status": "dispatched", "delegation_id": ...}`` on success, or + ``{"status": "rejected", "error": ...}`` when at capacity. + """ + delegation_id = _new_delegation_id() + dispatched_at = time.time() + record: Dict[str, Any] = { + "delegation_id": delegation_id, + "goal": goal, + "context": context, + "toolsets": list(toolsets) if toolsets else None, + "role": role, + "model": model, + "session_key": session_key, + "status": "running", + "dispatched_at": dispatched_at, + "completed_at": None, + "interrupt_fn": interrupt_fn, + } + # Capacity check and record insert under ONE lock hold — checking + # active_count() separately would let two concurrent dispatches (e.g. + # from different gateway sessions) both pass the check and exceed the cap. + with _records_lock: + running = sum( + 1 for r in _records.values() if r.get("status") == "running" + ) + if running >= max_async_children: + return { + "status": "rejected", + "error": ( + f"Async delegation capacity reached ({max_async_children} " + f"running). Wait for one to finish (its result will re-enter " + f"the chat), or run this task synchronously " + f"(background=false). Raise delegation.max_async_children in " + f"config.yaml to allow more concurrent background subagents." + ), + } + _records[delegation_id] = record + + executor = _get_executor(max_async_children) + + def _worker() -> None: + result: Dict[str, Any] = {} + status = "error" + try: + result = runner() or {} + status = result.get("status") or "completed" + except Exception as exc: # noqa: BLE001 — must never crash the worker + logger.exception("Async delegation %s crashed", delegation_id) + result = { + "status": "error", + "summary": None, + "error": f"{type(exc).__name__}: {exc}", + "api_calls": 0, + "duration_seconds": round(time.time() - dispatched_at, 2), + } + status = "error" + finally: + _finalize(delegation_id, result, status) + + try: + executor.submit(_worker) + except Exception as exc: # pragma: no cover — pool submit failure is rare + with _records_lock: + _records.pop(delegation_id, None) + return { + "status": "rejected", + "error": f"Failed to schedule async delegation: {exc}", + } + + logger.info( + "Dispatched async delegation %s (session_key=%s): %s", + delegation_id, session_key or "", (goal or "")[:80], + ) + return {"status": "dispatched", "delegation_id": delegation_id} + + +def _finalize(delegation_id: str, result: Dict[str, Any], status: str) -> None: + """Mark a record complete and push the completion event onto the queue.""" + with _records_lock: + record = _records.get(delegation_id) + if record is None: + return + record["status"] = status + record["completed_at"] = time.time() + record["interrupt_fn"] = None # drop the closure; child is done + # Snapshot fields needed for the event while holding the lock. + event_record = dict(record) + _prune_completed_locked() + + _push_completion_event(event_record, result, status) + + +def _push_completion_event( + record: Dict[str, Any], result: Dict[str, Any], status: str +) -> None: + """Push a type='async_delegation' event onto the shared completion queue. + + Best-effort: a failure here must not crash the worker, but it WOULD mean a + silently-lost result, so we log loudly. + """ + try: + from tools.process_registry import process_registry + except Exception as exc: # pragma: no cover + logger.error( + "Async delegation %s finished but process_registry import failed; " + "result lost: %s", + record.get("delegation_id"), exc, + ) + return + + summary = result.get("summary") + error = result.get("error") + dispatched_at = record.get("dispatched_at") or time.time() + completed_at = record.get("completed_at") or time.time() + + evt = { + "type": "async_delegation", + "delegation_id": record.get("delegation_id"), + # session_key routes the completion back to the originating gateway + # session; empty string => CLI (single-session) path. + "session_key": record.get("session_key", ""), + "goal": record.get("goal", ""), + "context": record.get("context"), + "toolsets": record.get("toolsets"), + "role": record.get("role"), + "model": result.get("model") or record.get("model"), + "status": status, + "summary": summary, + "error": error, + "api_calls": result.get("api_calls", 0), + "duration_seconds": result.get( + "duration_seconds", round(completed_at - dispatched_at, 2) + ), + "dispatched_at": dispatched_at, + "completed_at": completed_at, + "exit_reason": result.get("exit_reason"), + } + try: + process_registry.completion_queue.put(evt) + except Exception as exc: # pragma: no cover + logger.error( + "Async delegation %s: failed to enqueue completion event; " + "result lost: %s", + record.get("delegation_id"), exc, + ) + + +def list_async_delegations() -> List[Dict[str, Any]]: + """Snapshot of async delegations (running + recently completed). + + Safe to call from any thread. Excludes the non-serialisable interrupt_fn. + """ + with _records_lock: + return [ + {k: v for k, v in r.items() if k != "interrupt_fn"} + for r in _records.values() + ] + + +def interrupt_all(reason: str = "shutdown") -> int: + """Signal every running async delegation to stop. Returns how many. + + Used on ``/stop`` and gateway shutdown so a dangling background subagent + can't keep burning tokens with no one listening. The child still emits a + completion event (status='interrupted') via the normal finalize path. + """ + count = 0 + with _records_lock: + targets = [ + r for r in _records.values() if r.get("status") == "running" + ] + for r in targets: + fn = r.get("interrupt_fn") + if callable(fn): + try: + fn() + count += 1 + except Exception as exc: + logger.debug( + "interrupt_all: %s interrupt failed: %s", + r.get("delegation_id"), exc, + ) + if count: + logger.info("Interrupted %d async delegation(s) (%s)", count, reason) + return count + + +def _reset_for_tests() -> None: + """Test-only: clear all state and tear down the executor.""" + global _executor, _executor_max_workers + with _executor_lock: + if _executor is not None: + _executor.shutdown(wait=False) + _executor = None + _executor_max_workers = 0 + with _records_lock: + _records.clear() diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index fb17c537b9874..7fc82c72fea6d 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -397,6 +397,38 @@ def _get_max_concurrent_children() -> int: return _DEFAULT_MAX_CONCURRENT_CHILDREN +_DEFAULT_MAX_ASYNC_CHILDREN = 3 + + +def _get_max_async_children() -> int: + """Read delegation.max_async_children from config (floor 1, no ceiling). + + Caps how many background (``background=true``) subagents can run at once. + When at capacity, a new async dispatch is REJECTED (not queued) so a + runaway model can't pile up unbounded background work. Separate from + max_concurrent_children, which bounds a single synchronous batch. + """ + cfg = _load_config() + val = cfg.get("max_async_children") + if val is not None: + try: + return max(1, int(val)) + except (TypeError, ValueError): + logger.warning( + "delegation.max_async_children=%r is not a valid integer; " + "using default %d", + val, _DEFAULT_MAX_ASYNC_CHILDREN, + ) + return _DEFAULT_MAX_ASYNC_CHILDREN + env_val = os.getenv("DELEGATION_MAX_ASYNC_CHILDREN") + if env_val: + try: + return max(1, int(env_val)) + except (TypeError, ValueError): + return _DEFAULT_MAX_ASYNC_CHILDREN + return _DEFAULT_MAX_ASYNC_CHILDREN + + def _get_child_timeout() -> Optional[float]: """Read delegation.child_timeout_seconds from config. @@ -2018,6 +2050,7 @@ def delegate_task( acp_command: Optional[str] = None, acp_args: Optional[List[str]] = None, role: Optional[str] = None, + background: Optional[bool] = None, parent_agent=None, ) -> str: """ @@ -2049,6 +2082,19 @@ def delegate_task( # Normalise the top-level role once; per-task overrides re-normalise. top_role = _normalize_role(role) + # Async (background) delegation is single-task only in v1. A batch carries + # fan-out semantics (N handles, partial completion) that double the state + # model — reject early with a clear message rather than silently running + # the batch synchronously. + background = is_truthy_value(background, default=False) if background is not None else False + if background and tasks and isinstance(tasks, list) and len(tasks) > 1: + return tool_error( + "background=true is single-task only. Dispatch one background " + "subagent per delegate_task call (each returns its own handle and " + "re-enters the conversation independently), or run the batch " + "synchronously with background=false." + ) + # Depth limit — configurable via delegation.max_spawn_depth, # default 2 for parity with the original MAX_DEPTH constant. depth = getattr(parent_agent, "_delegate_depth", 0) @@ -2186,6 +2232,90 @@ def delegate_task( if n_tasks == 1: # Single task -- run directly (no thread pool overhead) _i, _t, child = children[0] + + # ----- Async / background dispatch ----- + # When background=true, hand the already-built child to the async + # delegation registry and return a handle immediately. The child runs + # on a daemon executor; its result re-enters the conversation as a + # fresh turn via process_registry.completion_queue (see + # tools/async_delegation.py). Batch async is intentionally NOT + # supported in v1 — the rejection is handled before we get here. + if background: + from tools.async_delegation import dispatch_async_delegation + from tools.approval import get_current_session_key + + # Capture the gateway routing key on THIS (parent) thread — the + # daemon worker won't carry the session contextvar. + _session_key = get_current_session_key(default="") + + # Detach the child from the parent's interrupt-propagation list. + # _build_child_agent registered it there (correct for sync + # children, which block the parent's turn), but a BACKGROUND + # child must survive parent-turn interrupts (Ctrl+C, mid-turn + # steering), cache evicts (release_clients), and session close + # (/new) — otherwise the detached subagent dies with whatever + # the parent was doing when it was dispatched. Its lifecycle is + # owned by the async-delegation registry (interrupt_fn below), + # and _run_single_child's finally block closes its resources + # when it finishes. + if hasattr(parent_agent, "_active_children"): + try: + _ac_lock = getattr(parent_agent, "_active_children_lock", None) + if _ac_lock: + with _ac_lock: + parent_agent._active_children.remove(child) + else: + parent_agent._active_children.remove(child) + except ValueError: + pass + + def _async_runner(_child=child, _goal=_t["goal"]): + return _run_single_child(0, _goal, _child, parent_agent) + + def _async_interrupt(_child=child): + try: + if hasattr(_child, "interrupt"): + _child.interrupt("Async delegation cancelled") + elif hasattr(_child, "_interrupt_requested"): + _child._interrupt_requested = True + except Exception: + pass + + dispatch = dispatch_async_delegation( + goal=_t["goal"], + context=_t.get("context"), + toolsets=_t.get("toolsets") or toolsets, + role=_normalize_role(_t.get("role") or top_role), + model=creds["model"], + session_key=_session_key, + runner=_async_runner, + interrupt_fn=_async_interrupt, + max_async_children=_get_max_async_children(), + ) + + if dispatch.get("status") == "dispatched": + return json.dumps( + { + "status": "dispatched", + "delegation_id": dispatch["delegation_id"], + "goal": _t["goal"], + "mode": "background", + "note": ( + "Subagent is running in the background. You and the " + "user can keep working; the full task source and " + "result will re-enter the conversation as a new " + "message when it finishes. Do not wait or poll — " + "just continue." + ), + }, + ensure_ascii=False, + ) + # Rejected (at capacity or schedule failure) — surface as a tool + # error so the model can fall back to synchronous delegation. + return tool_error( + dispatch.get("error", "Async delegation could not be scheduled.") + ) + result = _run_single_child(0, _t["goal"], child, parent_agent) results.append(result) else: @@ -2904,6 +3034,24 @@ def _build_dynamic_schema_overrides() -> dict: "enum": ["leaf", "orchestrator"], "description": "(rebuilt at get_definitions() time)", }, + "background": { + "type": "boolean", + "description": ( + "Run the subagent asynchronously in the BACKGROUND " + "instead of blocking this turn. When true, delegate_task " + "returns immediately with a delegation_id; you and the " + "user keep working while the subagent runs, and its full " + "result re-enters the conversation as a new message when " + "it finishes (similar to terminal background=true + " + "notify_on_complete). The re-injected message includes the " + "original goal/context so you can act on it even after " + "moving on. Single-task only — cannot be combined with the " + "'tasks' batch array. Use for long-running independent work " + "the user shouldn't have to wait on (research, builds, " + "multi-step investigations). Do NOT poll or wait after " + "dispatching — just continue; the result will come to you." + ), + }, "acp_command": { "type": "string", "description": ( @@ -2948,6 +3096,7 @@ def _build_dynamic_schema_overrides() -> dict: acp_command=args.get("acp_command"), acp_args=args.get("acp_args"), role=args.get("role"), + background=args.get("background"), parent_agent=kw.get("parent_agent"), ), check_fn=check_delegate_requirements, diff --git a/tools/process_registry.py b/tools/process_registry.py index 6c3d61ce5f47e..e9f3276ffb6ad 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -1531,6 +1531,91 @@ def recover_from_checkpoint(self) -> int: process_registry = ProcessRegistry() +def _format_age(seconds: float) -> str: + """Human-friendly elapsed string ('18m', '2h3m', '45s').""" + try: + s = int(max(0, seconds)) + except (TypeError, ValueError): + return "?" + if s < 60: + return f"{s}s" + m, s = divmod(s, 60) + if m < 60: + return f"{m}m" if s == 0 else f"{m}m{s}s" + h, m = divmod(m, 60) + return f"{h}h" if m == 0 else f"{h}h{m}m" + + +def _format_async_delegation(evt: dict) -> str: + """Format an async-delegation completion into a self-contained re-injection. + + Carries the FULL original task source (goal, the context the parent + supplied, toolsets, role, model) plus dispatch time, status, and the + complete result summary. When this re-enters the conversation the agent + may be deep in unrelated context and won't remember why the subagent + existed, so the block is written to stand entirely on its own — enough to + use the result OR re-dispatch if the world has moved on. + """ + import time as _time + + deleg_id = evt.get("delegation_id", "unknown") + goal = evt.get("goal", "") or "" + context = evt.get("context") + toolsets = evt.get("toolsets") + role = evt.get("role") or "leaf" + model = evt.get("model") or "?" + status = evt.get("status") or "completed" + summary = evt.get("summary") + error = evt.get("error") + api_calls = evt.get("api_calls", 0) + duration = evt.get("duration_seconds", "?") + dispatched_at = evt.get("dispatched_at") + completed_at = evt.get("completed_at") or _time.time() + + age = "" + if isinstance(dispatched_at, (int, float)): + age = f" ({_format_age(completed_at - dispatched_at)} ago)" + + lines = [ + f"[ASYNC DELEGATION COMPLETE — {deleg_id}]", + "A background subagent you dispatched earlier has finished. You may " + "have moved on since dispatching it; the full task source is below so " + "you can act on the result or re-dispatch if things have changed.", + "", + ] + if isinstance(dispatched_at, (int, float)): + ts = _time.strftime("%Y-%m-%d %H:%M:%S", _time.localtime(dispatched_at)) + lines.append(f"Dispatched: {ts}{age}") + lines.append(f"Original goal: {goal}") + if context: + lines.append(f"Context you provided: {context}") + if toolsets: + lines.append(f"Toolsets: {', '.join(toolsets)}") + lines.append(f"Role: {role} Model: {model}") + lines.append(f"Status: {status} API calls: {api_calls} Duration: {duration}s") + lines.append("--- RESULT ---") + if status in ("completed", "success") and summary: + lines.append(summary) + elif status == "interrupted": + lines.append( + "The subagent was interrupted before completing" + + (f": {error}" if error else ".") + ) + if summary: + lines.append("Partial output:") + lines.append(summary) + else: + # error / timeout / failed + lines.append( + f"The subagent did not complete successfully (status={status})." + + (f"\n{error}" if error else "") + ) + if summary: + lines.append("Partial output:") + lines.append(summary) + return "\n".join(lines) + + def format_process_notification(evt: dict) -> "str | None": """Format a process notification event into a [IMPORTANT: ...] message. @@ -1559,6 +1644,9 @@ def format_process_notification(evt: dict) -> "str | None": text += "]" return text + if evt_type == "async_delegation": + return _format_async_delegation(evt) + _exit = evt.get("exit_code", "?") _out = evt.get("output", "") _reason = evt.get("completion_reason") or "exited" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 715ca8b48b63c..4d12a1a417bb7 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5595,6 +5595,11 @@ def _notification_event_dedup_key(evt: dict) -> tuple: evt.get("message", ""), evt.get("suppressed", 0), ) + if evt_type == "async_delegation": + # Async-delegation completions have no process session_id; without + # this the fallthrough keys every one as ("", "async_delegation") + # and the second completion's status update is suppressed forever. + return (evt.get("delegation_id", ""), evt_type) return (evt_sid, evt_type) From 1f407057a8c09fef33421976c80a6d55bb25dd91 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sun, 14 Jun 2026 17:01:28 +0700 Subject: [PATCH 16/28] fix(discord): cap slash commands at Discord's 100-command limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord enforces a hard cap of 100 global application commands per app. The adapter registers ~27 native commands plus every gateway-available entry in COMMAND_REGISTRY plus all plugin commands plus the consolidated /skill group. On a loaded install (many plugins/quick commands) the desired set exceeds 100, so tree.sync() / _safe_sync_slash_commands() hits error 30032 ("Maximum number of application commands reached") and Discord rejects the ENTIRE batch — silently breaking every slash command, not just the overflow. Cap registration at the 100-command limit: native commands (registered first, highest priority) and the /skill group are always kept; lower- priority auto-registered COMMAND_REGISTRY and plugin commands are added only until the cap is reached, with a single concise warning telling the user how to surface the rest. Since both sync paths read from tree.get_commands(), bounding the tree fixes the root cause for both. --- plugins/platforms/discord/adapter.py | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 69b1bf4d228a8..8146ca9de1076 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -31,6 +31,12 @@ _DISCORD_COMMAND_SYNC_STATE_FILENAME = "discord_command_sync_state.json" _DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS = 4.5 _DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS = 30.0 +# Discord enforces a hard cap of 100 global application (slash) commands per +# app. Registering more makes the ENTIRE sync fail with error 30032 +# ("Maximum number of application commands reached"), which silently breaks +# every slash command — not just the overflow ones. We keep the desired set +# at or below this limit at registration time. +_DISCORD_MAX_APP_COMMANDS = 100 try: import discord @@ -3518,6 +3524,11 @@ async def _handler(interaction: discord.Interaction): ) already_registered: set[str] = set() + # Native commands above are registered first and are the highest + # priority, so they always survive the 100-command cap. Reserve one + # slot for the consolidated ``/skill`` group registered further below. + slot_cap = _DISCORD_MAX_APP_COMMANDS - 1 + dropped_over_cap = 0 try: from hermes_cli.commands import COMMAND_REGISTRY, _is_gateway_available, _resolve_config_gates @@ -3535,6 +3546,9 @@ async def _handler(interaction: discord.Interaction): discord_name = cmd_def.name.lower()[:32] if discord_name in already_registered: continue + if len(already_registered) >= slot_cap: + dropped_over_cap += 1 + continue auto_cmd = _build_auto_slash_command( cmd_def.name, cmd_def.description, @@ -3567,6 +3581,9 @@ async def _handler(interaction: discord.Interaction): discord_name = plugin_name.lower()[:32] if discord_name in already_registered: continue + if len(already_registered) >= slot_cap: + dropped_over_cap += 1 + continue auto_cmd = _build_auto_slash_command( plugin_name, plugin_desc, @@ -3589,6 +3606,20 @@ async def _handler(interaction: discord.Interaction): # supporting up to 25 categories × 25 skills = 625 skills. self._register_skill_group(tree) + if dropped_over_cap: + # Staying under the cap keeps the whole sync succeeding; without + # this guard a single over-limit command makes Discord reject the + # entire batch (error 30032), breaking every slash command. + logger.warning( + "[%s] Reached Discord's limit of %d slash commands; skipped %d " + "lower-priority command(s) to keep the command sync working. " + "Disable slash commands you don't need or trim installed plugins " + "to surface them all.", + self.name, + _DISCORD_MAX_APP_COMMANDS, + dropped_over_cap, + ) + # Optional defense-in-depth: hide every slash command from non-admin # guild members in Discord's slash picker. Server-side authorization # (``_check_slash_authorization``) is the actual gate; this is purely From 0ffaded506422555aba721a96484b109aba4cc94 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sun, 14 Jun 2026 17:02:21 +0700 Subject: [PATCH 17/28] test(discord): guard slash-command registration against the 100 cap Registers 200 plugin commands on top of the native + COMMAND_REGISTRY set and asserts the tree never exceeds Discord's 100-command limit, that native high-priority commands survive the cap, and that overflow is actually dropped. Regression guard for the recurring error 30032 ("Maximum number of application commands reached") sync failures. --- tests/gateway/test_discord_slash_commands.py | 52 ++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index 8d44f77302e57..5ef6812f53751 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -292,6 +292,58 @@ async def test_plugin_command_name_conflict_skipped(adapter): ) +# ------------------------------------------------------------------ +# 100-command cap (Discord error 30032 guard) +# ------------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_slash_command_registration_stays_under_discord_limit(adapter): + """Registering far more commands than Discord allows must NOT push the + tree over the 100-command hard cap. + + Discord rejects the ENTIRE command sync with error 30032 once the + desired set exceeds 100 global application commands, silently breaking + every slash command. The adapter must bound the desired set instead. + Regression guard for samuraiheart's recurring + "Maximum number of application commands reached (100)" sync failures. + """ + from plugins.platforms.discord.adapter import _DISCORD_MAX_APP_COMMANDS + + adapter._run_simple_slash = AsyncMock() + + # 200 plugin commands — way past Discord's limit on their own. + many_plugins = { + f"plug{i:03d}": { + "handler": lambda _a: "ok", + "description": f"Plugin command {i}", + "args_hint": "", + "plugin": "stress-plugin", + } + for i in range(200) + } + + with patch("hermes_cli.plugins.get_plugin_commands", return_value=many_plugins): + adapter._register_slash_commands() + + tree_names = set(adapter._client.tree.commands.keys()) + + # Contract: never exceed Discord's hard cap. + assert len(tree_names) <= _DISCORD_MAX_APP_COMMANDS, ( + f"registered {len(tree_names)} commands — exceeds Discord's " + f"{_DISCORD_MAX_APP_COMMANDS} limit and would fail sync with 30032" + ) + + # Native, high-priority commands are registered first and must survive + # the cap — they are the core UX, not droppable overflow. + for native in ("status", "stop", "new", "model", "help"): + assert native in tree_names, f"/{native} (native) was dropped by the cap" + + # The cap must actually have dropped overflow — not every plugin fit. + registered_plugins = [n for n in tree_names if n.startswith("plug")] + assert len(registered_plugins) < 200, "cap did not drop any overflow commands" + + # ------------------------------------------------------------------ # _handle_thread_create_slash — success, session dispatch, failure # ------------------------------------------------------------------ From 87f15d5d27bc7693a4744e756c7370e0d57f7748 Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 15 Jun 2026 17:03:44 -0400 Subject: [PATCH 18/28] fix(ci): always run pull_request checks no waiting for pending forever! --- .github/workflows/contributor-check.yml | 5 ++--- .github/workflows/docker-lint.yml | 9 ++++---- .github/workflows/docker-publish.yml | 13 +++++------- .github/workflows/docs-site-checks.yml | 16 ++++++++------- .github/workflows/history-check.yml | 7 +++++-- .github/workflows/lint.yml | 9 ++++---- .github/workflows/osv-scanner.yml | 26 +++++++++--------------- .github/workflows/supply-chain-audit.yml | 12 +++++------ .github/workflows/tests.yml | 8 ++++---- .github/workflows/typecheck.yml | 3 +++ .github/workflows/uv-lockfile-check.yml | 18 ++++++++-------- 11 files changed, 61 insertions(+), 65 deletions(-) diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml index de38fcaae9a67..23266931a6993 100644 --- a/.github/workflows/contributor-check.yml +++ b/.github/workflows/contributor-check.yml @@ -1,12 +1,11 @@ name: Contributor Attribution Check on: - pull_request: - branches: [main] # No paths filter — the job must always run so the required check # reports a status (path-gated workflows leave checks "pending" forever # when no matching files change, which blocks merge). - + pull_request: + branches: [main] permissions: contents: read diff --git a/.github/workflows/docker-lint.yml b/.github/workflows/docker-lint.yml index f1673813e99b1..631add200ad8b 100644 --- a/.github/workflows/docker-lint.yml +++ b/.github/workflows/docker-lint.yml @@ -18,13 +18,12 @@ on: - docker/** - .hadolint.yaml - .github/workflows/docker-lint.yml + + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: branches: [main] - paths: - - Dockerfile - - docker/** - - .hadolint.yaml - - .github/workflows/docker-lint.yml permissions: contents: read diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c12ad772fa654..09b89138412d5 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -11,16 +11,13 @@ on: - 'docker/**' - '.github/workflows/docker-publish.yml' - '.github/actions/hermes-smoke-test/**' + + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: branches: [main] - paths: - - '**/*.py' - - 'pyproject.toml' - - 'uv.lock' - - 'Dockerfile' - - 'docker/**' - - '.github/workflows/docker-publish.yml' - - '.github/actions/hermes-smoke-test/**' + release: types: [published] diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml index 7001c0b743939..975028afe238c 100644 --- a/.github/workflows/docs-site-checks.yml +++ b/.github/workflows/docs-site-checks.yml @@ -1,10 +1,12 @@ name: Docs Site Checks on: + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: - paths: - - 'website/**' - - '.github/workflows/docs-site-checks.yml' + branches: [main] + workflow_dispatch: permissions: @@ -14,9 +16,9 @@ jobs: docs-site-checks: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: npm @@ -26,9 +28,9 @@ jobs: run: npm ci working-directory: website - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: '3.11' + python-version: "3.11" - name: Install ascii-guard run: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3 diff --git a/.github/workflows/history-check.yml b/.github/workflows/history-check.yml index 46f5368f7903a..ef657d5982c3e 100644 --- a/.github/workflows/history-check.yml +++ b/.github/workflows/history-check.yml @@ -14,6 +14,9 @@ name: History Check # the PR head and main to be non-empty. on: + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: branches: [main] @@ -24,9 +27,9 @@ jobs: check-common-ancestor: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - fetch-depth: 0 # full history both sides for merge-base + fetch-depth: 0 # full history both sides for merge-base - name: Reject PRs with no common ancestor on main run: | diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 013d212020dfd..f2765823a0bf9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,12 +15,12 @@ on: - "**/*.md" - "docs/**" - "website/**" + + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: branches: [main] - paths-ignore: - - "**/*.md" - - "docs/**" - - "website/**" permissions: contents: read @@ -154,7 +154,6 @@ jobs: }); } - ruff-blocking: # Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently # PLW1514 (unspecified-encoding) — catches bare ``open()`` / diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index c7d4b5bb06743..d1b318cc737f1 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -20,29 +20,23 @@ name: OSV-Scanner # vulnerabilities in pinned deps that we may need to patch deliberately. on: + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: branches: [main] - paths: - - 'uv.lock' - - 'pyproject.toml' - - 'package.json' - - 'package-lock.json' - - 'ui-tui/package.json' - - 'website/package.json' - - 'website/package-lock.json' - - '.github/workflows/osv-scanner.yml' push: branches: [main] paths: - - 'uv.lock' - - 'pyproject.toml' - - 'package.json' - - 'package-lock.json' - - 'website/package-lock.json' + - "uv.lock" + - "pyproject.toml" + - "package.json" + - "package-lock.json" + - "website/package-lock.json" schedule: # Weekly scan against main — catches CVEs published after merge for # deps that haven't changed since. - - cron: '0 9 * * 1' + - cron: "0 9 * * 1" workflow_dispatch: permissions: @@ -54,7 +48,7 @@ permissions: jobs: scan: name: Scan lockfiles - uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 with: # Scan explicit lockfiles rather than recursing, so we only look at # the three sources of truth and skip vendored / test / worktree dirs. diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 4bee46a95cd03..f3405b7660f0d 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -1,11 +1,11 @@ name: Supply Chain Audit on: - pull_request: - types: [opened, synchronize, reopened] # No paths filter — the jobs must always run so required checks # report a status (path-gated workflows leave checks "pending" forever # when no matching files change, which blocks merge). + pull_request: + types: [opened, synchronize, reopened] permissions: pull-requests: write @@ -32,7 +32,7 @@ jobs: # True when the curated MCP catalog / bundled MCP manifests changed. mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Check for relevant file changes @@ -72,7 +72,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -207,7 +207,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -286,7 +286,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a6e7738fa40fe..c1f59c5094ae3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,11 +6,11 @@ on: paths-ignore: - "**/*.md" - "docs/**" + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: branches: [main] - paths-ignore: - - "**/*.md" - - "docs/**" permissions: contents: read @@ -219,4 +219,4 @@ jobs: env: OPENROUTER_API_KEY: "" OPENAI_API_KEY: "" - NOUS_API_KEY: "" \ No newline at end of file + NOUS_API_KEY: "" diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index e21b80864c8f7..29994e3e295d4 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -4,6 +4,9 @@ name: Typecheck on: push: branches: [main] + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: branches: [main] diff --git a/.github/workflows/uv-lockfile-check.yml b/.github/workflows/uv-lockfile-check.yml index 37c31799bea6e..54662b23edafd 100644 --- a/.github/workflows/uv-lockfile-check.yml +++ b/.github/workflows/uv-lockfile-check.yml @@ -47,15 +47,15 @@ on: push: branches: [main] paths: - - 'pyproject.toml' - - 'uv.lock' - - '.github/workflows/uv-lockfile-check.yml' + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/uv-lockfile-check.yml" + + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: branches: [main] - paths: - - 'pyproject.toml' - - 'uv.lock' - - '.github/workflows/uv-lockfile-check.yml' permissions: contents: read @@ -71,10 +71,10 @@ jobs: timeout-minutes: 5 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 # `uv lock --check` re-resolves the project from pyproject.toml and # compares the result to uv.lock, exiting non-zero if they disagree. From e2779413d0ec75701113d28b50fbc73fccd2af50 Mon Sep 17 00:00:00 2001 From: Erosika Date: Wed, 10 Jun 2026 16:07:53 -0400 Subject: [PATCH 19/28] refactor(honcho): canonicalize identity-mapping on pinUserPeer, migrate legacy key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup wizard wrote the legacy pinPeerName even though pinUserPeer is the canonical key that outranks it in the resolver — so it had to scrub the canonical key afterward to stop it winning. Write pinUserPeer directly and migrate any legacy pinPeerName onto it on touch (setup load + clone), which removes the precedence-fighting entirely. Resolver still reads pinPeerName as a back-compat alias; that's deferred. --- plugins/memory/honcho/cli.py | 50 ++++++++++++++-------- tests/honcho_plugin/test_cli.py | 73 ++++++++++++++++++++++----------- 2 files changed, 81 insertions(+), 42 deletions(-) diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 092b7c823d3fc..bd74f42abd21f 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -41,22 +41,20 @@ def clone_honcho_for_profile(profile_name: str) -> bool: return False # already exists # Clone settings from default block, override identity fields. - # Identity-mapping keys (pinPeerName/pinUserPeer, userPeerAliases, - # runtimePeerPrefix) carry the operator's runtime-to-peer routing - # intent from #27371. Both pin keys are inherited because - # HonchoClientConfig prefers pinUserPeer over pinPeerName — leaving - # the canonical key off this allowlist silently drops the pin on - # cloned profiles when the default uses the newer name. + # Identity-mapping keys (pinUserPeer, userPeerAliases, runtimePeerPrefix) + # carry the operator's runtime-to-peer routing intent from #27371. new_block = {} for key in ("recallMode", "writeFrequency", "sessionStrategy", "sessionPeerPrefix", "contextTokens", "dialecticReasoningLevel", "dialecticDynamic", "dialecticMaxChars", "messageMaxChars", "dialecticMaxInputChars", "saveMessages", "observation", - "pinPeerName", "pinUserPeer", "userPeerAliases", - "runtimePeerPrefix"): + "pinUserPeer", "userPeerAliases", "runtimePeerPrefix"): val = default_block.get(key) if val is not None: new_block[key] = val + # Carry a legacy default-block pinPeerName forward under the canonical key. + if "pinUserPeer" not in new_block and default_block.get("pinPeerName") is not None: + new_block["pinUserPeer"] = default_block["pinPeerName"] # Inherit peer name from default peer_name = default_block.get("peerName") or cfg.get("peerName") @@ -371,15 +369,28 @@ def _resolve_effective_identity_mapping( def _scrub_identity_mapping(hermes_host: dict) -> None: """Drop every peer-mapping key from the host block. - Called before the wizard writes a chosen shape so latent precedence - conflicts can't survive — e.g. a stray host ``pinUserPeer: false`` - that would silently outrank a freshly written ``pinPeerName: true`` - (host ``pinUserPeer`` is first in the resolver ladder). + Called before the wizard writes a chosen shape so a stale alias, prefix, + or pin from an earlier run can't bleed into the new mapping. """ for key in _IDENTITY_MAPPING_KEYS: hermes_host.pop(key, None) +def _migrate_pin_key(block: dict) -> bool: + """Rewrite a legacy ``pinPeerName`` to canonical ``pinUserPeer`` in place. + + ``pinUserPeer`` wins over ``pinPeerName`` in the resolver, so setup writes + only the canonical form and migrates on touch to stop configs carrying + both. Returns True if the block changed. + """ + if "pinPeerName" not in block: + return False + legacy = block.pop("pinPeerName") + if "pinUserPeer" not in block: + block["pinUserPeer"] = legacy + return True + + def _prompt(label: str, default: str | None = None, secret: bool = False) -> str: suffix = f" [{default}]" if default else "" sys.stdout.write(f" {label}{suffix}: ") @@ -446,6 +457,10 @@ def cmd_setup(args) -> None: hosts = cfg.setdefault("hosts", {}) hermes_host = hosts.setdefault(_host_key(), {}) + # Canonicalize any legacy pinPeerName before detection/writes. + _migrate_pin_key(cfg) + _migrate_pin_key(hermes_host) + # --- 1. Cloud or local? --- print(" Deployment:") print(" cloud -- Honcho cloud (api.honcho.dev)") @@ -599,12 +614,11 @@ def cmd_setup(args) -> None: new_shape = "skip" # Each shape branch scrubs every peer-mapping key before writing its own, - # so a stale ``pinUserPeer`` left behind by an earlier setup run can't - # outrank the freshly written ``pinPeerName`` via host-level precedence. + # so a stale alias/prefix/pin from an earlier run starts clean. if new_shape == "single": _scrub_identity_mapping(hermes_host) - hermes_host["pinPeerName"] = True - print(f" pinPeerName=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.") + hermes_host["pinUserPeer"] = True + print(f" pinUserPeer=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.") elif new_shape == "multi": # Preserve operator-curated, host-level aliases so multi → multi # re-runs don't drop them. Root-sourced aliases are left to @@ -615,7 +629,7 @@ def cmd_setup(args) -> None: else {} ) _scrub_identity_mapping(hermes_host) - hermes_host["pinPeerName"] = False + hermes_host["pinUserPeer"] = False # Do NOT auto-write ``userPeerAliases: {}``: an empty host map # would override any root-level ``userPeerAliases`` the operator # set as a cross-host baseline, silently disabling those aliases. @@ -642,7 +656,7 @@ def cmd_setup(args) -> None: # the mapping". existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {} _scrub_identity_mapping(hermes_host) - hermes_host["pinPeerName"] = False + hermes_host["pinUserPeer"] = False peer_target = hermes_host.get("peerName") or current_peer or "user" print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.") print(" Leave blank to skip a platform. Existing aliases are preserved.") diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index 74b7e1bc34e76..fcbce52703b97 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -239,7 +239,7 @@ class TestCloneHonchoForProfile: """Identity-key carryover during profile cloning. The host-scoped identity-mapping keys (``userPeerAliases``, - ``runtimePeerPrefix``, ``pinPeerName``) must survive a clone; otherwise + ``runtimePeerPrefix``, ``pinUserPeer``) must survive a clone; otherwise the new profile silently fragments memory by resolving gateway users to raw runtime IDs instead of operator-declared peers. """ @@ -290,7 +290,7 @@ def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_ new_block = written["cfg"]["hosts"]["hermes_coder"] assert new_block["runtimePeerPrefix"] == "telegram_" - def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path): + def test_legacy_pin_peer_name_migrates_to_canonical_on_clone(self, monkeypatch, tmp_path): cfg = { "apiKey": "***", "hosts": { @@ -304,7 +304,8 @@ def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path): ok = honcho_cli.clone_honcho_for_profile("coder") assert ok is True new_block = written["cfg"]["hosts"]["hermes_coder"] - assert new_block["pinPeerName"] is True + assert new_block["pinUserPeer"] is True + assert "pinPeerName" not in new_block def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, tmp_path): cfg = { @@ -317,6 +318,7 @@ def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, new_block = written["cfg"]["hosts"]["hermes_coder"] assert "userPeerAliases" not in new_block assert "runtimePeerPrefix" not in new_block + assert "pinUserPeer" not in new_block assert "pinPeerName" not in new_block @@ -409,7 +411,7 @@ def test_single_shape_sets_pin_peer_name_and_clears_aliases(self, monkeypatch, t }}, } host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is True + assert host["pinUserPeer"] is True assert "userPeerAliases" not in host assert "runtimePeerPrefix" not in host @@ -424,7 +426,7 @@ def test_multi_shape_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_ "telegram_", # runtime peer prefix ] host = self._run_setup(monkeypatch, tmp_path, answers=answers) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False # Multi must NOT auto-write ``userPeerAliases: {}``: an empty host # map would silently override a root-level baseline. Absence is # the correct "no host opinion" signal. @@ -446,7 +448,7 @@ def test_hybrid_shape_aliases_operator_runtime_ids_to_peer_name(self, monkeypatc "", # runtime peer prefix (skip) ] host = self._run_setup(monkeypatch, tmp_path, answers=answers) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False assert host["userPeerAliases"] == { "86701400": "eri", "491827364": "eri", @@ -454,6 +456,8 @@ def test_hybrid_shape_aliases_operator_runtime_ids_to_peer_name(self, monkeypatc assert "runtimePeerPrefix" not in host def test_skip_shape_preserves_existing_identity_config(self, monkeypatch, tmp_path): + # Seeds the legacy ``pinPeerName``: skip must leave the mapping intact + # except for the on-load migration onto the canonical key. initial_cfg = { "apiKey": "***", "hosts": {"hermes": { @@ -466,7 +470,8 @@ def test_skip_shape_preserves_existing_identity_config(self, monkeypatch, tmp_pa "cloud", "", "eri", "hermetika", "hermes", "skip", ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is True + assert host["pinUserPeer"] is True + assert "pinPeerName" not in host assert host["userPeerAliases"] == {"keep": "me"} assert host["runtimePeerPrefix"] == "keep_" @@ -494,7 +499,7 @@ def test_single_to_multi_steers_to_hybrid_by_default(self, monkeypatch, tmp_path "", # runtime prefix (skip) ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False assert host["userPeerAliases"] == {"86701400": "eri"} def test_single_to_multi_yes_override_keeps_multi(self, monkeypatch, tmp_path): @@ -512,7 +517,7 @@ def test_single_to_multi_yes_override_keeps_multi(self, monkeypatch, tmp_path): "telegram_", # runtime peer prefix ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False # See test_multi_shape_leaves_pin_false_and_accepts_prefix. assert "userPeerAliases" not in host assert host["runtimePeerPrefix"] == "telegram_" @@ -535,10 +540,9 @@ def test_host_pin_user_peer_true_is_detected_as_single(self, monkeypatch, tmp_pa # exercise that fallthrough — the mock returns it literally. answers = ["cloud", "", "eri", "hermetika", "hermes"] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - # Scrub-then-write normalises onto pinPeerName and drops the alias - # so resolver precedence can't reintroduce ambiguity. - assert host["pinPeerName"] is True - assert "pinUserPeer" not in host + # Scrub-then-write normalises onto the canonical pinUserPeer. + assert host["pinUserPeer"] is True + assert "pinPeerName" not in host def test_host_pin_user_peer_false_overrides_root_pin_peer_name( self, monkeypatch, tmp_path @@ -558,8 +562,8 @@ def test_host_pin_user_peer_false_overrides_root_pin_peer_name( } answers = ["cloud", "", "eri", "hermetika", "hermes"] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is False - assert "pinUserPeer" not in host + assert host["pinUserPeer"] is False + assert "pinPeerName" not in host def test_root_user_peer_aliases_detected_as_hybrid(self, monkeypatch, tmp_path): """Root-level ``userPeerAliases`` must classify as ``hybrid`` even @@ -572,7 +576,7 @@ def test_root_user_peer_aliases_detected_as_hybrid(self, monkeypatch, tmp_path): } answers = ["cloud", "", "eri", "hermetika", "hermes"] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False # Hybrid materialises the root aliases into the host so subsequent # operator edits live on the host block they're inspecting. assert host["userPeerAliases"] == {"86701400": "eri"} @@ -584,7 +588,7 @@ def test_multi_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_p Picking ``multi`` here is an active choice — detection would have defaulted to ``hybrid`` because root aliases exist — so the operator's intent is to drop the alias mapping for this host. - We honor that by writing ``pinPeerName: false`` only, and rely + We honor that by writing ``pinUserPeer: false`` only, and rely on the host's absence of ``userPeerAliases`` to inherit root. That inheritance is intentional: a true wipe would require the operator to delete the root key explicitly. @@ -599,14 +603,12 @@ def test_multi_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_p "multi", # explicit multi override of detected hybrid ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False assert "userPeerAliases" not in host def test_single_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path): - """Choosing ``single`` must drop any host-level ``pinUserPeer``, - otherwise an existing ``pinUserPeer: false`` would outrank the - freshly written ``pinPeerName: true`` and leave the profile - effectively unpinned (the P1 latent-precedence regression). + """Choosing ``single`` must overwrite a stale ``pinUserPeer: false`` + with ``pinUserPeer: true`` so the profile ends up genuinely pinned. """ initial_cfg = { "apiKey": "***", @@ -620,8 +622,7 @@ def test_single_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path): "single", ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is True - assert "pinUserPeer" not in host + assert host["pinUserPeer"] is True class TestCloneCarriesPinUserPeer: @@ -653,3 +654,27 @@ def test_clone_inherits_host_pin_user_peer(self, monkeypatch, tmp_path): assert ok is True new_block = written["cfg"]["hosts"]["hermes_partner"] assert new_block["pinUserPeer"] is True + + +class TestMigratePinKey: + """``_migrate_pin_key`` rewrites the legacy ``pinPeerName`` onto the + canonical ``pinUserPeer`` in place, without clobbering an existing + canonical value.""" + + def test_legacy_key_renamed_to_canonical(self): + import plugins.memory.honcho.cli as honcho_cli + block = {"pinPeerName": True} + assert honcho_cli._migrate_pin_key(block) is True + assert block == {"pinUserPeer": True} + + def test_canonical_key_wins_when_both_present(self): + import plugins.memory.honcho.cli as honcho_cli + block = {"pinPeerName": True, "pinUserPeer": False} + assert honcho_cli._migrate_pin_key(block) is True + assert block == {"pinUserPeer": False} + + def test_noop_when_no_legacy_key(self): + import plugins.memory.honcho.cli as honcho_cli + block = {"pinUserPeer": True} + assert honcho_cli._migrate_pin_key(block) is False + assert block == {"pinUserPeer": True} From 20392413a6113580bb1551fedb91e612b498afa8 Mon Sep 17 00:00:00 2001 From: Erosika Date: Wed, 10 Jun 2026 16:14:24 -0400 Subject: [PATCH 20/28] feat(honcho-setup): replace deployment-shape prompt with gateway-gated identity tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single/multi/hybrid 'deployment shape' was a misnomer: these keys only affect the gateway (the one entrypoint supplying a runtime user ID), and the three preset names stamped a lossy taxonomy onto three orthogonal knobs while hiding which keys got written. Replace it with an intent-led tree gated on gateway detection: - _gateway_platforms() lazily inspects the gateway config (best-effort, no hard dependency); the step auto-skips when no platform is connected. - 'who talks to this?' → just me / me+others (pooled?) / only others, deriving pinUserPeer + userPeerAliases + runtimePeerPrefix and echoing the result. - [e] drops to a raw-knob editor for power users. - The single→multi orphan guard survives as a pooling steer. --- plugins/memory/honcho/cli.py | 310 +++++++++++++++++++++----------- tests/honcho_plugin/test_cli.py | 148 ++++++++++----- 2 files changed, 303 insertions(+), 155 deletions(-) diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index bd74f42abd21f..33edcf12dc03f 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -391,6 +391,100 @@ def _migrate_pin_key(block: dict) -> bool: return True +def _gateway_platforms() -> list[str] | None: + """Connected gateway platforms, or None if undetectable. + + Identity mapping only affects gateway runtime users, so setup gates the + whole step on this. Best-effort and dependency-free: the memory plugin + must not hard-depend on the gateway package, so the import is lazy and + guarded (matching the idiom hermes_cli already uses for gateway refs). + """ + try: + from gateway.config import load_gateway_config + return [p.value for p in load_gateway_config().get_connected_platforms()] + except Exception: + return None + + +def _collect_operator_aliases(existing: dict, peer_target: str) -> dict: + """Prompt for the operator's per-platform runtime IDs, aliasing each to + ``peer_target``. Existing entries are preserved.""" + aliases = dict(existing) + print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.") + print(" Leave blank to skip a platform. Existing aliases are preserved.") + for platform_label, alias_hint in ( + ("Telegram UID", "e.g. 86701400"), + ("Discord snowflake", "e.g. 491827364"), + ("Slack user ID", "e.g. U04ABCDEF"), + ("Matrix MXID", "e.g. @you:matrix.org"), + ): + entered = _prompt(f" {platform_label} ({alias_hint})", default="").strip() + if entered: + aliases[entered] = peer_target + return aliases + + +def _apply_runtime_prefix( + hermes_host: dict, current_prefix: str, prefix_from_root: bool, label: str +) -> None: + """Write a host-level runtimePeerPrefix only when it diverges from an + inherited root value; otherwise let the root cascade stand.""" + new_prefix = _prompt(label, default=current_prefix or "").strip() + if new_prefix and not (prefix_from_root and new_prefix == current_prefix): + hermes_host["runtimePeerPrefix"] = new_prefix + + +def _echo_identity_mapping(hermes_host: dict) -> None: + """Show the resulting keys so the operator can verify what was written.""" + aliases = hermes_host.get("userPeerAliases") + prefix = hermes_host.get("runtimePeerPrefix") + print(" resolved →") + print(f" pinUserPeer = {bool(hermes_host.get('pinUserPeer'))}") + print(f" userPeerAliases = {aliases if aliases else '{}'}") + print(f" runtimePeerPrefix = {prefix if prefix else '(none)'}") + + +def _configure_raw_identity_mapping( + hermes_host: dict, + current_pin: bool, + current_aliases: dict, + current_prefix: str, + aliases_from_root: bool, + prefix_from_root: bool, +) -> None: + """Power-user escape hatch: set the three resolver knobs directly.""" + print("\n Raw identity-mapping keys (resolver tries them top-down):") + pin_in = _prompt( + "pinUserPeer — pin all gateway users to your peer? (true/false)", + default=str(bool(current_pin)).lower(), + ).strip().lower() + pin = pin_in in {"true", "t", "yes", "y", "1"} + _scrub_identity_mapping(hermes_host) + hermes_host["pinUserPeer"] = pin + if pin: + return + aliases = ( + dict(current_aliases) + if isinstance(current_aliases, dict) and not aliases_from_root + else {} + ) + print(" userPeerAliases — 'runtime_id=peer' pairs (blank line to finish):") + while True: + entry = _prompt(" alias", default="").strip() + if not entry: + break + if "=" in entry: + rid, peer = (p.strip() for p in entry.split("=", 1)) + if rid and peer: + aliases[rid] = peer + if aliases: + hermes_host["userPeerAliases"] = aliases + _apply_runtime_prefix( + hermes_host, current_prefix, prefix_from_root, + "runtimePeerPrefix — namespace for unknown IDs (blank for none)", + ) + + def _prompt(label: str, default: str | None = None, secret: bool = False) -> str: suffix = f" [{default}]" if default else "" sys.stdout.write(f" {label}{suffix}: ") @@ -560,18 +654,15 @@ def cmd_setup(args) -> None: if new_workspace: hermes_host["workspace"] = new_workspace - # --- 3b. Deployment shape --- - # Determines how runtime user identities (Telegram UIDs, Discord - # snowflakes, etc.) map to Honcho peers in gateway sessions. Three - # shapes cover the realistic deployments; each writes a different - # combination of pinPeerName / userPeerAliases / runtimePeerPrefix. - # See plugins/memory/honcho/README.md for the resolver ladder. + # --- 3b. Gateway identity mapping --- + # These keys only affect the Hermes GATEWAY (Telegram/Discord/Slack/...), + # the one entrypoint that supplies a runtime user ID. CLI/TUI/desktop/ACP + # sessions have no runtime ID and fall through to peerName, so the step is + # moot off-gateway — gate it behind detection. # - # Detection must mirror the gateway resolver: root-level config and - # ``pinUserPeer`` (which outranks ``pinPeerName`` at the same level) - # both affect effective routing, so reading host-only fields would - # mis-classify a profile that inherits its mapping from root or uses - # the newer canonical key. + # Detection mirrors the gateway resolver: root-level config and the + # canonical ``pinUserPeer`` both affect routing, so host-only reads would + # mis-classify a profile that inherits its mapping from root. ( current_pin, current_aliases, @@ -587,102 +678,109 @@ def cmd_setup(args) -> None: else: current_shape = "multi" - print("\n Deployment shape (how gateway users map to peers):") - print(" single -- all platforms route to your peer (recommended for personal use)") - print(" multi -- each platform user gets their own peer (multi-user bots)") - print(" hybrid -- multi-user, but YOUR runtime IDs alias to your peer") - print(" skip -- don't touch identity-mapping config") - new_shape = _prompt("Deployment shape", default=current_shape).strip().lower() - - # Transitioning single → multi orphans the peerName pool for runtime users - # (their resolved peers go from peerName to runtime-derived IDs with empty - # history). Steer the operator toward hybrid so their own continuity is - # preserved via alias mappings. - if current_shape == "single" and new_shape == "multi": - peer_target = hermes_host.get("peerName") or current_peer or "user" - print( - f"\n ⚠ Switching from single to multi will orphan memory accumulated\n" - f" under peer '{peer_target}'. Existing runtime users (Telegram,\n" - f" Discord, etc.) will resolve to fresh, empty peers." - ) - print(" To keep your own continuity, choose 'hybrid' and alias your\n" - " runtime IDs back to peerName.") - confirm = _prompt("Continue with multi anyway? (yes/hybrid/no)", default="hybrid").strip().lower() - if confirm in {"hybrid", "h"}: - new_shape = "hybrid" - elif confirm not in {"yes", "y"}: - new_shape = "skip" - - # Each shape branch scrubs every peer-mapping key before writing its own, - # so a stale alias/prefix/pin from an earlier run starts clean. - if new_shape == "single": - _scrub_identity_mapping(hermes_host) - hermes_host["pinUserPeer"] = True - print(f" pinUserPeer=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.") - elif new_shape == "multi": - # Preserve operator-curated, host-level aliases so multi → multi - # re-runs don't drop them. Root-sourced aliases are left to - # cascade naturally and are NOT copied down into the host. - prior_aliases = ( - dict(current_aliases) - if isinstance(current_aliases, dict) and not aliases_from_root - else {} - ) - _scrub_identity_mapping(hermes_host) - hermes_host["pinUserPeer"] = False - # Do NOT auto-write ``userPeerAliases: {}``: an empty host map - # would override any root-level ``userPeerAliases`` the operator - # set as a cross-host baseline, silently disabling those aliases. - # Absence is the right "no host opinion" signal. - if prior_aliases: - hermes_host["userPeerAliases"] = prior_aliases - _prefix_default = current_prefix or "" - _new_prefix = _prompt( - "Runtime peer prefix (e.g. 'telegram_', blank for none)", - default=_prefix_default, - ).strip() - # Only write a host-level prefix when the operator typed one that - # diverges from the inherited root value; otherwise let the root - # cascade continue unmodified. - if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix): - hermes_host["runtimePeerPrefix"] = _new_prefix - print(" Multi-user mode: each runtime ID → own peer. Use 'hermes honcho status' to inspect.") - elif new_shape == "hybrid": - # Hybrid encodes operator intent at the host level: collect existing - # entries (host or root) so the wizard never silently drops a known - # alias, then write the combined map. Materialising root entries - # into the host is the right move here — once the operator answers - # the alias prompts for a host, they're declaring "this host owns - # the mapping". - existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {} - _scrub_identity_mapping(hermes_host) - hermes_host["pinUserPeer"] = False - peer_target = hermes_host.get("peerName") or current_peer or "user" - print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.") - print(" Leave blank to skip a platform. Existing aliases are preserved.") - for platform_label, alias_hint in ( - ("Telegram UID", "e.g. 86701400"), - ("Discord snowflake", "e.g. 491827364"), - ("Slack user ID", "e.g. U04ABCDEF"), - ("Matrix MXID", "e.g. @you:matrix.org"), - ): - entered = _prompt(f" {platform_label} ({alias_hint})", default="").strip() - if entered: - existing_aliases[entered] = peer_target - if existing_aliases: - hermes_host["userPeerAliases"] = existing_aliases - _prefix_default = current_prefix or "" - _new_prefix = _prompt( - "Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)", - default=_prefix_default, - ).strip() - if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix): - hermes_host["runtimePeerPrefix"] = _new_prefix - print(f" Hybrid mode: your runtime IDs → '{peer_target}', others → own peer.") - elif new_shape == "skip": - pass # leave config untouched + gw_platforms = _gateway_platforms() + if gw_platforms is None: + print("\n Gateway identity mapping routes platform users to memory peers.") + run_mapping = _prompt( + "Running the Hermes gateway (Telegram/Discord/etc.)? (y/N)", + default="n", + ).strip().lower() in {"y", "yes"} + elif not gw_platforms: + print("\n No gateway platforms connected — identity mapping only affects") + print(" gateway users, so this step doesn't apply here.") + run_mapping = _prompt( + "Configure gateway mapping anyway? (y/N)", default="n", + ).strip().lower() in {"y", "yes"} else: - print(f" Unknown shape '{new_shape}' — leaving identity-mapping config untouched.") + print(f"\n Gateway platforms detected: {', '.join(gw_platforms)}") + run_mapping = True + + if run_mapping: + peer_target = hermes_host.get("peerName") or current_peer or "user" + default_choice = {"single": "1", "hybrid": "2", "multi": "3"}.get(current_shape, "3") + print("\n How should gateway users map to memory peers?") + print(" [1] just me — everyone collapses to your peer") + print(" [2] me + other people — keep mine pooled, others separate") + print(" [3] only other people — everyone gets their own peer") + print(" [s] skip (leave untouched) [e] edit raw keys") + choice = _prompt("Choice", default=default_choice).strip().lower() + + if choice in {"2", "me+others", "both"}: + pooled = _prompt( + " Keep my own memory pooled across platforms? (Y/n)", default="y", + ).strip().lower() + shape = "hybrid" if pooled in {"y", "yes", ""} else "multi" + elif choice in {"1", "me", "just-me"}: + shape = "single" + elif choice in {"3", "others"}: + shape = "multi" + elif choice in {"e", "edit", "raw"}: + shape = "raw" + else: + shape = "skip" + + # Un-pinning a currently-pinned profile without aliasing strands the + # pooled peerName history; steer the operator toward pooling instead. + if current_pin and shape == "multi": + print( + f"\n ⚠ Un-pinning will orphan memory accumulated under peer\n" + f" '{peer_target}'. Existing gateway users resolve to fresh,\n" + f" empty peers." + ) + confirm = _prompt( + " Pool my own memory instead (alias my IDs to peerName)? (Y/n)", + default="y", + ).strip().lower() + if confirm in {"y", "yes", ""}: + shape = "hybrid" + + # Each branch scrubs every peer-mapping key first so a stale alias, + # prefix, or pin from an earlier run starts clean. + if shape == "single": + _scrub_identity_mapping(hermes_host) + hermes_host["pinUserPeer"] = True + print(f" All gateway users route to '{peer_target}'.") + _echo_identity_mapping(hermes_host) + elif shape == "multi": + # Preserve operator-curated host-level aliases across multi → multi + # re-runs. Root-sourced aliases cascade naturally and are NOT + # copied down — an empty host map would mask a root baseline. + prior_aliases = ( + dict(current_aliases) + if isinstance(current_aliases, dict) and not aliases_from_root + else {} + ) + _scrub_identity_mapping(hermes_host) + hermes_host["pinUserPeer"] = False + if prior_aliases: + hermes_host["userPeerAliases"] = prior_aliases + _apply_runtime_prefix( + hermes_host, current_prefix, prefix_from_root, + "Runtime peer prefix (e.g. 'telegram_', blank for none)", + ) + print(" Each gateway user → own peer.") + _echo_identity_mapping(hermes_host) + elif shape == "hybrid": + existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {} + _scrub_identity_mapping(hermes_host) + hermes_host["pinUserPeer"] = False + merged = _collect_operator_aliases(existing_aliases, peer_target) + if merged: + hermes_host["userPeerAliases"] = merged + _apply_runtime_prefix( + hermes_host, current_prefix, prefix_from_root, + "Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)", + ) + print(f" Your runtime IDs → '{peer_target}', others → own peer.") + _echo_identity_mapping(hermes_host) + elif shape == "raw": + _configure_raw_identity_mapping( + hermes_host, current_pin, current_aliases, current_prefix, + aliases_from_root, prefix_from_root, + ) + _echo_identity_mapping(hermes_host) + else: # skip + print(" Identity mapping left untouched.") # --- 4. Observation mode --- current_obs = hermes_host.get("observationMode") or cfg.get("observationMode", "directional") diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index fcbce52703b97..afcc7af077926 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -323,19 +323,20 @@ def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, class TestSetupWizardDeploymentShape: - """The deployment-shape step writes pinPeerName / userPeerAliases / - runtimePeerPrefix based on the operator's chosen shape. + """The gateway identity-mapping tree writes pinUserPeer / userPeerAliases / + runtimePeerPrefix based on the operator's intent. - Single-operator deployments collapse all platforms to peerName. - Multi-user gateways leave the resolver to route per-runtime. - Hybrid deployments alias the operator's own runtime IDs only. + Choice [1] (just me) collapses all platforms to peerName. + Choice [3] (only other people) leaves the resolver to route per-runtime. + Choice [2] (me + others, pooled) aliases the operator's own runtime IDs. - These tests script the interactive _prompt calls and assert the - resulting hermes_host block, so the wizard's deployment-shape + These tests mock gateway detection and script the interactive _prompt + calls, asserting the resulting hermes_host block so the tree's routing semantics stay locked even as adjacent prompts are added. """ - def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None): + def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None, + gateway_platforms=("telegram",)): import plugins.memory.honcho.cli as honcho_cli cfg_path = tmp_path / "config.json" @@ -348,6 +349,10 @@ def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None): monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes") monkeypatch.setattr(honcho_cli, "_ensure_sdk_installed", lambda: True) monkeypatch.setattr(honcho_cli, "_write_config", lambda *a, **k: None) + # Gate detection is mocked so tests control whether the tree runs. + # None → undetectable; list (possibly empty) → connected platforms. + gw = None if gateway_platforms is None else list(gateway_platforms) + monkeypatch.setattr(honcho_cli, "_gateway_platforms", lambda: gw) # Bypass config.yaml + connection test side effects. monkeypatch.setattr( @@ -393,14 +398,14 @@ def _scripted_prompt(label, default=None, secret=False): honcho_cli.cmd_setup(SimpleNamespace()) return cfg["hosts"]["hermes"] - def test_single_shape_sets_pin_peer_name_and_clears_aliases(self, monkeypatch, tmp_path): + def test_just_me_pins_and_clears_aliases(self, monkeypatch, tmp_path): answers = [ "cloud", # deployment "", # api key (keep) "eri", # peer name "hermetika", # ai peer "hermes", # workspace - "single", # deployment shape ← key answer + "1", # tree: just me ← key answer # remaining prompts fall through to defaults ] initial_cfg = { @@ -415,14 +420,14 @@ def test_single_shape_sets_pin_peer_name_and_clears_aliases(self, monkeypatch, t assert "userPeerAliases" not in host assert "runtimePeerPrefix" not in host - def test_multi_shape_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path): + def test_only_others_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path): answers = [ "cloud", # deployment "", # api key (keep) "eri", # peer name "hermetika", # ai peer "hermes", # workspace - "multi", # deployment shape + "3", # tree: only other people "telegram_", # runtime peer prefix ] host = self._run_setup(monkeypatch, tmp_path, answers=answers) @@ -433,14 +438,15 @@ def test_multi_shape_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_ assert "userPeerAliases" not in host assert host["runtimePeerPrefix"] == "telegram_" - def test_hybrid_shape_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path): + def test_pooled_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path): answers = [ "cloud", # deployment "", # api key (keep) "eri", # peer name "hermetika", # ai peer "hermes", # workspace - "hybrid", # deployment shape + "2", # tree: me + other people + "y", # keep my memory pooled? → hybrid "86701400", # telegram uid "491827364", # discord snowflake "", # slack (skip) @@ -467,7 +473,7 @@ def test_skip_shape_preserves_existing_identity_config(self, monkeypatch, tmp_pa }}, } answers = [ - "cloud", "", "eri", "hermetika", "hermes", "skip", + "cloud", "", "eri", "hermetika", "hermes", "s", ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) assert host["pinUserPeer"] is True @@ -475,10 +481,10 @@ def test_skip_shape_preserves_existing_identity_config(self, monkeypatch, tmp_pa assert host["userPeerAliases"] == {"keep": "me"} assert host["runtimePeerPrefix"] == "keep_" - def test_single_to_multi_steers_to_hybrid_by_default(self, monkeypatch, tmp_path): - """Flipping single → multi triggers a warning that auto-steers the - operator to ``hybrid`` (default), so their own runtime IDs keep - landing on peerName instead of orphaning the pinned-pool history. + def test_unpin_steers_to_pooled_by_default(self, monkeypatch, tmp_path): + """Choosing 'only other people' on a currently-pinned profile triggers + the orphan warning, which auto-steers to pooled (hybrid) so the + operator's own runtime IDs keep landing on peerName. """ initial_cfg = { "apiKey": "***", @@ -490,8 +496,8 @@ def test_single_to_multi_steers_to_hybrid_by_default(self, monkeypatch, tmp_path "eri", # peer name "hermetika", # ai peer "hermes", # workspace - "multi", # deployment shape — triggers the guard - "hybrid", # guard response: accept the steer + "3", # tree: only others — triggers the orphan guard + "y", # pool my own memory instead? → hybrid "86701400", # telegram uid "", # discord (skip) "", # slack (skip) @@ -502,42 +508,40 @@ def test_single_to_multi_steers_to_hybrid_by_default(self, monkeypatch, tmp_path assert host["pinUserPeer"] is False assert host["userPeerAliases"] == {"86701400": "eri"} - def test_single_to_multi_yes_override_keeps_multi(self, monkeypatch, tmp_path): - """Operator can override the steer by answering ``yes`` and accept - the orphaning consequences. This is the explicit undo-the-pin path. - """ + def test_unpin_decline_steer_keeps_per_user(self, monkeypatch, tmp_path): + """Operator can decline the steer ('n') and accept orphaning, ending + up with per-user peers (no aliases).""" initial_cfg = { "apiKey": "***", "hosts": {"hermes": {"pinPeerName": True, "peerName": "eri"}}, } answers = [ "cloud", "", "eri", "hermetika", "hermes", - "multi", # deployment shape — triggers the guard - "yes", # guard response: confirm multi + "3", # tree: only others — triggers the orphan guard + "n", # decline pooling, accept orphaning "telegram_", # runtime peer prefix ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) assert host["pinUserPeer"] is False - # See test_multi_shape_leaves_pin_false_and_accepts_prefix. assert "userPeerAliases" not in host assert host["runtimePeerPrefix"] == "telegram_" def test_host_pin_user_peer_true_is_detected_as_single(self, monkeypatch, tmp_path): """Host-level ``pinUserPeer: true`` must classify as ``single``. - Pressing Enter at the shape prompt then preserves the pin instead - of falling through to ``multi`` and orphaning the user's memory - pool — the bug the wizard regressed when ``pinUserPeer`` landed - as a higher-precedence alias. + Pressing Enter at the choice prompt then preserves the pin instead + of falling through to per-user routing and orphaning the user's + memory pool — the bug the wizard regressed when ``pinUserPeer`` + landed as a higher-precedence alias. """ initial_cfg = { "apiKey": "***", "hosts": {"hermes": {"pinUserPeer": True, "peerName": "eri"}}, } - # Exhaust the iterator before the shape prompt so the scripted - # mock falls through to the prompt's default (which is the - # wizard-detected shape). Scripting an explicit "" would NOT - # exercise that fallthrough — the mock returns it literally. + # Exhaust the iterator before the choice prompt so the scripted + # mock falls through to the prompt's default (the detected shape → + # choice "1"). Scripting an explicit "" would NOT exercise that + # fallthrough — the mock returns it literally. answers = ["cloud", "", "eri", "hermetika", "hermes"] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) # Scrub-then-write normalises onto the canonical pinUserPeer. @@ -581,16 +585,16 @@ def test_root_user_peer_aliases_detected_as_hybrid(self, monkeypatch, tmp_path): # operator edits live on the host block they're inspecting. assert host["userPeerAliases"] == {"86701400": "eri"} - def test_multi_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path): - """Explicit ``multi`` must leave the host ``userPeerAliases`` key - absent, preserving any root-level aliases as a cross-host baseline. + def test_only_others_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path): + """Explicitly choosing 'only other people' must leave the host + ``userPeerAliases`` key absent, preserving any root-level aliases as a + cross-host baseline. - Picking ``multi`` here is an active choice — detection would have - defaulted to ``hybrid`` because root aliases exist — so the - operator's intent is to drop the alias mapping for this host. - We honor that by writing ``pinUserPeer: false`` only, and rely - on the host's absence of ``userPeerAliases`` to inherit root. - That inheritance is intentional: a true wipe would require the + Picking [3] here is an active choice — detection would have defaulted + to [2]/hybrid because root aliases exist — so the operator's intent is + to drop the alias mapping for this host. We honor that by writing + ``pinUserPeer: false`` only, relying on the host's absence of + ``userPeerAliases`` to inherit root. A true wipe would require the operator to delete the root key explicitly. """ initial_cfg = { @@ -600,14 +604,14 @@ def test_multi_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_p } answers = [ "cloud", "", "eri", "hermetika", "hermes", - "multi", # explicit multi override of detected hybrid + "3", # explicit per-user override of detected hybrid ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) assert host["pinUserPeer"] is False assert "userPeerAliases" not in host - def test_single_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path): - """Choosing ``single`` must overwrite a stale ``pinUserPeer: false`` + def test_just_me_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path): + """Choosing 'just me' must overwrite a stale ``pinUserPeer: false`` with ``pinUserPeer: true`` so the profile ends up genuinely pinned. """ initial_cfg = { @@ -619,11 +623,57 @@ def test_single_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path): } answers = [ "cloud", "", "eri", "hermetika", "hermes", - "single", + "1", ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) assert host["pinUserPeer"] is True + def test_no_gateway_connected_skips_mapping_when_declined(self, monkeypatch, tmp_path): + """With no gateway platforms connected, the tree is gated off; declining + the 'configure anyway?' prompt leaves identity mapping untouched.""" + initial_cfg = { + "apiKey": "***", + "hosts": {"hermes": {"peerName": "eri"}}, + } + answers = ["cloud", "", "eri", "hermetika", "hermes", "n"] + host = self._run_setup( + monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg, + gateway_platforms=[], + ) + assert "pinUserPeer" not in host + assert "userPeerAliases" not in host + assert "runtimePeerPrefix" not in host + + def test_undetectable_gateway_skips_mapping_when_declined(self, monkeypatch, tmp_path): + """When the gateway package can't be inspected (None), the wizard asks + whether the gateway is running; 'no' skips the mapping step.""" + initial_cfg = { + "apiKey": "***", + "hosts": {"hermes": {"peerName": "eri"}}, + } + answers = ["cloud", "", "eri", "hermetika", "hermes", "n"] + host = self._run_setup( + monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg, + gateway_platforms=None, + ) + assert "pinUserPeer" not in host + + def test_raw_edit_sets_resolver_knobs_directly(self, monkeypatch, tmp_path): + """The [e] escape hatch lets a power user set pinUserPeer + an alias + + prefix directly, bypassing the intent tree.""" + answers = [ + "cloud", "", "eri", "hermetika", "hermes", + "e", # tree: edit raw keys + "false", # pinUserPeer + "99887766=eri", # one alias pair + "", # finish aliases + "discord_", # runtimePeerPrefix + ] + host = self._run_setup(monkeypatch, tmp_path, answers=answers) + assert host["pinUserPeer"] is False + assert host["userPeerAliases"] == {"99887766": "eri"} + assert host["runtimePeerPrefix"] == "discord_" + class TestCloneCarriesPinUserPeer: """``pinUserPeer`` (canonical name for ``pinPeerName``) must survive a From 220a85064742454681a530a881fd8416e89f5bf0 Mon Sep 17 00:00:00 2001 From: Erosika Date: Wed, 10 Jun 2026 16:15:17 -0400 Subject: [PATCH 21/28] docs(honcho): demote pinPeerName to deprecated alias; document gateway identity tree Drop pinPeerName from the key table (now a deprecated-alias note), and replace the single/multi/hybrid 'deployment shapes' section with the gateway-gated intent tree the wizard actually presents, including the [e] raw-edit hatch and the un-pin pooling steer. --- plugins/memory/honcho/README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 3774747d05a7b..77270ffd2ddca 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -137,11 +137,12 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a | Key | Type | Default | Description | |-----|------|---------|-------------| -| `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer. Also accepted as `pinPeerName` | -| `pinPeerName` | bool | `false` | Alias for `pinUserPeer`; same effect | +| `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer | | `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"86701400": "eri"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer | | `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"` → `telegram_86701400`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape | +> **Deprecated:** `pinPeerName` is a legacy alias for `pinUserPeer`, still read for back-compat (`pinUserPeer` wins where both are set). `hermes honcho setup` migrates it onto `pinUserPeer` on touch and never writes it. + **Resolver ladder** (first match wins): ``` @@ -158,13 +159,15 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a **Host vs root semantics.** All three keys are accepted at both root and `hosts.` levels. Host-level wins. For maps and prefixes, host-level *replaces* the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with `userPeerAliases: {}` / `runtimePeerPrefix: ""`. -**Deployment shapes** (`hermes memory setup honcho` asks one prompt to set these): +**Setup — gateway identity tree.** `hermes honcho setup` only asks about identity mapping when it detects a connected gateway platform (it inspects the gateway config; off-gateway the step is skipped because these keys do nothing without a runtime user ID). When it runs, it asks *who talks to this gateway?* and derives the keys: + +- **just me** → `pinUserPeer: true`. All gateway users collapse to `peerName`. Personal use where you connect Hermes to your own Telegram/Discord/etc. +- **me + other people, pooled** → `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName`. You stay on the shared history; everyone else gets their own peer. +- **me + other people / only other people** → `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. For bots serving many humans. -- **Single-operator** — `pinUserPeer: true`. All gateway users → `peerName`. Recommended for personal use where you connect Hermes to your own Telegram/Discord/etc. -- **Multi-user gateway** — `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. Recommended for bots serving many humans. -- **Hybrid** — `pinUserPeer: false`, `userPeerAliases` mapping the operator's runtime IDs to `peerName`. Multi-user gateway where YOU are routed but others stay distinct. +Pick **[e]** at the prompt to set the three keys directly instead of going through the tree. -**Migrating single → multi.** Flipping `pinUserPeer` from `true` to `false` does not migrate data. Memory accumulated under `peerName` while pinned stays there; runtime users now resolve to fresh, empty peers. To preserve your own continuity, use the **hybrid** shape — alias your runtime IDs back to `peerName` so your turns keep landing on the pooled history while other users get their own peers. The setup wizard offers this path automatically when it detects a single → multi transition. +**Un-pinning (single → per-user).** Flipping `pinUserPeer` from `true` to `false` does not migrate data. Memory accumulated under `peerName` while pinned stays there; runtime users now resolve to fresh, empty peers. To preserve your own continuity, choose the **pooled** path — alias your runtime IDs back to `peerName` so your turns keep landing on the pooled history while other users get their own peers. The wizard offers this steer automatically when it detects you're un-pinning a previously pinned profile. ### Memory & Recall From 38af5788dacf576940f8d453280c8b54433b3856 Mon Sep 17 00:00:00 2001 From: Erosika Date: Thu, 11 Jun 2026 14:58:19 -0400 Subject: [PATCH 22/28] docs(website): cover gateway identity mapping in Honcho feature page The identity-mapping keys never made it to the site docs. Add the three keys to the config reference and a Gateway Identity Mapping section: when it applies (gateway only, setup-gated), the intent tree, resolver order, the un-pin orphan warning, and the deprecated pinPeerName alias. --- website/docs/user-guide/features/honcho.md | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index b971bea272d3e..b2493de7f53ab 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -129,6 +129,9 @@ When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and | `messageMaxChars` | `25000` | Max chars per message sent via `add_messages()`. Chunked if exceeded | | `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input to `peer.chat()` | | `sessionStrategy` | `'per-directory'` | `per-directory`, `per-repo`, `per-session`, or `global` | +| `pinUserPeer` | `false` | Gateway only. When `true`, every platform user collapses to `peerName` | +| `userPeerAliases` | `{}` | Gateway only. Map of runtime IDs to peers (`{"86701400": "eri"}`). Many-to-one | +| `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_86701400`) when no alias matches | **Session strategy** controls how Honcho sessions map to your work: - `per-session` — each `hermes` run gets a fresh session. Clean starts, memory via tools. Recommended for new users. @@ -154,6 +157,30 @@ When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and In `tools` mode, the model is fully in control — it calls `honcho_reasoning` when it wants, at whatever `reasoning_level` it picks. Cadence and budget settings only apply to modes with auto-injection (`hybrid` and `context`). +## Gateway Identity Mapping + +These settings only matter when you run the [Hermes gateway](../../developer-guide/gateway-internals.md) — the one entrypoint where users arrive with platform-native runtime IDs (Telegram UID, Discord snowflake, Slack user). CLI, TUI, and desktop sessions have no runtime ID and always resolve to `peerName`, so off-gateway these keys do nothing. + +The setup wizard detects whether a gateway platform is connected and skips this step entirely if not. When it runs, it asks one question — *who talks to this gateway?* — and derives the keys: + +| Answer | Result | +|--------|--------| +| **just me** | `pinUserPeer: true` — everyone collapses to your peer | +| **me + other people** (pooled) | `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName` — you stay on your shared history, others get their own peers | +| **only other people** | `pinUserPeer: false`, optional `runtimePeerPrefix` — each user gets their own peer | + +Pick `[e]` at the prompt to set the three keys directly instead. + +The resolver tries the keys top-down, first match wins: `pinUserPeer` → `userPeerAliases[id]` → `runtimePeerPrefix + id` → raw runtime ID → `peerName` → session-key fallback. + +:::warning Un-pinning orphans pooled memory +Flipping `pinUserPeer` from `true` to `false` does not migrate data — memory accumulated under `peerName` stays there, and platform users resolve to fresh, empty peers. To keep your own continuity, choose the **pooled** path so your runtime IDs alias back to `peerName`. The wizard offers this steer automatically when it detects the transition. +::: + +:::note Deprecated key +`pinPeerName` is a legacy alias for `pinUserPeer` — still read for back-compat (`pinUserPeer` wins where both are set), never written. Re-running setup migrates it onto the canonical key. +::: + ## Observation (Directional vs. Unified) Honcho models a conversation as peers exchanging messages. Each peer has two observation toggles that map 1:1 to Honcho's `SessionPeerConfig`: From f55ffd0006257659fe201f701439ac9ed3786883 Mon Sep 17 00:00:00 2001 From: Erosika Date: Thu, 11 Jun 2026 15:04:01 -0400 Subject: [PATCH 23/28] docs(honcho): anonymize example peer name to alice --- plugins/memory/honcho/README.md | 4 ++-- website/docs/user-guide/features/honcho.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 77270ffd2ddca..44c523be8bf6f 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -138,7 +138,7 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a | Key | Type | Default | Description | |-----|------|---------|-------------| | `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer | -| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"86701400": "eri"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer | +| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"86701400": "alice"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer | | `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"` → `telegram_86701400`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape | > **Deprecated:** `pinPeerName` is a legacy alias for `pinUserPeer`, still read for back-compat (`pinUserPeer` wins where both are set). `hermes honcho setup` migrates it onto `pinUserPeer` on touch and never writes it. @@ -208,7 +208,7 @@ The Honcho session name determines which conversation bucket memory lands in. Re Gateway platforms always resolve via priority 3 (per-chat isolation) regardless of `sessionStrategy`. The strategy setting only affects CLI sessions. -If `sessionPeerPrefix` is `true`, the peer name is prepended: `eri-hermes-agent`. +If `sessionPeerPrefix` is `true`, the peer name is prepended: `alice-hermes-agent`. #### What each strategy produces diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index b2493de7f53ab..4e8caa43a92a7 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -130,7 +130,7 @@ When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and | `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input to `peer.chat()` | | `sessionStrategy` | `'per-directory'` | `per-directory`, `per-repo`, `per-session`, or `global` | | `pinUserPeer` | `false` | Gateway only. When `true`, every platform user collapses to `peerName` | -| `userPeerAliases` | `{}` | Gateway only. Map of runtime IDs to peers (`{"86701400": "eri"}`). Many-to-one | +| `userPeerAliases` | `{}` | Gateway only. Map of runtime IDs to peers (`{"86701400": "alice"}`). Many-to-one | | `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_86701400`) when no alias matches | **Session strategy** controls how Honcho sessions map to your work: From 180ce16e41ec89707ca280a978e77de70bf8a5a6 Mon Sep 17 00:00:00 2001 From: Erosika Date: Thu, 11 Jun 2026 15:06:07 -0400 Subject: [PATCH 24/28] chore(honcho): replace example Telegram UID with placeholder --- plugins/memory/honcho/README.md | 4 +- plugins/memory/honcho/cli.py | 2 +- tests/gateway/test_agent_cache.py | 10 +- tests/honcho_plugin/test_cli.py | 16 +-- tests/honcho_plugin/test_pin_peer_name.py | 120 ++++++++++----------- website/docs/user-guide/features/honcho.md | 4 +- 6 files changed, 78 insertions(+), 78 deletions(-) diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 44c523be8bf6f..70fe1fb53152d 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -138,8 +138,8 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a | Key | Type | Default | Description | |-----|------|---------|-------------| | `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer | -| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"86701400": "alice"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer | -| `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"` → `telegram_86701400`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape | +| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"7654321": "alice"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer | +| `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"` → `telegram_7654321`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape | > **Deprecated:** `pinPeerName` is a legacy alias for `pinUserPeer`, still read for back-compat (`pinUserPeer` wins where both are set). `hermes honcho setup` migrates it onto `pinUserPeer` on touch and never writes it. diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 33edcf12dc03f..25460989df2af 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -413,7 +413,7 @@ def _collect_operator_aliases(existing: dict, peer_target: str) -> dict: print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.") print(" Leave blank to skip a platform. Existing aliases are preserved.") for platform_label, alias_hint in ( - ("Telegram UID", "e.g. 86701400"), + ("Telegram UID", "e.g. 7654321"), ("Discord snowflake", "e.g. 491827364"), ("Slack user ID", "e.g. U04ABCDEF"), ("Matrix MXID", "e.g. @you:matrix.org"), diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index 88806c7d15719..559e1c0e96c53 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -1498,7 +1498,7 @@ def test_signature_changes_with_user_id(self): from gateway.run import GatewayRunner runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"} sig_a = GatewayRunner._agent_config_signature( - "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400" + "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321" ) sig_b = GatewayRunner._agent_config_signature( "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="491827364" @@ -1509,10 +1509,10 @@ def test_signature_stable_with_same_user_id(self): from gateway.run import GatewayRunner runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"} sig_1 = GatewayRunner._agent_config_signature( - "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400" + "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321" ) sig_2 = GatewayRunner._agent_config_signature( - "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400" + "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321" ) assert sig_1 == sig_2 @@ -1521,11 +1521,11 @@ def test_signature_changes_with_user_id_alt(self): runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"} sig_a = GatewayRunner._agent_config_signature( "claude-sonnet-4", runtime, ["hermes-telegram"], "", - user_id="86701400", user_id_alt="@igor_tg", + user_id="7654321", user_id_alt="@igor_tg", ) sig_b = GatewayRunner._agent_config_signature( "claude-sonnet-4", runtime, ["hermes-telegram"], "", - user_id="86701400", user_id_alt="@erosika_tg", + user_id="7654321", user_id_alt="@erosika_tg", ) assert sig_a != sig_b diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index afcc7af077926..c021cdb8cfe17 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -263,7 +263,7 @@ def test_user_peer_aliases_carry_into_cloned_profile(self, monkeypatch, tmp_path "apiKey": "***", "hosts": { "hermes": { - "userPeerAliases": {"86701400": "eri", "discord-491827364": "eri"}, + "userPeerAliases": {"7654321": "eri", "discord-491827364": "eri"}, "peerName": "eri", }, }, @@ -272,7 +272,7 @@ def test_user_peer_aliases_carry_into_cloned_profile(self, monkeypatch, tmp_path ok = honcho_cli.clone_honcho_for_profile("coder") assert ok is True new_block = written["cfg"]["hosts"]["hermes_coder"] - assert new_block["userPeerAliases"] == {"86701400": "eri", "discord-491827364": "eri"} + assert new_block["userPeerAliases"] == {"7654321": "eri", "discord-491827364": "eri"} def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_path): cfg = { @@ -447,7 +447,7 @@ def test_pooled_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp "hermes", # workspace "2", # tree: me + other people "y", # keep my memory pooled? → hybrid - "86701400", # telegram uid + "7654321", # telegram uid "491827364", # discord snowflake "", # slack (skip) "", # matrix (skip) @@ -456,7 +456,7 @@ def test_pooled_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp host = self._run_setup(monkeypatch, tmp_path, answers=answers) assert host["pinUserPeer"] is False assert host["userPeerAliases"] == { - "86701400": "eri", + "7654321": "eri", "491827364": "eri", } assert "runtimePeerPrefix" not in host @@ -498,7 +498,7 @@ def test_unpin_steers_to_pooled_by_default(self, monkeypatch, tmp_path): "hermes", # workspace "3", # tree: only others — triggers the orphan guard "y", # pool my own memory instead? → hybrid - "86701400", # telegram uid + "7654321", # telegram uid "", # discord (skip) "", # slack (skip) "", # matrix (skip) @@ -506,7 +506,7 @@ def test_unpin_steers_to_pooled_by_default(self, monkeypatch, tmp_path): ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) assert host["pinUserPeer"] is False - assert host["userPeerAliases"] == {"86701400": "eri"} + assert host["userPeerAliases"] == {"7654321": "eri"} def test_unpin_decline_steer_keeps_per_user(self, monkeypatch, tmp_path): """Operator can decline the steer ('n') and accept orphaning, ending @@ -575,7 +575,7 @@ def test_root_user_peer_aliases_detected_as_hybrid(self, monkeypatch, tmp_path): """ initial_cfg = { "apiKey": "***", - "userPeerAliases": {"86701400": "eri"}, + "userPeerAliases": {"7654321": "eri"}, "hosts": {"hermes": {"peerName": "eri"}}, } answers = ["cloud", "", "eri", "hermetika", "hermes"] @@ -583,7 +583,7 @@ def test_root_user_peer_aliases_detected_as_hybrid(self, monkeypatch, tmp_path): assert host["pinUserPeer"] is False # Hybrid materialises the root aliases into the host so subsequent # operator edits live on the host block they're inspecting. - assert host["userPeerAliases"] == {"86701400": "eri"} + assert host["userPeerAliases"] == {"7654321": "eri"} def test_only_others_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path): """Explicitly choosing 'only other people' must leave the host diff --git a/tests/honcho_plugin/test_pin_peer_name.py b/tests/honcho_plugin/test_pin_peer_name.py index 1e72bc97d1a45..1a6e2394a8751 100644 --- a/tests/honcho_plugin/test_pin_peer_name.py +++ b/tests/honcho_plugin/test_pin_peer_name.py @@ -105,7 +105,7 @@ def test_root_level_aliases_and_prefix_parse(self, tmp_path): config_file.write_text(json.dumps({ "apiKey": "k", "userPeerAliases": { - " 86701400 ": " Igor ", + " 7654321 ": " Igor ", "": "ignored", "empty-value": " ", "null-value": None, @@ -115,7 +115,7 @@ def test_root_level_aliases_and_prefix_parse(self, tmp_path): config = HonchoClientConfig.from_global_config(config_path=config_file) - assert config.user_peer_aliases == {"86701400": "Igor"} + assert config.user_peer_aliases == {"7654321": "Igor"} assert config.runtime_peer_prefix == "telegram_" def test_host_aliases_override_root_aliases_as_whole_map(self, tmp_path): @@ -226,12 +226,12 @@ def test_runtime_wins_when_pin_is_false(self): mgr = HonchoSessionManager( honcho=MagicMock(), config=self._config(peer_name="Igor", pin_peer_name=False), - runtime_user_peer_name="86701400", # e.g. Telegram UID + runtime_user_peer_name="7654321", # e.g. Telegram UID ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == "86701400", ( + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == "7654321", ( "pin_peer_name=False is the multi-user default — the gateway's " "platform-native user ID must win so each user gets their own " "peer scope. If this regresses, every Telegram/Discord/Slack " @@ -245,14 +245,14 @@ def test_alias_wins_for_known_runtime_id(self): config=self._config( peer_name="Igor", pin_peer_name=False, - user_peer_aliases={"86701400": "Igor"}, + user_peer_aliases={"7654321": "Igor"}, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") + session = mgr.get_or_create("telegram:7654321") assert session.user_peer_id == "Igor" def test_unknown_runtime_id_uses_prefix(self): @@ -264,12 +264,12 @@ def test_unknown_runtime_id_uses_prefix(self): pin_peer_name=False, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == "telegram_86701400" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == "telegram_7654321" def test_prefixed_runtime_id_hashes_when_sanitization_is_lossy(self): """Generated prefixed IDs avoid merges caused by lossy sanitization.""" @@ -291,43 +291,43 @@ def test_prefixed_runtime_id_hashes_when_sanitization_is_lossy(self): def test_prefixed_runtime_id_hashes_when_it_collides_with_peer_name(self): """Unknown generated peers should not silently merge into peerName.""" - raw_peer_id = "telegram_86701400" + raw_peer_id = "telegram_7654321" expected_hash = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()[:8] mgr = HonchoSessionManager( honcho=MagicMock(), config=self._config( - peer_name="telegram_86701400", + peer_name="telegram_7654321", pin_peer_name=False, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == f"telegram_86701400-{expected_hash}" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == f"telegram_7654321-{expected_hash}" def test_prefixed_runtime_id_hashes_when_it_collides_with_alias_target(self): """Unknown generated peers should not silently merge into alias targets.""" - raw_peer_id = "telegram_86701400" + raw_peer_id = "telegram_7654321" expected_hash = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()[:8] mgr = HonchoSessionManager( honcho=MagicMock(), config=self._config( peer_name=None, pin_peer_name=False, - user_peer_aliases={"known-user": "telegram_86701400"}, + user_peer_aliases={"known-user": "telegram_7654321"}, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == f"telegram_86701400-{expected_hash}" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == f"telegram_7654321-{expected_hash}" def test_prefixed_runtime_id_extends_hash_when_short_hash_collides(self): - raw_peer_id = "telegram_86701400" + raw_peer_id = "telegram_7654321" digest = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest() mgr = HonchoSessionManager( honcho=MagicMock(), @@ -335,17 +335,17 @@ def test_prefixed_runtime_id_extends_hash_when_short_hash_collides(self): peer_name=None, pin_peer_name=False, user_peer_aliases={ - "known-user": "telegram_86701400", - "reserved-user": f"telegram_86701400-{digest[:8]}", + "known-user": "telegram_7654321", + "reserved-user": f"telegram_7654321-{digest[:8]}", }, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == f"telegram_86701400-{digest[:12]}" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == f"telegram_7654321-{digest[:12]}" def test_alias_value_is_sanitized_after_selection(self): mgr = HonchoSessionManager( @@ -353,13 +353,13 @@ def test_alias_value_is_sanitized_after_selection(self): config=self._config( peer_name=None, pin_peer_name=False, - user_peer_aliases={"86701400": "Alice Smith!"}, + user_peer_aliases={"7654321": "Alice Smith!"}, ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") + session = mgr.get_or_create("telegram:7654321") assert session.user_peer_id == "Alice-Smith-" def test_alias_keys_match_raw_runtime_id_before_sanitization(self): @@ -391,13 +391,13 @@ def test_session_peer_prefix_is_orthogonal_to_runtime_peer_prefix(self): runtime_peer_prefix="telegram_", session_peer_prefix=True, ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == "telegram_86701400" - assert session.honcho_session_id == "telegram-86701400" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == "telegram_7654321" + assert session.honcho_session_id == "telegram-7654321" def test_config_wins_when_pin_is_true(self): """With pin enabled, configured peer_name beats runtime ID.""" @@ -406,14 +406,14 @@ def test_config_wins_when_pin_is_true(self): config=self._config( peer_name="Igor", pin_peer_name=True, - user_peer_aliases={"86701400": "Alias"}, + user_peer_aliases={"7654321": "Alias"}, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", # Telegram pushes this in + runtime_user_peer_name="7654321", # Telegram pushes this in ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") + session = mgr.get_or_create("telegram:7654321") assert session.user_peer_id == "Igor", ( "With pinPeerName=true the user's configured peer_name must " "beat the platform-native runtime ID so memory stays unified " @@ -429,26 +429,26 @@ def test_pin_noop_when_peer_name_missing(self): config=self._config( peer_name=None, pin_peer_name=True, - user_peer_aliases={"86701400": "Igor"}, + user_peer_aliases={"7654321": "Igor"}, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") + session = mgr.get_or_create("telegram:7654321") assert session.user_peer_id == "Igor" def test_pin_noop_without_peer_name_or_mapping_preserves_runtime(self): mgr = HonchoSessionManager( honcho=MagicMock(), config=self._config(peer_name=None, pin_peer_name=True), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == "86701400" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == "7654321" def test_alt_runtime_id_can_match_alias_without_changing_raw_fallback(self): """Stable alternate IDs can map known users while primary ID fallback stays unchanged.""" @@ -526,11 +526,11 @@ def test_pin_does_not_affect_assistant_peer(self): mgr = HonchoSessionManager( honcho=MagicMock(), config=cfg, - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") + session = mgr.get_or_create("telegram:7654321") assert session.user_peer_id == "Igor" assert session.assistant_peer_id == "hermes-assistant" @@ -556,10 +556,10 @@ def test_telegram_and_discord_collapse_to_one_peer_when_pinned(self): mgr_telegram = HonchoSessionManager( honcho=MagicMock(), config=self._config_pinned(), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr_telegram) - telegram_session = mgr_telegram.get_or_create("telegram:86701400") + telegram_session = mgr_telegram.get_or_create("telegram:7654321") # Discord turn (separate manager instance — simulates a fresh # platform-adapter invocation) @@ -701,20 +701,20 @@ def test_fresh_manager_after_flip_resolves_to_runtime(self): pinned_mgr = HonchoSessionManager( honcho=MagicMock(), config=self._pinned(), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(pinned_mgr) - before = pinned_mgr.get_or_create("telegram:86701400") + before = pinned_mgr.get_or_create("telegram:7654321") assert before.user_peer_id == "Igor" unpinned_mgr = HonchoSessionManager( honcho=MagicMock(), config=self._unpinned(), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(unpinned_mgr) - after = unpinned_mgr.get_or_create("telegram:86701400") - assert after.user_peer_id == "86701400", ( + after = unpinned_mgr.get_or_create("telegram:7654321") + assert after.user_peer_id == "7654321", ( "After flipping pinPeerName off, the same runtime ID must resolve " "to its own peer — otherwise multi-user mode silently merges users." ) @@ -723,14 +723,14 @@ def test_cached_session_survives_config_flip_in_same_manager(self): mgr = HonchoSessionManager( honcho=MagicMock(), config=self._pinned(), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - first = mgr.get_or_create("telegram:86701400") + first = mgr.get_or_create("telegram:7654321") assert first.user_peer_id == "Igor" mgr._config = self._unpinned() - second = mgr.get_or_create("telegram:86701400") + second = mgr.get_or_create("telegram:7654321") assert second.user_peer_id == "Igor", ( "The per-key session cache is keyed by session-key, not by " "resolved peer. In-process flips don't invalidate it — the " @@ -764,7 +764,7 @@ def test_cache_busting_signature_reflects_user_peer_aliases(self, tmp_path, monk cfg_path.write_text(json.dumps({ "apiKey": "k", "peerName": "Igor", - "userPeerAliases": {"86701400": "Igor"}, + "userPeerAliases": {"7654321": "Igor"}, })) sig_with_aliases = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}}) @@ -839,18 +839,18 @@ def test_two_profiles_pinned_to_different_peer_names_resolve_distinctly(self): mgr_a = HonchoSessionManager( honcho=MagicMock(), config=self._pinned_to("alice"), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr_a) - sess_a = mgr_a.get_or_create("telegram:86701400") + sess_a = mgr_a.get_or_create("telegram:7654321") mgr_b = HonchoSessionManager( honcho=MagicMock(), config=self._pinned_to("bob"), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr_b) - sess_b = mgr_b.get_or_create("telegram:86701400") + sess_b = mgr_b.get_or_create("telegram:7654321") assert sess_a.user_peer_id == "alice" assert sess_b.user_peer_id == "bob" diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index 4e8caa43a92a7..a692b26d96b4f 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -130,8 +130,8 @@ When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and | `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input to `peer.chat()` | | `sessionStrategy` | `'per-directory'` | `per-directory`, `per-repo`, `per-session`, or `global` | | `pinUserPeer` | `false` | Gateway only. When `true`, every platform user collapses to `peerName` | -| `userPeerAliases` | `{}` | Gateway only. Map of runtime IDs to peers (`{"86701400": "alice"}`). Many-to-one | -| `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_86701400`) when no alias matches | +| `userPeerAliases` | `{}` | Gateway only. Map of runtime IDs to peers (`{"7654321": "alice"}`). Many-to-one | +| `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_7654321`) when no alias matches | **Session strategy** controls how Honcho sessions map to your work: - `per-session` — each `hermes` run gets a fresh session. Clean starts, memory via tools. Recommended for new users. From 2b9d7f68fea9889d87f0b5e35730866c1827c765 Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 15 Jun 2026 21:34:09 +0000 Subject: [PATCH 25/28] docs(honcho): clarify pinUserPeer pins only non-agent users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'everyone collapses to your peer' read as a promise about all traffic. pinUserPeer pins the user-side peer and is checked before userPeerAliases (session.py:335), so a pin overrides every alias — including agent peers. For a multi-agent operator that silently pools distinct agents onto one peer, the opposite of intent. Scopes the wording to 'every non-agent gateway user', notes the pin overrides aliases, and points agent-mesh operators at pinUserPeer:false + userPeerAliases instead. Same correction in the wizard menu/echo text, the plugin README, and the website Honcho page. --- plugins/memory/honcho/README.md | 2 +- plugins/memory/honcho/cli.py | 4 ++-- website/docs/user-guide/features/honcho.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 70fe1fb53152d..cb9b720bf56a2 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -161,7 +161,7 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a **Setup — gateway identity tree.** `hermes honcho setup` only asks about identity mapping when it detects a connected gateway platform (it inspects the gateway config; off-gateway the step is skipped because these keys do nothing without a runtime user ID). When it runs, it asks *who talks to this gateway?* and derives the keys: -- **just me** → `pinUserPeer: true`. All gateway users collapse to `peerName`. Personal use where you connect Hermes to your own Telegram/Discord/etc. +- **just me** → `pinUserPeer: true`. Every non-agent gateway user collapses to `peerName`; the pin overrides all aliases, so pick this only when no user-side identity needs its own peer. Personal use where you connect Hermes to your own Telegram/Discord/etc. If separate agents reach the gateway and each needs a distinct peer, do **not** pin — leave `pinUserPeer: false` and map them via `userPeerAliases` (the `[e]` editor). - **me + other people, pooled** → `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName`. You stay on the shared history; everyone else gets their own peer. - **me + other people / only other people** → `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. For bots serving many humans. diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 25460989df2af..cc19711e95674 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -699,7 +699,7 @@ def cmd_setup(args) -> None: peer_target = hermes_host.get("peerName") or current_peer or "user" default_choice = {"single": "1", "hybrid": "2", "multi": "3"}.get(current_shape, "3") print("\n How should gateway users map to memory peers?") - print(" [1] just me — everyone collapses to your peer") + print(" [1] just me — every non-agent user collapses to your peer") print(" [2] me + other people — keep mine pooled, others separate") print(" [3] only other people — everyone gets their own peer") print(" [s] skip (leave untouched) [e] edit raw keys") @@ -739,7 +739,7 @@ def cmd_setup(args) -> None: if shape == "single": _scrub_identity_mapping(hermes_host) hermes_host["pinUserPeer"] = True - print(f" All gateway users route to '{peer_target}'.") + print(f" All non-agent gateway users route to '{peer_target}' (pin overrides aliases).") _echo_identity_mapping(hermes_host) elif shape == "multi": # Preserve operator-curated host-level aliases across multi → multi diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index a692b26d96b4f..31d8391383072 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -165,7 +165,7 @@ The setup wizard detects whether a gateway platform is connected and skips this | Answer | Result | |--------|--------| -| **just me** | `pinUserPeer: true` — everyone collapses to your peer | +| **just me** | `pinUserPeer: true` — every non-agent gateway user collapses to your peer. Pin overrides all aliases, so pick this only when no user-side identity needs its own peer. If separate agents reach the gateway and each needs a distinct peer, do **not** pin — leave `pinUserPeer: false` and map them via `userPeerAliases` (the `[e]` editor) instead | | **me + other people** (pooled) | `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName` — you stay on your shared history, others get their own peers | | **only other people** | `pinUserPeer: false`, optional `runtimePeerPrefix` — each user gets their own peer | From 2da21086487d112645ba017ece441aac42947399 Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 15 Jun 2026 21:50:24 +0000 Subject: [PATCH 26/28] docs(memory-providers): cover gateway identity mapping for Honcho MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Honcho provider page documented the per-profile peer model (user peer / AI peer / observation) but never the gateway axis — how platform runtime IDs map to peers. Adds the three keys to the config table and a short Gateway identity mapping subsection that points at the Honcho page for the resolver ladder. Uses the corrected pinUserPeer wording (pins non-agent users, overrides aliases) so the provider-comparison reader gets the same accurate framing as the dedicated page. --- .../docs/user-guide/features/memory-providers.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index 43b70334da6d2..476bd46696dd4 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -95,6 +95,9 @@ The legacy `hermes honcho setup` command still works (it now redirects to `herme | `messageMaxChars` | `25000` | Max chars per message (chunked if exceeded) | | `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input to `peer.chat()` | | `sessionStrategy` | `'per-directory'` | `per-directory`, `per-repo`, `per-session`, `global` | +| `pinUserPeer` | `false` | Gateway only. When `true`, every non-agent gateway user collapses to `peerName`; the pin overrides all aliases | +| `userPeerAliases` | `{}` | Gateway only. Maps runtime IDs to peers (`{"7654321": "alice"}`). Many-to-one | +| `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_7654321`) when no alias matches | @@ -199,6 +202,18 @@ Server-side toggles set via the [Honcho dashboard](https://app.honcho.dev) win o See the [Honcho page](./honcho.md#observation-directional-vs-unified) for the full observation reference. +### Gateway identity mapping + +The peer model above covers CLI, TUI, and desktop sessions, where every conversation resolves to `peerName`. The [gateway](../../developer-guide/gateway-internals.md) adds a second axis: users arrive with platform-native runtime IDs (Telegram UID, Discord snowflake, Slack user), and three keys decide which peer each ID resolves to. + +| Key | Effect | +|-----|--------| +| `pinUserPeer: true` | Every non-agent gateway user collapses to `peerName`. The pin is checked first, so it overrides all aliases — pick it only when no user-side identity needs its own peer | +| `userPeerAliases` | Maps specific runtime IDs to peers (`{"7654321": "alice"}`). The home for routing distinct identities — including agents that each carry their own peer | +| `runtimePeerPrefix` | Namespaces any unmapped runtime ID (`telegram_7654321`) so platforms with same-shaped IDs don't collide | + +Off-gateway these keys do nothing. `hermes memory setup` only prompts for them when it detects a connected gateway platform. See the [Honcho page](./honcho.md#gateway-identity-mapping) for the resolver ladder and the setup flow. +
Full honcho.json example (multi-profile) From 72cc32b5089e58fba4222c889f71776bfbf73df5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:52:13 -0700 Subject: [PATCH 27/28] fix(mattermost): preserve thread-local delivery hygiene Salvage the valid thread-routing pieces from #41640: - route Mattermost progress/status sends through metadata thread IDs - treat top-level Mattermost channel posts as thread roots for progress - preserve thread metadata through media/file sends - allow flat fallback only for final notify-worthy replies on confirmed broken roots Co-authored-by: Wolfram Ravenwolf --- gateway/run.py | 18 ++- plugins/platforms/mattermost/adapter.py | 124 +++++++++++++---- tests/gateway/test_mattermost.py | 172 ++++++++++++++++++++++++ 3 files changed, 286 insertions(+), 28 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 1650851fb756d..1c29a593e3c18 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -402,6 +402,17 @@ async def _send_or_update_status_coro(adapter, chat_id, status_key, content, met return await adapter.send(chat_id, content, metadata=metadata) +def _resolve_progress_thread_id(platform: Any, source_thread_id: Any, event_message_id: Any) -> Optional[str]: + """Return thread/root ID that progress/status bubbles should target.""" + platform_value = getattr(platform, "value", platform) + platform_key = str(platform_value or "").lower() + if source_thread_id: + return str(source_thread_id) + if platform_key in {"slack", "mattermost"} and event_message_id: + return str(event_message_id) + return None + + def _telegramize_command_mentions(text: str, platform: Any) -> str: """Rewrite slash-command mentions to Telegram-valid command names. @@ -13884,10 +13895,9 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non # - Feishu only honors reply_in_thread when sending a reply, so topic # progress uses the triggering event message as the reply target # - Other platforms should use explicit source.thread_id only - if source.platform == Platform.SLACK: - _progress_thread_id = source.thread_id or event_message_id - else: - _progress_thread_id = source.thread_id + _progress_thread_id = _resolve_progress_thread_id( + source.platform, source.thread_id, event_message_id, + ) _progress_metadata = ( self._thread_metadata_for_source(source, event_message_id) if _progress_thread_id == source.thread_id diff --git a/plugins/platforms/mattermost/adapter.py b/plugins/platforms/mattermost/adapter.py index bb6dc9b81f248..bc2280cb6d262 100644 --- a/plugins/platforms/mattermost/adapter.py +++ b/plugins/platforms/mattermost/adapter.py @@ -96,6 +96,9 @@ def __init__(self, config: PlatformConfig): or os.getenv("MATTERMOST_REPLY_MODE", "off") ).lower() + self._last_post_status: Optional[int] = None + self._last_post_error: str = "" + # Dedup cache (prevent reprocessing) self._dedup = MessageDeduplicator() @@ -130,20 +133,79 @@ async def _api_post( """POST /api/v4/{path} with JSON body.""" import aiohttp url = f"{self._base_url}/api/v4/{path.lstrip('/')}" + self._last_post_status = None + self._last_post_error = "" try: async with self._session.post( url, headers=self._headers(), json=payload, timeout=aiohttp.ClientTimeout(total=30) ) as resp: + self._last_post_status = resp.status if resp.status >= 400: body = await resp.text() + self._last_post_error = body or "" logger.error("MM API POST %s → %s: %s", path, resp.status, body[:200]) return {} return await resp.json() except aiohttp.ClientError as exc: + self._last_post_error = str(exc) logger.error("MM API POST %s network error: %s", path, exc) return {} + async def _thread_root_for_send( + self, + reply_to: Optional[str], + metadata: Optional[Dict[str, Any]], + ) -> Optional[str]: + """Resolve the Mattermost root_id from reply_to or metadata.""" + if self._reply_mode != "thread": + return None + candidate = reply_to + if not candidate and isinstance(metadata, dict): + candidate = metadata.get("thread_id") or metadata.get("root_id") + if not candidate: + return None + return await self._resolve_root_id(str(candidate)) + + def _last_post_failure_is_broken_thread_root(self) -> bool: + """Return True only for clear invalid/missing Mattermost thread roots.""" + if self._last_post_status not in {400, 404}: + return False + body = (self._last_post_error or "").lower() + if not body: + return False + rootish = any(marker in body for marker in ("root_id", "rootid", "root id", "thread", "post")) + broken = any(marker in body for marker in ("invalid", "not found", "does not exist", "missing")) + return rootish and broken + + async def _post_preserving_thread( + self, + chat_id: str, + payload: Dict[str, Any], + metadata: Optional[Dict[str, Any]], + ) -> Dict[str, Any]: + """Post once, optionally falling back flat for final notify content.""" + data = await self._api_post("posts", payload) + if data or "root_id" not in payload: + return data + if not (isinstance(metadata, dict) and metadata.get("notify")): + return data + if not self._last_post_failure_is_broken_thread_root(): + return data + + flat_payload = dict(payload) + flat_payload.pop("root_id", None) + original = str(flat_payload.get("message") or "") + flat_payload["message"] = ( + "⚠️ Mattermost thread delivery failed; posting final reply in channel.\n\n" + + original + ).strip() + logger.warning( + "Mattermost: falling back to flat channel delivery for notify-worthy post in %s", + chat_id, + ) + return await self._api_post("posts", flat_payload) + async def _api_put( self, path: str, payload: Dict[str, Any] ) -> Dict[str, Any]: @@ -286,14 +348,12 @@ async def send( "channel_id": chat_id, "message": chunk, } - # Thread support: reply_to is the root post ID. - if reply_to and self._reply_mode == "thread": - # Ensure root_id points to the thread root, not a reply. - # Mattermost rejects non-root post IDs as root_id. - resolved_root = await self._resolve_root_id(reply_to) + # Thread support: reply_to or metadata["thread_id"] is the root post ID. + resolved_root = await self._thread_root_for_send(reply_to, metadata) + if resolved_root: payload["root_id"] = resolved_root - data = await self._api_post("posts", payload) + data = await self._post_preserving_thread(chat_id, payload, metadata) if not data or "id" not in data: return SendResult(success=False, error="Failed to create post") last_id = data["id"] @@ -346,7 +406,7 @@ async def send_image( ) -> SendResult: """Download an image and upload it as a file attachment.""" return await self._send_url_as_file( - chat_id, image_url, caption, reply_to, "image" + chat_id, image_url, caption, reply_to, "image", metadata ) async def send_image_file( @@ -359,7 +419,7 @@ async def send_image_file( ) -> SendResult: """Upload a local image file.""" return await self._send_local_file( - chat_id, image_path, caption, reply_to + chat_id, image_path, caption, reply_to, metadata=metadata ) async def send_document( @@ -373,7 +433,7 @@ async def send_document( ) -> SendResult: """Upload a local file as a document.""" return await self._send_local_file( - chat_id, file_path, caption, reply_to, file_name + chat_id, file_path, caption, reply_to, file_name, metadata ) async def send_voice( @@ -386,7 +446,7 @@ async def send_voice( ) -> SendResult: """Upload an audio file.""" return await self._send_local_file( - chat_id, audio_path, caption, reply_to + chat_id, audio_path, caption, reply_to, metadata=metadata ) async def send_video( @@ -399,7 +459,7 @@ async def send_video( ) -> SendResult: """Upload a video file.""" return await self._send_local_file( - chat_id, video_path, caption, reply_to + chat_id, video_path, caption, reply_to, metadata=metadata ) def format_message(self, content: str) -> str: @@ -423,12 +483,13 @@ async def _send_url_as_file( caption: Optional[str], reply_to: Optional[str], kind: str = "file", + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Download a URL and upload it as a file attachment.""" from tools.url_safety import is_safe_url if not is_safe_url(url): logger.warning("Mattermost: blocked unsafe URL (SSRF protection)") - return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to) + return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata) import aiohttp @@ -446,7 +507,7 @@ async def _send_url_as_file( await asyncio.sleep(1.5 * (attempt + 1)) continue if resp.status >= 400: - return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to) + return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata) file_data = await resp.read() ct = resp.content_type or "application/octet-stream" break @@ -455,25 +516,26 @@ async def _send_url_as_file( await asyncio.sleep(1.5 * (attempt + 1)) continue logger.warning("Mattermost: failed to download %s after %d attempts: %s", url, attempt + 1, exc) - return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to) + return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata) if file_data is None: logger.warning("Mattermost: download returned no data for %s", url) - return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to) + return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata) file_id = await self._upload_file(chat_id, file_data, fname, ct) if not file_id: - return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to) + return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata) payload: Dict[str, Any] = { "channel_id": chat_id, "message": caption or "", "file_ids": [file_id], } - if reply_to and self._reply_mode == "thread": - payload["root_id"] = await self._resolve_root_id(reply_to) + resolved_root = await self._thread_root_for_send(reply_to, metadata) + if resolved_root: + payload["root_id"] = resolved_root - data = await self._api_post("posts", payload) + data = await self._post_preserving_thread(chat_id, payload, metadata) if not data or "id" not in data: return SendResult(success=False, error="Failed to post with file") return SendResult(success=True, message_id=data["id"]) @@ -485,6 +547,7 @@ async def _send_local_file( caption: Optional[str], reply_to: Optional[str], file_name: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Upload a local file and attach it to a post.""" import mimetypes @@ -509,10 +572,11 @@ async def _send_local_file( "message": caption or "", "file_ids": [file_id], } - if reply_to and self._reply_mode == "thread": - payload["root_id"] = await self._resolve_root_id(reply_to) + resolved_root = await self._thread_root_for_send(reply_to, metadata) + if resolved_root: + payload["root_id"] = resolved_root - data = await self._api_post("posts", payload) + data = await self._post_preserving_thread(chat_id, payload, metadata) if not data or "id" not in data: return SendResult(success=False, error="Failed to post with file") return SendResult(success=True, message_id=data["id"]) @@ -596,11 +660,14 @@ async def send_multiple_images( "message": "\n".join(caption_parts), "file_ids": file_ids, } + resolved_root = await self._thread_root_for_send(None, metadata) + if resolved_root: + payload["root_id"] = resolved_root logger.info( "Mattermost: sending %d image(s) as single post (chunk %d/%d)", len(file_ids), chunk_idx + 1, len(chunks), ) - data = await self._api_post("posts", payload) + data = await self._post_preserving_thread(chat_id, payload, metadata) if not data or "id" not in data: logger.warning("Mattermost: multi-image post failed, falling back") await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay) @@ -786,8 +853,16 @@ async def _handle_ws_event(self, event: Dict[str, Any]) -> None: sender_id = post.get("user_id", "") sender_name = data.get("sender_name", "").lstrip("@") or sender_id - # Thread support: if the post is in a thread, use root_id. + # Thread support: if the post is in a thread, use root_id. In + # thread mode, top-level channel posts are valid roots for progress. thread_id = post.get("root_id") or None + if ( + not thread_id + and self._reply_mode == "thread" + and channel_type_raw != "D" + and post_id + ): + thread_id = post_id # Determine message type. file_ids = post.get("file_ids") or [] @@ -849,6 +924,7 @@ async def _handle_ws_event(self, event: Dict[str, Any]) -> None: user_id=sender_id, user_name=sender_name, thread_id=thread_id, + message_id=post_id, ) # Per-channel ephemeral prompt diff --git a/tests/gateway/test_mattermost.py b/tests/gateway/test_mattermost.py index cafe5ad68a492..9b174a5137a46 100644 --- a/tests/gateway/test_mattermost.py +++ b/tests/gateway/test_mattermost.py @@ -6,6 +6,30 @@ from unittest.mock import MagicMock, patch, AsyncMock from gateway.config import Platform, PlatformConfig +from gateway.run import _resolve_progress_thread_id + + +class TestMattermostProgressThreadRouting: + def test_top_level_mattermost_progress_uses_event_message_id(self): + assert _resolve_progress_thread_id( + Platform.MATTERMOST, + source_thread_id=None, + event_message_id="top_post_123", + ) == "top_post_123" + + def test_threaded_mattermost_progress_prefers_existing_thread_root(self): + assert _resolve_progress_thread_id( + Platform.MATTERMOST, + source_thread_id="root_post_123", + event_message_id="reply_post_456", + ) == "root_post_123" + + def test_telegram_progress_does_not_use_message_id_as_thread_id(self): + assert _resolve_progress_thread_id( + Platform.TELEGRAM, + source_thread_id=None, + event_message_id="12345", + ) is None # --------------------------------------------------------------------------- @@ -237,6 +261,92 @@ async def test_send_without_thread_no_root_id(self): payload = self.adapter._session.post.call_args[1]["json"] assert "root_id" not in payload + + @pytest.mark.asyncio + async def test_send_uses_metadata_thread_id_for_progress_messages(self): + """Progress/status messages pass Mattermost thread context via metadata.""" + self.adapter._reply_mode = "thread" + self.adapter._api_get = AsyncMock(return_value={"id": "root_post_123", "root_id": ""}) + self.adapter._api_post = AsyncMock(return_value={"id": "progress_post"}) + + result = await self.adapter.send( + "channel_1", + "⚡ terminal...", + metadata={"thread_id": "root_post_123"}, + ) + + assert result.success is True + payload = self.adapter._api_post.call_args_list[0][0][1] + assert payload["root_id"] == "root_post_123" + + @pytest.mark.asyncio + async def test_progress_send_with_invalid_thread_root_never_falls_back_flat(self): + """Tool/status/progress bubbles must stay quiet when the thread is broken.""" + self.adapter._reply_mode = "thread" + self.adapter._api_get = AsyncMock(return_value={"id": "bad_root", "root_id": ""}) + self.adapter._last_post_status = 400 + self.adapter._last_post_error = "api.context.invalid_param.app_error: invalid root_id" + self.adapter._api_post = AsyncMock(return_value={}) + + result = await self.adapter.send( + "channel_1", + "⚙️ terminal...", + metadata={"thread_id": "bad_root"}, + ) + + assert result.success is False + assert self.adapter._api_post.call_count == 1 + payload = self.adapter._api_post.call_args_list[0][0][1] + assert payload["root_id"] == "bad_root" + + @pytest.mark.asyncio + async def test_notify_send_with_invalid_thread_root_falls_back_flat_with_warning(self): + """Notify-worthy replies may fall back flat so the answer is not lost.""" + self.adapter._reply_mode = "thread" + self.adapter._api_get = AsyncMock(return_value={"id": "bad_root", "root_id": ""}) + self.adapter._last_post_status = 400 + self.adapter._last_post_error = "api.context.invalid_param.app_error: invalid root_id" + self.adapter._api_post = AsyncMock(side_effect=[{}, {"id": "flat_final"}]) + + result = await self.adapter.send( + "channel_1", + "Final answer body", + reply_to="bad_root", + metadata={"notify": True}, + ) + + assert result.success is True + assert result.message_id == "flat_final" + assert self.adapter._api_post.call_count == 2 + threaded_payload = self.adapter._api_post.call_args_list[0][0][1] + flat_payload = self.adapter._api_post.call_args_list[1][0][1] + assert threaded_payload["root_id"] == "bad_root" + assert "root_id" not in flat_payload + assert flat_payload["channel_id"] == "channel_1" + assert "Mattermost thread delivery failed" in flat_payload["message"] + assert "Final answer body" in flat_payload["message"] + + @pytest.mark.asyncio + async def test_notify_send_with_server_error_does_not_fall_back_flat(self): + """Notify fallback is only for broken thread roots, not generic API failures.""" + self.adapter._reply_mode = "thread" + self.adapter._api_get = AsyncMock(return_value={"id": "root_post", "root_id": ""}) + self.adapter._last_post_status = 500 + self.adapter._last_post_error = "Internal Server Error" + self.adapter._api_post = AsyncMock(return_value={}) + + result = await self.adapter.send( + "channel_1", + "Final answer body", + reply_to="root_post", + metadata={"notify": True}, + ) + + assert result.success is False + assert self.adapter._api_post.call_count == 1 + payload = self.adapter._api_post.call_args_list[0][0][1] + assert payload["root_id"] == "root_post" + @pytest.mark.asyncio async def test_send_api_failure(self): """When API returns error, send should return failure.""" @@ -750,3 +860,65 @@ async def test_document_media_type_is_full_mime(self): assert msg.media_types == ["application/pdf"] assert not msg.media_types[0].startswith("image/") assert not msg.media_types[0].startswith("audio/") + + + +@pytest.mark.asyncio +async def test_mattermost_top_level_channel_post_is_thread_root(): + adapter = _make_adapter() + adapter._reply_mode = "thread" + adapter._bot_user_id = "bot_user_id" + adapter._bot_username = "hermes-bot" + adapter.handle_message = AsyncMock() + post_data = { + "id": "top_post_123", + "user_id": "user_123", + "channel_id": "chan_456", + "message": "@hermes-bot start work", + "root_id": "", + } + event = { + "event": "posted", + "data": { + "post": json.dumps(post_data), + "channel_type": "O", + "sender_name": "@alice", + }, + } + + await adapter._handle_ws_event(event) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.source.thread_id == "top_post_123" + assert msg_event.source.message_id == "top_post_123" + assert msg_event.message_id == "top_post_123" + + +@pytest.mark.asyncio +async def test_mattermost_dm_post_does_not_seed_thread_root(): + adapter = _make_adapter() + adapter._reply_mode = "thread" + adapter._bot_user_id = "bot_user_id" + adapter._bot_username = "hermes-bot" + adapter.handle_message = AsyncMock() + post_data = { + "id": "dm_post_123", + "user_id": "user_123", + "channel_id": "dm_chan", + "message": "hello", + "root_id": "", + } + event = { + "event": "posted", + "data": { + "post": json.dumps(post_data), + "channel_type": "D", + "sender_name": "@alice", + }, + } + + await adapter._handle_ws_event(event) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.source.thread_id is None + assert msg_event.source.message_id == "dm_post_123" From c470cbd3042d95a62708cb5572118efd964f7699 Mon Sep 17 00:00:00 2001 From: CodeForgeNet Date: Wed, 17 Jun 2026 01:54:42 +0530 Subject: [PATCH 28/28] perf(state): add compact_rows to skip system_prompt blob in session list queries list_sessions_rich and _get_session_rich_row previously used SELECT s.*, pulling the system_prompt TEXT blob on every row even for dashboard and picker callers that never display it. On large databases this blob routinely runs to tens of kilobytes per session, causing unnecessary B-tree I/O. Add compact_rows=False param to both functions. When True, an explicit column list omitting system_prompt is substituted for s.* in both the simple and the recursive-CTE (order_by_last_active) query paths. Default is False so all existing callers are unaffected. Update dashboard and session-picker callers in web_server.py and tui_gateway/server.py to pass compact_rows=True. Add seven regression tests covering: omission of system_prompt, presence of all metadata fields, both query paths, _get_session_rich_row, and backward-compat default. --- hermes_cli/web_server.py | 4 ++- hermes_state.py | 38 ++++++++++++++++++++---- tests/test_hermes_state.py | 60 ++++++++++++++++++++++++++++++++++++++ tui_gateway/server.py | 6 ++-- 4 files changed, 99 insertions(+), 9 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 0e77b3d7a2531..14c5a4febe6c0 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1620,7 +1620,7 @@ async def get_status(): from hermes_state import SessionDB db = SessionDB() try: - sessions = db.list_sessions_rich(limit=50) + sessions = db.list_sessions_rich(limit=50, compact_rows=True) now = time.time() active_sessions = sum( 1 for s in sessions @@ -2607,6 +2607,7 @@ async def get_sessions( include_archived=include_archived, archived_only=archived_only, order_by_last_active=order == "recent", + compact_rows=True, ) total = db.session_count( source=source or None, @@ -2718,6 +2719,7 @@ async def get_profiles_sessions( include_archived=include_archived, archived_only=archived_only, order_by_last_active=order == "recent", + compact_rows=True, ) profile_total = db.session_count( source=source_filter, diff --git a/hermes_state.py b/hermes_state.py index 8ffe8c25f6810..04233578fe3f3 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1977,6 +1977,21 @@ def get_compression_tip(self, session_id: str) -> Optional[str]: current = row["id"] return current + # All sessions columns except the large system_prompt blob, each prefixed + # with the "s" table alias used in list_sessions_rich/_get_session_rich_row + # queries. Used when compact_rows=True to avoid reading the blob for + # dashboard and picker callers that only need lightweight metadata. + _SESSION_COMPACT_COLS = ( + "s.id, s.source, s.user_id, s.model, s.model_config, " + "s.parent_session_id, s.started_at, s.ended_at, s.end_reason, " + "s.message_count, s.tool_call_count, s.input_tokens, s.output_tokens, " + "s.cache_read_tokens, s.cache_write_tokens, s.reasoning_tokens, " + "s.cwd, s.billing_provider, s.billing_base_url, s.billing_mode, " + "s.estimated_cost_usd, s.actual_cost_usd, s.cost_status, s.cost_source, " + "s.pricing_version, s.title, s.api_call_count, s.handoff_state, " + "s.handoff_platform, s.handoff_error, s.rewind_count, s.archived" + ) + def list_sessions_rich( self, source: str = None, @@ -1990,6 +2005,7 @@ def list_sessions_rich( include_archived: bool = False, archived_only: bool = False, id_query: str = None, + compact_rows: bool = False, ) -> List[Dict[str, Any]]: """List sessions with preview (first user message) and last active timestamp. @@ -2017,6 +2033,12 @@ def list_sessions_rich( surfaces in the correct slot. Ordering is computed at SQL level via a recursive CTE that walks compression-continuation edges, so LIMIT and OFFSET still apply efficiently. + + Pass ``compact_rows=True`` for dashboard and picker callers that only + need lightweight metadata. This omits the ``system_prompt`` blob from + the SELECT so SQLite never copies it out of the B-tree page — a + significant I/O saving on large databases where the blob routinely + runs to tens of kilobytes per row. """ where_clauses = [] params = [] @@ -2099,6 +2121,7 @@ def list_sessions_rich( outer_where = ( f"{where_sql} AND {id_clause}" if where_sql else f"WHERE {id_clause}" ) + _sel = self._SESSION_COMPACT_COLS if compact_rows else "s.*" query = f""" WITH RECURSIVE chain(root_id, cur_id) AS ( SELECT s.id, s.id FROM sessions s {where_sql} @@ -2120,7 +2143,7 @@ def list_sessions_rich( FROM chain GROUP BY root_id ) - SELECT s.*, + SELECT {_sel}, COALESCE( (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) FROM messages m @@ -2143,8 +2166,9 @@ def list_sessions_rich( # only applies to the outer select. params = params + params + id_params + [limit, offset] else: + _sel = self._SESSION_COMPACT_COLS if compact_rows else "s.*" query = f""" - SELECT s.*, + SELECT {_sel}, COALESCE( (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) FROM messages m @@ -2281,13 +2305,17 @@ def list_cron_job_runs( runs.append(s) return runs - def _get_session_rich_row(self, session_id: str) -> Optional[Dict[str, Any]]: + def _get_session_rich_row(self, session_id: str, compact_rows: bool = False) -> Optional[Dict[str, Any]]: """Fetch a single session with the same enriched columns as ``list_sessions_rich`` (preview + last_active). Returns None if the session doesn't exist. + + Pass ``compact_rows=True`` to omit the ``system_prompt`` blob (see + ``list_sessions_rich`` for details). """ - query = """ - SELECT s.*, + _sel = self._SESSION_COMPACT_COLS if compact_rows else "s.*" + query = f""" + SELECT {_sel}, COALESCE( (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) FROM messages m diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index f4258f2b915d2..926e81c0ae2dc 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -4141,3 +4141,63 @@ def test_uses_index_range_scan(self, db): detail = " ".join(row[-1] for row in plan) assert "USING INDEX" in detail or "USING COVERING INDEX" in detail, detail assert "idx_sessions_source" in detail, detail + + +# ========================================================================= +# compact_rows — lightweight column projection (issue #47414) +# ========================================================================= + +class TestCompactRows: + """list_sessions_rich and _get_session_rich_row with compact_rows=True + must omit system_prompt but return all other metadata fields.""" + + def _create(self, db, sid, *, system_prompt="big blob " * 500): + db.create_session(session_id=sid, source="cli", model="m") + db.update_system_prompt(sid, system_prompt) + return sid + + def test_compact_rows_omits_system_prompt(self, db): + self._create(db, "s1") + rows = db.list_sessions_rich(compact_rows=True) + assert len(rows) == 1 + assert "system_prompt" not in rows[0] + + def test_full_rows_include_system_prompt(self, db): + self._create(db, "s1", system_prompt="keep me") + rows = db.list_sessions_rich(compact_rows=False) + assert rows[0]["system_prompt"] == "keep me" + + def test_compact_rows_preserves_metadata_fields(self, db): + self._create(db, "s1") + rows = db.list_sessions_rich(compact_rows=True) + row = rows[0] + for field in ("id", "source", "model", "started_at", "message_count", + "input_tokens", "output_tokens", "title", "cwd", + "archived", "preview", "last_active"): + assert field in row, f"missing field: {field}" + + def test_compact_rows_order_by_last_active(self, db): + """compact_rows=True also works with the CTE / order_by_last_active path.""" + self._create(db, "s1") + self._create(db, "s2") + rows = db.list_sessions_rich(compact_rows=True, order_by_last_active=True) + assert len(rows) == 2 + assert all("system_prompt" not in r for r in rows) + + def test_get_session_rich_row_compact_omits_system_prompt(self, db): + self._create(db, "s1", system_prompt="should be gone") + row = db._get_session_rich_row("s1", compact_rows=True) + assert row is not None + assert "system_prompt" not in row + assert row["id"] == "s1" + + def test_get_session_rich_row_full_includes_system_prompt(self, db): + self._create(db, "s1", system_prompt="stay") + row = db._get_session_rich_row("s1", compact_rows=False) + assert row["system_prompt"] == "stay" + + def test_compact_rows_default_is_false(self, db): + """Default behaviour (compact_rows not passed) is unchanged — full rows.""" + self._create(db, "s1", system_prompt="present") + rows = db.list_sessions_rich() + assert "system_prompt" in rows[0] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 4d12a1a417bb7..b1611b6636c86 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3987,7 +3987,7 @@ def _(rid, params: dict) -> dict: fetch_limit = max(limit * 2, 200) rows = [ s - for s in db.list_sessions_rich(source=None, limit=fetch_limit) + for s in db.list_sessions_rich(source=None, limit=fetch_limit, compact_rows=True) if (s.get("source") or "").strip().lower() not in deny ][:limit] return _ok( @@ -4034,7 +4034,7 @@ def _(rid, params: dict) -> dict: # users (lots of recent ``tool`` rows) don't get a false # "no eligible session" answer. ``session.list`` uses a # similar over-fetch strategy. - rows = db.list_sessions_rich(source=None, limit=200) + rows = db.list_sessions_rich(source=None, limit=200, compact_rows=True) for row in rows: src = (row.get("source") or "").strip().lower() if src in deny: @@ -9478,7 +9478,7 @@ def _(rid, params: dict) -> dict: cutoff = time.time() - days * 86400 rows = [ s - for s in db.list_sessions_rich(limit=500) + for s in db.list_sessions_rich(limit=500, compact_rows=True) if (s.get("started_at") or 0) >= cutoff ] return _ok(