Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions acp_adapter/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 7 additions & 2 deletions acp_adapter/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -----------------------------------------------
Expand Down
50 changes: 49 additions & 1 deletion acp_adapter/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -191,19 +215,28 @@ 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.
Used by tests. When omitted, a real AIAgent is created
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 ---------------------------------------------------------

Expand Down Expand Up @@ -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 ""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ephemeral_system_prompt is sent on every request, but ACP usage estimation currently counts only _cached_system_prompt (acp_adapter/server.py:683-686). Please update that estimator and add coverage so startup-skill tokens are reflected in the ACP context indicator.

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.
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions tests/acp/test_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")))

Expand Down
6 changes: 6 additions & 0 deletions tests/acp/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
106 changes: 106 additions & 0 deletions tests/acp/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
63 changes: 63 additions & 0 deletions tests/acp_adapter/test_acp_skills.py
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions tests/hermes_cli/test_acp_skills_forwarding.py
Original file line number Diff line number Diff line change
@@ -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"]