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/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. diff --git a/tests/acp/test_tool_policy.py b/tests/acp/test_tool_policy.py new file mode 100644 index 000000000000..4ee627e1dfe1 --- /dev/null +++ b/tests/acp/test_tool_policy.py @@ -0,0 +1,356 @@ +"""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): + pytest.importorskip("acp.schema") + 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 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: