diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index 9ce6281824c9e..d0c60ad18eb46 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -209,7 +209,11 @@ def _run_setup_browser(assume_yes: bool = False) -> int: return 1 -def main(argv: list[str] | None = None) -> None: +def main( + argv: list[str] | None = None, + *, + skills: str | list[str] | tuple[str, ...] | None = None, +) -> None: """Entry point: load env, configure logging, run the ACP agent.""" args = _parse_args(argv) if args.version: @@ -252,7 +256,7 @@ def main(argv: list[str] | None = None) -> None: except Exception: logger.debug("MCP tool discovery failed at ACP startup", exc_info=True) - agent = HermesACPAgent() + agent = HermesACPAgent(skills=skills) try: asyncio.run(acp.run_agent(agent, use_unstable_protocol=True)) except KeyboardInterrupt: diff --git a/acp_adapter/server.py b/acp_adapter/server.py index fbdee70527a38..848fcc23b149c 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -513,9 +513,14 @@ class HermesACPAgent(acp.Agent): value: key for key, value in _MODE_TO_EDIT_APPROVAL_POLICY.items() } - def __init__(self, session_manager: SessionManager | None = None): + def __init__( + self, + session_manager: SessionManager | None = None, + *, + skills: str | list[str] | tuple[str, ...] | None = None, + ): super().__init__() - self.session_manager = session_manager or SessionManager() + self.session_manager = session_manager or SessionManager(skills=skills) self._conn: Optional[acp.Client] = None # ---- Connection lifecycle ----------------------------------------------- diff --git a/acp_adapter/session.py b/acp_adapter/session.py index c40553f267268..50568c375752d 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -166,6 +166,30 @@ def _clear_task_cwd(task_id: str) -> None: logger.debug("Failed to clear ACP task cwd override", exc_info=True) +def _normalize_startup_skills( + skills: str | list[str] | tuple[str, ...] | None, +) -> tuple[str, ...]: + """Normalize launch-time skill flags into an ordered, deduplicated tuple.""" + if not skills: + return () + if isinstance(skills, str): + raw_values = [skills] + elif isinstance(skills, (list, tuple)): + raw_values = [str(item) for item in skills if item is not None] + else: + raw_values = [str(skills)] + + parsed: list[str] = [] + seen: set[str] = set() + for raw in raw_values: + for part in raw.replace("\n", ",").split(","): + item = part.strip() + if item and item not in seen: + seen.add(item) + parsed.append(item) + return tuple(parsed) + + @dataclass class SessionState: """Tracks per-session state for an ACP-managed Hermes agent.""" @@ -191,7 +215,13 @@ class SessionManager: via ``session_search``. """ - def __init__(self, agent_factory=None, db=None): + def __init__( + self, + agent_factory=None, + db=None, + *, + skills: str | list[str] | tuple[str, ...] | None = None, + ): """ Args: agent_factory: Optional callable that creates an AIAgent-like object. @@ -199,11 +229,14 @@ def __init__(self, agent_factory=None, db=None): using the current Hermes runtime provider configuration. db: Optional SessionDB instance. When omitted, the default SessionDB (``~/.hermes/state.db``) is lazily created. + skills: Optional launch-time skills to preload into every real + ACP AIAgent created by this manager. """ self._sessions: Dict[str, SessionState] = {} self._lock = Lock() self._agent_factory = agent_factory self._db_instance = db # None → lazy-init on first use + self._startup_skills = _normalize_startup_skills(skills) # ---- public API --------------------------------------------------------- @@ -620,6 +653,21 @@ def _make_agent( except Exception: logger.debug("ACP session falling back to default provider resolution", exc_info=True) + if self._startup_skills: + from agent.skill_commands import build_preloaded_skills_prompt + + skills_prompt, _loaded_skills, missing_skills = build_preloaded_skills_prompt( + list(self._startup_skills), + task_id=session_id, + ) + if missing_skills: + raise ValueError(f"Unknown skill(s): {', '.join(missing_skills)}") + if skills_prompt: + existing_prompt = kwargs.get("ephemeral_system_prompt") or "" + kwargs["ephemeral_system_prompt"] = "\n\n".join( + part for part in (existing_prompt, skills_prompt) if part + ).strip() + _register_task_cwd(session_id, cwd) agent = AIAgent(**kwargs) # ACP stdio transport requires stdout to remain protocol-only JSON-RPC. diff --git a/hermes_cli/main.py b/hermes_cli/main.py index fa10749012f22..0caf6b80c9f67 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13258,7 +13258,7 @@ def cmd_acp(args): acp_argv.append("--setup-browser") if getattr(args, "assume_yes", False): acp_argv.append("--yes") - acp_main(acp_argv) + acp_main(acp_argv, skills=getattr(args, "skills", None)) except ImportError: print("ACP dependencies not installed.", file=sys.stderr) print("Install them with: pip install -e '.[acp]'", file=sys.stderr) diff --git a/tests/acp/test_entry.py b/tests/acp/test_entry.py index 1d881565bd90f..3188be141c1e3 100644 --- a/tests/acp/test_entry.py +++ b/tests/acp/test_entry.py @@ -23,6 +23,28 @@ async def fake_run_agent(agent, **kwargs): assert calls["kwargs"]["use_unstable_protocol"] is True +def test_main_passes_startup_skills_to_agent(monkeypatch): + calls = {} + + class FakeACPAgent: + def __init__(self, **kwargs): + calls["agent_kwargs"] = kwargs + + async def fake_run_agent(agent, **kwargs): + calls["agent"] = agent + calls["run_kwargs"] = kwargs + + monkeypatch.setattr(entry, "_setup_logging", lambda: None) + monkeypatch.setattr(entry, "_load_env", lambda: None) + monkeypatch.setattr("acp_adapter.server.HermesACPAgent", FakeACPAgent) + monkeypatch.setattr(acp, "run_agent", fake_run_agent) + + entry.main([], skills=["alpha,beta", "gamma"]) + + assert calls["agent_kwargs"]["skills"] == ["alpha,beta", "gamma"] + assert calls["run_kwargs"]["use_unstable_protocol"] is True + + def test_main_version_prints_without_starting_server(monkeypatch, capsys): monkeypatch.setattr(entry, "_setup_logging", lambda: (_ for _ in ()).throw(AssertionError("started server"))) diff --git a/tests/acp/test_server.py b/tests/acp/test_server.py index c1ff1bf4e63e0..eaf40bd5eafbe 100644 --- a/tests/acp/test_server.py +++ b/tests/acp/test_server.py @@ -55,6 +55,12 @@ def agent(mock_manager): return HermesACPAgent(session_manager=mock_manager) +def test_agent_passes_startup_skills_to_default_session_manager(): + acp_agent = HermesACPAgent(skills="alpha,beta") + + assert acp_agent.session_manager._startup_skills == ("alpha", "beta") + + @pytest.mark.asyncio async def test_new_session_exposes_edit_approvals_as_modes_not_config_options(agent): resp = await agent.new_session(cwd="/tmp") diff --git a/tests/acp/test_session.py b/tests/acp/test_session.py index 3651d6ceaf000..9be972308e9b5 100644 --- a/tests/acp/test_session.py +++ b/tests/acp/test_session.py @@ -29,6 +29,16 @@ def manager(): class TestCreateSession: + def test_startup_skills_are_normalized_ordered_and_deduplicated(self): + manager = SessionManager(skills=["alpha,beta", "alpha", " gamma "]) + + assert manager._startup_skills == ("alpha", "beta", "gamma") + + def test_startup_skills_default_empty(self): + manager = SessionManager() + + assert manager._startup_skills == () + def test_create_session_returns_state(self, manager): state = manager.create_session(cwd="/tmp/work") assert isinstance(state, SessionState) @@ -275,6 +285,102 @@ def fake_agent(**kwargs): assert captured["enabled_toolsets"] == ["hermes-acp", "mcp-olympus", "mcp-exa"] + def test_make_agent_injects_startup_skills_as_ephemeral_system_prompt(self, monkeypatch): + captured = {} + skill_calls = {} + + def fake_resolve_runtime_provider(requested=None, **kwargs): + return { + "provider": "openrouter", + "api_mode": "chat_completions", + "base_url": "https://openrouter.example/v1", + "api_key": "***", + "command": None, + "args": [], + } + + def fake_build_preloaded_skills_prompt(skills, task_id=None): + skill_calls["skills"] = list(skills) + skill_calls["task_id"] = task_id + return "SKILL PROMPT", ["alpha", "beta"], [] + + def fake_agent(**kwargs): + captured.update(kwargs) + return SimpleNamespace(model=kwargs.get("model")) + + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"model": {"default": "test-model"}}) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + fake_resolve_runtime_provider, + ) + monkeypatch.setattr( + "agent.skill_commands.build_preloaded_skills_prompt", + fake_build_preloaded_skills_prompt, + ) + monkeypatch.setattr("acp_adapter.session._register_task_cwd", lambda *_args, **_kwargs: None) + + with patch("run_agent.AIAgent", side_effect=fake_agent): + manager = SessionManager(db=None, skills=["alpha,beta"]) + manager._make_agent(session_id="acp-session", cwd=".") + + assert skill_calls == {"skills": ["alpha", "beta"], "task_id": "acp-session"} + assert captured["ephemeral_system_prompt"] == "SKILL PROMPT" + assert "prefill_messages" not in captured + + def test_make_agent_raises_for_unknown_startup_skill(self, monkeypatch): + def fake_build_preloaded_skills_prompt(skills, task_id=None): + return "", [], ["missing-skill"] + + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"model": {"default": "test-model"}}) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **_kwargs: { + "provider": "openrouter", + "api_mode": "chat_completions", + "base_url": "https://openrouter.example/v1", + "api_key": "***", + "command": None, + "args": [], + }, + ) + monkeypatch.setattr( + "agent.skill_commands.build_preloaded_skills_prompt", + fake_build_preloaded_skills_prompt, + ) + monkeypatch.setattr("acp_adapter.session._register_task_cwd", lambda *_args, **_kwargs: None) + + manager = SessionManager(db=None, skills="missing-skill") + + with pytest.raises(ValueError, match="Unknown skill\\(s\\): missing-skill"): + manager._make_agent(session_id="acp-session", cwd=".") + + def test_make_agent_does_not_set_ephemeral_prompt_without_skills(self, monkeypatch): + captured = {} + + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"model": {"default": "test-model"}}) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **_kwargs: { + "provider": "openrouter", + "api_mode": "chat_completions", + "base_url": "https://openrouter.example/v1", + "api_key": "***", + "command": None, + "args": [], + }, + ) + monkeypatch.setattr("acp_adapter.session._register_task_cwd", lambda *_args, **_kwargs: None) + + def fake_agent(**kwargs): + captured.update(kwargs) + return SimpleNamespace(model=kwargs.get("model")) + + with patch("run_agent.AIAgent", side_effect=fake_agent): + manager = SessionManager(db=None) + manager._make_agent(session_id="acp-session", cwd=".") + + assert "ephemeral_system_prompt" not in captured + def test_create_session_writes_to_db(self, manager): state = manager.create_session(cwd="/project") db = manager._get_db() diff --git a/tests/acp_adapter/test_acp_skills.py b/tests/acp_adapter/test_acp_skills.py new file mode 100644 index 0000000000000..674525828d599 --- /dev/null +++ b/tests/acp_adapter/test_acp_skills.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import sys +from types import SimpleNamespace + +import acp + + +def test_hermes_cli_acp_startup_skills_reach_agent_ephemeral_prompt(monkeypatch): + from hermes_cli import main as main_mod + from acp_adapter import entry as entry_mod + + captured = {} + skill_calls = {} + + class CapturingAgent: + def __init__(self, **kwargs): + captured.update(kwargs) + self.model = kwargs.get("model") + + async def fake_run_agent(agent, **kwargs): + captured["run_kwargs"] = kwargs + agent.session_manager._make_agent(session_id="acp-session", cwd=".") + + def fake_build_preloaded_skills_prompt(skills, task_id=None): + skill_calls["skills"] = list(skills) + skill_calls["task_id"] = task_id + return "SKILL PROMPT", list(skills), [] + + monkeypatch.setattr(sys, "argv", ["hermes", "-s", "alpha,beta", "-s", "gamma", "acp"]) + monkeypatch.setattr(entry_mod, "_setup_logging", lambda: None) + monkeypatch.setattr(entry_mod, "_load_env", lambda: None) + monkeypatch.setattr(acp, "run_agent", fake_run_agent) + monkeypatch.setattr("run_agent.AIAgent", CapturingAgent) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"model": {"default": "test-model"}}) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **_kwargs: { + "provider": "openrouter", + "api_mode": "chat_completions", + "base_url": "https://openrouter.example/v1", + "api_key": "***", + "command": None, + "args": [], + }, + ) + monkeypatch.setattr( + "agent.skill_commands.build_preloaded_skills_prompt", + fake_build_preloaded_skills_prompt, + ) + monkeypatch.setattr("acp_adapter.session._register_task_cwd", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + "tools.mcp_tool.discover_mcp_tools", + lambda: None, + raising=False, + ) + + main_mod.main() + + assert captured["run_kwargs"]["use_unstable_protocol"] is True + assert skill_calls == {"skills": ["alpha", "beta", "gamma"], "task_id": "acp-session"} + assert captured["ephemeral_system_prompt"] == "SKILL PROMPT" + assert "prefill_messages" not in captured diff --git a/tests/hermes_cli/test_acp_skills_forwarding.py b/tests/hermes_cli/test_acp_skills_forwarding.py new file mode 100644 index 0000000000000..0488ad9673497 --- /dev/null +++ b/tests/hermes_cli/test_acp_skills_forwarding.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import sys + + +def test_hermes_acp_command_forwards_global_skills(monkeypatch): + from hermes_cli import main as main_mod + from acp_adapter import entry as entry_mod + + calls = {} + + def fake_acp_main(argv=None, **kwargs): + calls["argv"] = list(argv or []) + calls.update(kwargs) + + monkeypatch.setattr(entry_mod, "main", fake_acp_main) + monkeypatch.setattr( + sys, + "argv", + ["hermes", "--skills", "alpha,beta", "-s", "gamma", "acp", "--check"], + ) + + main_mod.main() + + assert calls["argv"] == ["--check"] + assert calls["skills"] == ["alpha,beta", "gamma"]