From bcefd2194c354c3e4cecb1954e4c43aca14f2c1c Mon Sep 17 00:00:00 2001 From: joelbrilliant Date: Fri, 24 Jul 2026 07:48:59 +1000 Subject: [PATCH 1/4] feat(acp): opt-in profile tool policy for full-capability hosts Add acp.tool_policy so ACP hosts can use a profile's local CLI tool configuration instead of the coding-only hermes-acp default. Profile mode resolves tools through the canonical platform resolver, applies the same policy on session restore, and ignores host MCP expansion so the host cannot broaden the selected profile. Default remains hermes-acp for editor compatibility. No config version bump. --- acp_adapter/server.py | 34 +++- acp_adapter/session.py | 95 ++++++++- hermes_cli/config.py | 11 ++ tests/acp/test_tool_policy.py | 355 ++++++++++++++++++++++++++++++++++ 4 files changed, 483 insertions(+), 12 deletions(-) create mode 100644 tests/acp/test_tool_policy.py diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 3e79bdcd38a4..443160bbd557 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -72,7 +72,12 @@ ) from acp_adapter.permissions import make_approval_callback from acp_adapter.provenance import session_provenance_meta -from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets +from acp_adapter.session import ( + SessionManager, + SessionState, + _expand_acp_enabled_toolsets, + resolve_acp_tool_policy, +) from acp_adapter.tools import build_tool_complete, build_tool_start from agent.context_compressor import ( COMPRESSED_SUMMARY_METADATA_KEY, @@ -798,10 +803,35 @@ async def _register_session_mcp_servers( state: SessionState, mcp_servers: list[McpServerStdio | McpServerHttp | McpServerSse] | None, ) -> None: - """Register ACP-provided MCP servers and refresh the agent tool surface.""" + """Register ACP-provided MCP servers and refresh the agent tool surface. + + In ``acp.tool_policy: profile`` mode the host may not expand the + selected profile's capability set. Client-provided MCP servers are + ignored; profile-configured MCP remains whatever session construction + already resolved from Hermes config. + """ if not mcp_servers: return + try: + from hermes_cli.config import load_config + + if resolve_acp_tool_policy(load_config()) == "profile": + logger.info( + "Session %s: ignoring %d host MCP server(s) under " + "acp.tool_policy=profile (profile owns tool policy)", + state.session_id, + len(mcp_servers), + ) + return + except Exception: + logger.debug( + "Session %s: could not resolve ACP tool policy before host MCP " + "registration; continuing with compatibility behaviour", + state.session_id, + exc_info=True, + ) + try: from tools.mcp_tool import register_mcp_servers diff --git a/acp_adapter/session.py b/acp_adapter/session.py index 6f1e17a07f57..d92a84c12675 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -144,6 +144,90 @@ def _expand_acp_enabled_toolsets( return expanded +# Compatibility values keep the historical coding-focused ACP toolset. +_ACP_COMPAT_TOOL_POLICIES = frozenset({"", "hermes-acp", "compat", "default"}) +_ACP_PROFILE_TOOL_POLICY = "profile" + + +def resolve_acp_tool_policy(config: dict | None) -> str: + """Return the effective ACP tool policy for *config*. + + ``hermes-acp`` (default) preserves the coding-focused ACP toolset used by + editor hosts. ``profile`` resolves the selected Hermes profile's local CLI + tool configuration so remote ACP hosts can match interactive Hermes. + + Absent, invalid, or unknown values fall back to the compatibility default. + """ + if not isinstance(config, dict): + return "hermes-acp" + acp_cfg = config.get("acp") + if not isinstance(acp_cfg, dict): + return "hermes-acp" + raw = acp_cfg.get("tool_policy") + if raw is None: + return "hermes-acp" + if not isinstance(raw, str): + return "hermes-acp" + policy = raw.strip().lower() + if policy in _ACP_COMPAT_TOOL_POLICIES: + return "hermes-acp" + if policy == _ACP_PROFILE_TOOL_POLICY: + return _ACP_PROFILE_TOOL_POLICY + return "hermes-acp" + + +def resolve_acp_base_toolsets(config: dict | None) -> List[str]: + """Resolve base ACP toolsets for *config* without host-provided MCP names. + + Profile mode uses the canonical platform tool resolver against the + profile's local interactive policy (``platform_toolsets.cli``), or an + explicit ``platform_toolsets.acp`` list when the profile defines one. + Profile-configured MCP servers are included by that resolver. Host MCP + expansion is intentionally not applied here. + """ + config = config if isinstance(config, dict) else {} + if resolve_acp_tool_policy(config) != _ACP_PROFILE_TOOL_POLICY: + return ["hermes-acp"] + + from hermes_cli.tools_config import _get_platform_tools + + platform_toolsets = config.get("platform_toolsets") or {} + # Explicit acp platform list wins when present; otherwise mirror CLI so a + # profile that already works interactively keeps the same capability set. + if isinstance(platform_toolsets.get("acp"), list): + platform = "acp" + else: + platform = "cli" + + resolved = sorted( + _get_platform_tools( + config, + platform, + include_default_mcp_servers=True, + ) + ) + return resolved or ["hermes-acp"] + + +def resolve_acp_enabled_toolsets(config: dict | None) -> List[str]: + """Resolve the full ACP enabled-toolset list for session creation/restore.""" + config = config if isinstance(config, dict) else {} + base = resolve_acp_base_toolsets(config) + if resolve_acp_tool_policy(config) == _ACP_PROFILE_TOOL_POLICY: + # Profile MCP membership already lives in *base* via _get_platform_tools. + return _expand_acp_enabled_toolsets(base, mcp_server_names=None) + + configured_mcp_servers = [ + name + for name, cfg in (config.get("mcp_servers") or {}).items() + if not isinstance(cfg, dict) or cfg.get("enabled", True) is not False + ] + return _expand_acp_enabled_toolsets( + base, + mcp_server_names=configured_mcp_servers, + ) + + def _clear_task_cwd(task_id: str) -> None: """Remove task-specific cwd overrides for an ACP session.""" if not task_id: @@ -614,18 +698,9 @@ def _make_agent( elif isinstance(model_cfg, str) and model_cfg.strip(): default_model = model_cfg.strip() - configured_mcp_servers = [ - name - for name, cfg in (config.get("mcp_servers") or {}).items() - if not isinstance(cfg, dict) or cfg.get("enabled", True) is not False - ] - kwargs = { "platform": "acp", - "enabled_toolsets": _expand_acp_enabled_toolsets( - ["hermes-acp"], - mcp_server_names=configured_mcp_servers, - ), + "enabled_toolsets": resolve_acp_enabled_toolsets(config), "quiet_mode": True, "session_id": session_id, "session_db": self._get_db(), diff --git a/hermes_cli/config.py b/hermes_cli/config.py index fc690e910ed1..fa9df12d7a70 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3536,6 +3536,17 @@ def _ensure_hermes_home_managed(home: Path): "region": "global", }, + # ACP host integration (Zed, Buzz, and other Agent Client Protocol hosts). + # tool_policy: + # "hermes-acp" (default) — coding-focused ACP toolset for editor hosts. + # "profile" — use this profile's local CLI tool configuration so a remote + # ACP host gets the same capability surface as interactive + # Hermes (skills, memory, cron, kanban, etc. as configured). + # Absent or invalid values preserve the hermes-acp default. No migration. + "acp": { + "tool_policy": "hermes-acp", + }, + # Config schema version - bump this when adding new required fields "_config_version": 33, } diff --git a/tests/acp/test_tool_policy.py b/tests/acp/test_tool_policy.py new file mode 100644 index 000000000000..c7bf07b08455 --- /dev/null +++ b/tests/acp/test_tool_policy.py @@ -0,0 +1,355 @@ +"""ACP tool policy: compatibility vs profile-owned capability. + +Covers the opt-in ``acp.tool_policy`` config that lets ACP hosts either keep +the coding-focused ``hermes-acp`` toolset or resolve the selected profile's +local CLI tool configuration through the canonical platform resolver. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from acp_adapter.session import ( + SessionManager, + resolve_acp_enabled_toolsets, + resolve_acp_tool_policy, +) +from hermes_state import SessionDB + + +# --------------------------------------------------------------------------- +# pure policy resolution +# --------------------------------------------------------------------------- + + +class TestResolveAcpToolPolicy: + def test_absent_defaults_to_compat(self): + assert resolve_acp_tool_policy({}) == "hermes-acp" + assert resolve_acp_tool_policy(None) == "hermes-acp" + + def test_invalid_falls_back_to_compat(self): + assert resolve_acp_tool_policy({"acp": {"tool_policy": "full"}}) == "hermes-acp" + assert resolve_acp_tool_policy({"acp": {"tool_policy": 12}}) == "hermes-acp" + assert resolve_acp_tool_policy({"acp": "profile"}) == "hermes-acp" + + def test_compat_aliases(self): + for value in ("hermes-acp", "compat", "default", "", " COMPAT "): + assert resolve_acp_tool_policy({"acp": {"tool_policy": value}}) == "hermes-acp" + + def test_profile_mode(self): + assert resolve_acp_tool_policy({"acp": {"tool_policy": "profile"}}) == "profile" + assert resolve_acp_tool_policy({"acp": {"tool_policy": " PROFILE "}}) == "profile" + + +class TestResolveAcpEnabledToolsets: + def test_compat_mode_uses_hermes_acp_and_profile_mcp(self): + config = { + "acp": {"tool_policy": "hermes-acp"}, + "mcp_servers": { + "olympus": {"command": "python", "enabled": True}, + "disabled": {"command": "python", "enabled": False}, + }, + } + assert resolve_acp_enabled_toolsets(config) == [ + "hermes-acp", + "mcp-olympus", + ] + + def test_profile_mode_uses_cli_platform_policy(self, monkeypatch): + config = { + "acp": {"tool_policy": "profile"}, + "platform_toolsets": { + "cli": [ + "terminal", + "file", + "memory", + "skills", + "cronjob", + "kanban", + "delegation", + ], + }, + "mcp_servers": {}, + "agent": {}, + } + + # Avoid environment-gated auto-enables polluting the assertion surface. + monkeypatch.delenv("HASS_TOKEN", raising=False) + monkeypatch.setattr( + "hermes_cli.tools_config._xai_credentials_present", + lambda: False, + ) + + enabled = resolve_acp_enabled_toolsets(config) + assert "hermes-acp" not in enabled + for required in ( + "terminal", + "file", + "memory", + "skills", + "cronjob", + "kanban", + "delegation", + ): + assert required in enabled + + def test_profile_mode_honours_disabled_and_default_off_toolsets(self, monkeypatch): + config = { + "acp": {"tool_policy": "profile"}, + "platform_toolsets": { + # Explicit list without homeassistant/spotify — those stay off. + "cli": ["terminal", "file", "memory"], + }, + "agent": { + "disabled_toolsets": ["memory"], + }, + "mcp_servers": {}, + } + monkeypatch.delenv("HASS_TOKEN", raising=False) + monkeypatch.setattr( + "hermes_cli.tools_config._xai_credentials_present", + lambda: False, + ) + + enabled = set(resolve_acp_enabled_toolsets(config)) + assert "terminal" in enabled + assert "file" in enabled + assert "memory" not in enabled + assert "homeassistant" not in enabled + assert "spotify" not in enabled + + def test_profile_mode_prefers_explicit_acp_platform_list(self, monkeypatch): + config = { + "acp": {"tool_policy": "profile"}, + "platform_toolsets": { + "cli": ["terminal", "file", "cronjob", "kanban"], + "acp": ["terminal", "memory"], + }, + "mcp_servers": {}, + "agent": {}, + } + monkeypatch.delenv("HASS_TOKEN", raising=False) + monkeypatch.setattr( + "hermes_cli.tools_config._xai_credentials_present", + lambda: False, + ) + + enabled = set(resolve_acp_enabled_toolsets(config)) + assert "terminal" in enabled + assert "memory" in enabled + assert "cronjob" not in enabled + assert "kanban" not in enabled + + +# --------------------------------------------------------------------------- +# session construction / restore +# --------------------------------------------------------------------------- + + +def _fake_runtime_provider(requested=None, **kwargs): + return { + "provider": "openrouter", + "api_mode": "chat_completions", + "base_url": "https://openrouter.example/v1", + "api_key": "***", + "command": None, + "args": [], + } + + +def _patch_agent_stack(monkeypatch, config, captured): + def fake_agent(**kwargs): + captured.update(kwargs) + return SimpleNamespace( + model=kwargs.get("model"), + enabled_toolsets=kwargs.get("enabled_toolsets"), + session_cwd=None, + _print_fn=None, + ) + + monkeypatch.setattr("hermes_cli.config.load_config", lambda: config) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + _fake_runtime_provider, + ) + monkeypatch.setattr("acp_adapter.session._register_task_cwd", lambda *a, **k: None) + monkeypatch.setattr("run_agent.AIAgent", fake_agent) + + +class TestSessionToolPolicy: + def test_default_compat_session_keeps_hermes_acp(self, tmp_path, monkeypatch): + captured = {} + config = { + "model": {"provider": "openrouter", "default": "test-model"}, + "mcp_servers": {}, + } + _patch_agent_stack(monkeypatch, config, captured) + db = SessionDB(tmp_path / "state.db") + SessionManager(db=db).create_session(cwd="/work") + assert captured["enabled_toolsets"] == ["hermes-acp"] + + def test_profile_mode_session_exposes_configured_cli_toolsets( + self, tmp_path, monkeypatch + ): + captured = {} + config = { + "model": {"provider": "openrouter", "default": "test-model"}, + "acp": {"tool_policy": "profile"}, + "platform_toolsets": { + "cli": ["terminal", "skills", "memory", "cronjob", "delegation"], + }, + "mcp_servers": {}, + "agent": {}, + } + monkeypatch.delenv("HASS_TOKEN", raising=False) + monkeypatch.setattr( + "hermes_cli.tools_config._xai_credentials_present", + lambda: False, + ) + _patch_agent_stack(monkeypatch, config, captured) + db = SessionDB(tmp_path / "state.db") + SessionManager(db=db).create_session(cwd="/work") + enabled = set(captured["enabled_toolsets"]) + assert "hermes-acp" not in enabled + assert {"terminal", "skills", "memory", "cronjob", "delegation"} <= enabled + + def test_restored_session_uses_same_profile_policy(self, tmp_path, monkeypatch): + """Create under profile policy, drop memory, restore — policy must re-apply.""" + captured_create = {} + captured_restore = {} + config = { + "model": {"provider": "openrouter", "default": "test-model"}, + "acp": {"tool_policy": "profile"}, + "platform_toolsets": { + "cli": ["terminal", "file", "cronjob"], + }, + "mcp_servers": {}, + "agent": {}, + } + monkeypatch.delenv("HASS_TOKEN", raising=False) + monkeypatch.setattr( + "hermes_cli.tools_config._xai_credentials_present", + lambda: False, + ) + + def fake_agent(**kwargs): + # First construction is create; after memory drop, restore rebuilds. + if "enabled_toolsets" in kwargs: + if not captured_create: + captured_create.update(kwargs) + else: + captured_restore.update(kwargs) + return SimpleNamespace( + model=kwargs.get("model"), + enabled_toolsets=kwargs.get("enabled_toolsets"), + session_cwd=None, + _print_fn=None, + ) + + monkeypatch.setattr("hermes_cli.config.load_config", lambda: config) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + _fake_runtime_provider, + ) + monkeypatch.setattr("acp_adapter.session._register_task_cwd", lambda *a, **k: None) + monkeypatch.setattr("run_agent.AIAgent", fake_agent) + + db = SessionDB(tmp_path / "state.db") + manager = SessionManager(db=db) + state = manager.create_session(cwd="/work") + sid = state.session_id + + with manager._lock: + del manager._sessions[sid] + + restored = manager.get_session(sid) + assert restored is not None + assert set(captured_create["enabled_toolsets"]) == set( + captured_restore["enabled_toolsets"] + ) + assert "cronjob" in captured_restore["enabled_toolsets"] + assert "hermes-acp" not in captured_restore["enabled_toolsets"] + + def test_restore_still_rejects_non_acp_source(self, tmp_path, monkeypatch): + config = { + "model": {"provider": "openrouter", "default": "test-model"}, + "acp": {"tool_policy": "profile"}, + "platform_toolsets": {"cli": ["terminal"]}, + "mcp_servers": {}, + "agent": {}, + } + monkeypatch.setattr("hermes_cli.config.load_config", lambda: config) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + _fake_runtime_provider, + ) + monkeypatch.setattr("acp_adapter.session._register_task_cwd", lambda *a, **k: None) + monkeypatch.setattr( + "run_agent.AIAgent", + lambda **kwargs: SimpleNamespace( + model="m", + enabled_toolsets=kwargs.get("enabled_toolsets"), + session_cwd=None, + _print_fn=None, + ), + ) + + db = SessionDB(tmp_path / "state.db") + # Seed a non-ACP session row directly. + db.create_session(session_id="desktop-sess-1", source="cli") + manager = SessionManager(db=db) + assert manager.get_session("desktop-sess-1") is None + + +# --------------------------------------------------------------------------- +# host MCP expansion blocked in profile mode +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_profile_mode_ignores_host_mcp_servers(tmp_path, monkeypatch): + from acp.schema import McpServerStdio + from acp_adapter.server import HermesACPAgent + + config = { + "model": {"provider": "openrouter", "default": "test-model"}, + "acp": {"tool_policy": "profile"}, + "platform_toolsets": {"cli": ["terminal", "memory"]}, + "mcp_servers": {}, + "agent": {}, + } + monkeypatch.setattr("hermes_cli.config.load_config", lambda: config) + + agent = HermesACPAgent() + agent.session_manager = SessionManager( + agent_factory=lambda: SimpleNamespace( + enabled_toolsets=["terminal", "memory"], + disabled_toolsets=None, + tools=[], + valid_tool_names=set(), + session_id="s1", + model="m", + ), + db=SessionDB(tmp_path / "state.db"), + ) + state = agent.session_manager.create_session(cwd="/work") + original = list(state.agent.enabled_toolsets) + + register_calls = [] + + def fake_register(config_map): + register_calls.append(config_map) + + monkeypatch.setattr( + "tools.mcp_tool.register_mcp_servers", + fake_register, + ) + + server = McpServerStdio(name="host-extra", command="echo", args=[], env=[]) + await agent._register_session_mcp_servers(state, [server]) + assert register_calls == [] + assert state.agent.enabled_toolsets == original + assert "mcp-host-extra" not in state.agent.enabled_toolsets + assert "host-extra" not in state.agent.enabled_toolsets From 279acf3bca4ba33786bfd932172d186d4130e8c9 Mon Sep 17 00:00:00 2001 From: joelbrilliant Date: Fri, 24 Jul 2026 07:57:55 +1000 Subject: [PATCH 2/4] test(acp): importorskip acp.schema for host MCP policy test Some clean-env runners lack the optional ACP package; skip cleanly. --- tests/acp/test_tool_policy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/acp/test_tool_policy.py b/tests/acp/test_tool_policy.py index c7bf07b08455..4ee627e1dfe1 100644 --- a/tests/acp/test_tool_policy.py +++ b/tests/acp/test_tool_policy.py @@ -310,6 +310,7 @@ def test_restore_still_rejects_non_acp_source(self, tmp_path, monkeypatch): @pytest.mark.asyncio async def test_profile_mode_ignores_host_mcp_servers(tmp_path, monkeypatch): + pytest.importorskip("acp.schema") from acp.schema import McpServerStdio from acp_adapter.server import HermesACPAgent From e226b279d0d5c5897b639ed843488c934ccfa16c Mon Sep 17 00:00:00 2001 From: joelbrilliant Date: Fri, 24 Jul 2026 08:17:30 +1000 Subject: [PATCH 3/4] fix(dashboard): fold acp.tool_policy into agent config tab The new acp.tool_policy default was a single-field CONFIG_SCHEMA category, which fails test_no_single_field_categories. Merge acp into agent like other one-field sections (mcp, computer_use, onboarding). --- hermes_cli/web_server.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 5d45e731f26b..fa34ded9acb4 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -940,6 +940,9 @@ def _memory_provider_options() -> List[str]: # field — fold it into the agent tab rather than spawning a one-field # orphan category. "computer_use": "agent", + # `acp.tool_policy` is the only schema-surfaced ACP host field — fold it + # into the agent tab rather than spawning a one-field orphan category. + "acp": "agent", } # Display order for tabs — unlisted categories sort alphabetically after these. From 61fb3214bf8c5dea7449789e0dd492f9e949f8d4 Mon Sep 17 00:00:00 2001 From: joelbrilliant Date: Fri, 24 Jul 2026 08:36:53 +1000 Subject: [PATCH 4/4] docs(acp): document acp.tool_policy for full-capability hosts Explain profile vs hermes-acp tool policy so remote ACP hosts (Buzz and similar) can match interactive CLI tools without changing editor defaults. --- website/docs/user-guide/features/acp.md | 28 ++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/website/docs/user-guide/features/acp.md b/website/docs/user-guide/features/acp.md index ccdaabc5832d..425bc62662de 100644 --- a/website/docs/user-guide/features/acp.md +++ b/website/docs/user-guide/features/acp.md @@ -19,7 +19,7 @@ ACP is a good fit when you want Hermes to behave like an editor-native coding ag ## What Hermes exposes in ACP mode -Hermes runs with a curated `hermes-acp` toolset designed for editor workflows. It includes: +By default Hermes runs with a curated `hermes-acp` toolset designed for editor workflows. It includes: - file tools: `read_file`, `write_file`, `patch`, `search_files` - terminal tools: `terminal`, `process` @@ -31,6 +31,32 @@ Hermes runs with a curated `hermes-acp` toolset designed for editor workflows. I It intentionally excludes things that do not fit typical editor UX, such as messaging delivery and cronjob management. +### Full-capability hosts (`acp.tool_policy`) + +Editor hosts usually want that coding-focused default. Remote ACP hosts that attach +an existing Hermes profile (for example a messaging desktop surface) often need the +**same tools as interactive CLI** for that profile. + +In `~/.hermes/config.yaml` (or the selected profile home): + +```yaml +acp: + # hermes-acp (default) — coding-focused editor toolset + # profile — use this profile's local CLI tool configuration + tool_policy: profile +``` + +When `tool_policy` is `profile`: + +- tools resolve through the canonical platform resolver against + `platform_toolsets.cli`, or an explicit `platform_toolsets.acp` list when set +- host-provided MCP servers cannot expand the capability set (profile owns policy) +- skills, memory, cron, kanban, delegation, and other profile-enabled surfaces + match interactive Hermes for that home + +Absent or invalid values keep `hermes-acp` (no behaviour change for existing +editor installs). + ## Installation Install Hermes normally, then add the ACP extra from the install checkout: