mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
feat: add ACP registry metadata for Zed
This commit is contained in:
parent
e8b9f5ff9a
commit
4c94396206
17 changed files with 683 additions and 75 deletions
|
|
@ -1,8 +1,11 @@
|
|||
"""ACP auth helpers — detect the currently configured Hermes provider."""
|
||||
"""ACP auth helpers — detect and advertise Hermes authentication methods."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
TERMINAL_SETUP_AUTH_METHOD_ID = "hermes-setup"
|
||||
|
||||
|
||||
def detect_provider() -> Optional[str]:
|
||||
|
|
@ -22,3 +25,44 @@ def detect_provider() -> Optional[str]:
|
|||
def has_provider() -> bool:
|
||||
"""Return True if Hermes can resolve any runtime provider credentials."""
|
||||
return detect_provider() is not None
|
||||
|
||||
|
||||
def build_auth_methods() -> list[Any]:
|
||||
"""Return registry-compatible ACP auth methods for Hermes.
|
||||
|
||||
The official ACP registry validates that agents advertise at least one
|
||||
usable auth method during the initial handshake. A fresh Zed install may
|
||||
not have Hermes provider credentials configured yet, so Hermes always
|
||||
advertises a terminal setup method. When credentials are already present,
|
||||
it also advertises the resolved provider as the default agent-managed
|
||||
runtime credential method.
|
||||
"""
|
||||
from acp.schema import AuthMethodAgent, TerminalAuthMethod
|
||||
|
||||
methods: list[Any] = []
|
||||
provider = detect_provider()
|
||||
if provider:
|
||||
methods.append(
|
||||
AuthMethodAgent(
|
||||
id=provider,
|
||||
name=f"{provider} runtime credentials",
|
||||
description=(
|
||||
"Authenticate Hermes using the currently configured "
|
||||
f"{provider} runtime credentials."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
methods.append(
|
||||
TerminalAuthMethod(
|
||||
id=TERMINAL_SETUP_AUTH_METHOD_ID,
|
||||
name="Configure Hermes provider",
|
||||
description=(
|
||||
"Open Hermes' interactive model/provider setup in a terminal. "
|
||||
"Use this when Hermes has not been configured on this machine yet."
|
||||
),
|
||||
type="terminal",
|
||||
args=["--setup"],
|
||||
)
|
||||
)
|
||||
return methods
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ except ModuleNotFoundError:
|
|||
# means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected.
|
||||
pass
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
|
@ -107,8 +108,62 @@ def _load_env() -> None:
|
|||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hermes-acp",
|
||||
description="Run Hermes Agent as an ACP stdio server.",
|
||||
)
|
||||
parser.add_argument("--version", action="store_true", help="Print Hermes version and exit")
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Verify ACP dependencies and adapter imports, then exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--setup",
|
||||
action="store_true",
|
||||
help="Run interactive Hermes provider/model setup for ACP terminal auth",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _print_version() -> None:
|
||||
from hermes_cli import __version__ as hermes_version
|
||||
|
||||
print(hermes_version)
|
||||
|
||||
|
||||
def _run_check() -> None:
|
||||
import acp # noqa: F401
|
||||
from acp_adapter.server import HermesACPAgent # noqa: F401
|
||||
|
||||
print("Hermes ACP check OK")
|
||||
|
||||
|
||||
def _run_setup() -> None:
|
||||
from hermes_cli.main import main as hermes_main
|
||||
|
||||
old_argv = sys.argv[:]
|
||||
try:
|
||||
sys.argv = [old_argv[0] if old_argv else "hermes", "model"]
|
||||
hermes_main()
|
||||
finally:
|
||||
sys.argv = old_argv
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
"""Entry point: load env, configure logging, run the ACP agent."""
|
||||
args = _parse_args(argv)
|
||||
if args.version:
|
||||
_print_version()
|
||||
return
|
||||
if args.check:
|
||||
_run_check()
|
||||
return
|
||||
if args.setup:
|
||||
_run_setup()
|
||||
return
|
||||
|
||||
_setup_logging()
|
||||
_load_env()
|
||||
|
||||
|
|
|
|||
|
|
@ -57,13 +57,7 @@ from acp.schema import (
|
|||
UserMessageChunk,
|
||||
)
|
||||
|
||||
# AuthMethodAgent was renamed from AuthMethod in agent-client-protocol 0.9.0
|
||||
try:
|
||||
from acp.schema import AuthMethodAgent
|
||||
except ImportError:
|
||||
from acp.schema import AuthMethod as AuthMethodAgent # type: ignore[attr-defined]
|
||||
|
||||
from acp_adapter.auth import detect_provider
|
||||
from acp_adapter.auth import TERMINAL_SETUP_AUTH_METHOD_ID, build_auth_methods, detect_provider
|
||||
from acp_adapter.events import (
|
||||
make_message_cb,
|
||||
make_step_cb,
|
||||
|
|
@ -744,16 +738,7 @@ class HermesACPAgent(acp.Agent):
|
|||
resolved_protocol_version = (
|
||||
protocol_version if isinstance(protocol_version, int) else acp.PROTOCOL_VERSION
|
||||
)
|
||||
provider = detect_provider()
|
||||
auth_methods = None
|
||||
if provider:
|
||||
auth_methods = [
|
||||
AuthMethodAgent(
|
||||
id=provider,
|
||||
name=f"{provider} runtime credentials",
|
||||
description=f"Authenticate Hermes using the currently configured {provider} runtime credentials.",
|
||||
)
|
||||
]
|
||||
auth_methods = build_auth_methods()
|
||||
|
||||
client_name = client_info.name if client_info else "unknown"
|
||||
logger.info(
|
||||
|
|
@ -784,10 +769,18 @@ class HermesACPAgent(acp.Agent):
|
|||
# server has provider credentials configured — harmless under
|
||||
# Hermes' threat model (ACP is stdio-only, local-trust), but poor
|
||||
# API hygiene and confusing if ACP ever grows multi-method auth.
|
||||
provider = detect_provider()
|
||||
if not provider:
|
||||
if not isinstance(method_id, str):
|
||||
return None
|
||||
if not isinstance(method_id, str) or method_id.strip().lower() != provider:
|
||||
normalized_method = method_id.strip().lower()
|
||||
provider = detect_provider()
|
||||
|
||||
if normalized_method == TERMINAL_SETUP_AUTH_METHOD_ID:
|
||||
# Terminal auth launches Hermes setup/model selection out-of-band.
|
||||
# Only report success once that flow has produced usable runtime
|
||||
# credentials for the normal ACP session.
|
||||
return AuthenticateResponse() if provider else None
|
||||
|
||||
if not provider or normalized_method != provider:
|
||||
return None
|
||||
return AuthenticateResponse()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
{
|
||||
"schema_version": 1,
|
||||
"name": "hermes-agent",
|
||||
"display_name": "Hermes Agent",
|
||||
"description": "AI agent by Nous Research with 90+ tools, persistent memory, and multi-platform support",
|
||||
"icon": "icon.svg",
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.13.0",
|
||||
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
|
||||
"repository": "https://github.com/NousResearch/hermes-agent",
|
||||
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
"authors": ["Nous Research"],
|
||||
"license": "MIT",
|
||||
"distribution": {
|
||||
"type": "command",
|
||||
"command": "hermes",
|
||||
"args": ["acp"]
|
||||
"npx": {
|
||||
"package": "@nousresearch/hermes-agent-acp@0.13.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="gold" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#F5C542;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#D4961C;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Staff -->
|
||||
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#gold)" />
|
||||
<!-- Wings (left) -->
|
||||
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9" />
|
||||
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill="#D4961C" opacity="0.8" />
|
||||
<!-- Wings (right) -->
|
||||
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9" />
|
||||
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill="#D4961C" opacity="0.8" />
|
||||
<!-- Left serpent -->
|
||||
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42"
|
||||
fill="none" stroke="#F5C542" stroke-width="2.5" stroke-linecap="round" />
|
||||
<!-- Right serpent -->
|
||||
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42"
|
||||
fill="none" stroke="#D4961C" stroke-width="2.5" stroke-linecap="round" />
|
||||
<!-- Orb at top -->
|
||||
<circle cx="32" cy="10" r="4" fill="#F5C542" />
|
||||
<circle cx="32" cy="10" r="2" fill="#FFF8E1" opacity="0.7" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none">
|
||||
<path d="M8 1.5v13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M8 3.25c-2.35-1.4-4.7-.95-6.25.35 1.85-.2 3.8.2 5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 3.25c2.35-1.4 4.7-.95 6.25.35-1.85-.2-3.8.2-5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.25c-2.3-1-3.05-2.65-1.35-4.15-2 .8-2.35 2.95-.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.25c2.3-1 3.05-2.65 1.35-4.15 2 .8 2.35 2.95.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="8" cy="1.8" r="1.1" fill="currentColor"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 882 B |
97
docs/plans/acp-registry-zed-integration.md
Normal file
97
docs/plans/acp-registry-zed-integration.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Hermes Agent ACP Registry + Zed Integration Implementation Plan
|
||||
|
||||
> For Hermes: Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
Goal: Make Hermes Agent installable from Zed's official ACP Registry, so users can add Hermes from Zed's agent panel without manual custom `agent_servers` settings.
|
||||
|
||||
Architecture: Use the official `agentclientprotocol/registry` flow instead of the deprecated Zed Agent Server Extension path. Ship a registry-compatible launcher distribution, advertise valid ACP auth methods during every handshake, validate against official registry schema and auth CI, then submit a registry PR for `hermes-agent`.
|
||||
|
||||
Tech Stack: Hermes Agent Python package, ACP adapter (`hermes acp` / `hermes-acp`), npm launcher package, official ACP Registry JSON schema, Zed external agent UI.
|
||||
|
||||
---
|
||||
|
||||
## Compliance constraints
|
||||
|
||||
- Zed v0.221.x+ prefers the ACP Registry for external agents; do not use Zed Agent Server Extensions for distribution.
|
||||
- Registry repo layout is top-level `hermes-agent/agent.json` and `hermes-agent/icon.svg`, not `agents/hermes-agent/`.
|
||||
- Registry metadata must use the official schema: `id`, `name`, `version`, `description`, `distribution`, optional `repository`, `website`, `authors`, `license`.
|
||||
- Distribution must be exactly one supported type unless intentionally adding another: `binary`, `npx`, or `uvx`.
|
||||
- Hermes must advertise at least one valid `authMethods` entry on a clean first-run handshake. No-provider/no-auth is not compliant.
|
||||
- Terminal Auth must be explicit and deterministic: `id: hermes-setup`, `type: terminal`, `args: ["--setup"]`.
|
||||
- `icon.svg` must be 16x16, square, monochrome, and use only `currentColor` / `none` for fill/stroke; no gradients, hardcoded colors, or `url(#...)` paints.
|
||||
- ACP server mode must reserve stdout for JSON-RPC only. Diagnostics/logs go to stderr. `--version`, `--check`, and `--setup` are not server mode and may print normally.
|
||||
- Published npm package must exist and be runnable before the upstream registry PR references it.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
1. Verify/implement ACP auth methods.
|
||||
- Always return terminal setup auth from `initialize()`.
|
||||
- Return configured provider auth in addition when provider credentials are resolvable.
|
||||
- Add tests for provider auth, terminal fallback auth, and authenticate behavior before/after provider setup.
|
||||
|
||||
2. Add non-interactive ACP commands.
|
||||
- `hermes acp --version`
|
||||
- `hermes acp --check`
|
||||
- `hermes acp --setup`
|
||||
- Same behavior through `hermes-acp`.
|
||||
|
||||
3. Build npm launcher package.
|
||||
- Package: `@nousresearch/hermes-agent-acp@<version>`.
|
||||
- Command: `uvx --from 'hermes-agent[acp]==<version>' hermes-acp ...args`.
|
||||
- Fallback: `uv tool run --from ...` when only `uv` exists.
|
||||
- Forward all args, including `--setup`, `--version`, and `--check`.
|
||||
- Preserve stdio in server mode.
|
||||
- Print actionable stderr error when `uv`/`uvx` is missing.
|
||||
|
||||
4. Replace local registry metadata.
|
||||
- Convert `acp_registry/agent.json` from old command-style local format to official registry schema.
|
||||
- Replace `acp_registry/icon.svg` with compliant 16x16 currentColor icon.
|
||||
- Add tests rejecting old fields (`schema_version`, `display_name`, `distribution.type`, `distribution.command`) and unknown distribution keys.
|
||||
|
||||
5. Update docs.
|
||||
- Zed docs show official ACP Registry install first: Add Agent / `zed: acp registry` -> search Hermes Agent -> install.
|
||||
- Manual `agent_servers` JSON remains only as local-development fallback.
|
||||
- Docs include `uv` prerequisite and `hermes acp --check` troubleshooting.
|
||||
- Developer internals mention npm launcher and terminal setup auth.
|
||||
|
||||
6. Validate locally.
|
||||
- `python -m pytest tests/acp/test_auth.py tests/acp/test_server.py tests/acp/test_entry.py tests/acp/test_registry_manifest.py -q`
|
||||
- `(cd packages/hermes-agent-acp && npm test)`
|
||||
- `(cd packages/hermes-agent-acp && npm pack --dry-run)`
|
||||
- `hermes acp --version`
|
||||
- `hermes acp --check`
|
||||
|
||||
7. Validate against official registry tooling before PR.
|
||||
- In a clone/fork of `agentclientprotocol/registry`, copy files into top-level `hermes-agent/`.
|
||||
- Run official dry-run build, e.g. `uv run --with jsonschema .github/workflows/build_registry.py --dry-run`.
|
||||
- Run official auth check if available, e.g. `.github/workflows/scripts/run-registry-docker.sh python3 .github/workflows/verify_agents.py --auth-check`.
|
||||
- Fix any schema/auth issues before submitting.
|
||||
|
||||
8. Publish and submit.
|
||||
- Publish `@nousresearch/hermes-agent-acp@<version>`.
|
||||
- Verify published package:
|
||||
- `npx @nousresearch/hermes-agent-acp@<version> --version`
|
||||
- `npx @nousresearch/hermes-agent-acp@<version> --check`
|
||||
- ACP initialize/authMethods smoke test through the published package.
|
||||
- Open PR to `agentclientprotocol/registry` adding `hermes-agent/agent.json` and `hermes-agent/icon.svg`.
|
||||
|
||||
9. End-to-end Zed verification.
|
||||
- Install Hermes Agent through Zed's ACP Registry.
|
||||
- Start a Hermes thread.
|
||||
- Verify workspace cwd, file tools, terminal tools, tool rendering, and approval prompts.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Hermes appears in Zed's official ACP Registry UI.
|
||||
- Install starts Hermes without custom Zed settings.
|
||||
- Registry CI passes schema and auth validation.
|
||||
- ACP stdout remains JSON-RPC only; all logs go to stderr.
|
||||
- `authMethods` are present and valid on clean first run.
|
||||
- Terminal Auth can launch Hermes provider/model setup with `--setup`.
|
||||
- Zed workspace cwd is honored by Hermes file and terminal tools.
|
||||
- Docs describe registry install first and manual custom config second.
|
||||
- Package/release automation prevents registry entries from pointing at unpublished versions.
|
||||
|
|
@ -11699,16 +11699,39 @@ Examples:
|
|||
description="Start Hermes Agent in ACP mode for editor integration (VS Code, Zed, JetBrains)",
|
||||
)
|
||||
_add_accept_hooks_flag(acp_parser)
|
||||
acp_parser.add_argument(
|
||||
"--version",
|
||||
action="store_true",
|
||||
dest="acp_version",
|
||||
help="Print Hermes ACP version and exit",
|
||||
)
|
||||
acp_parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Verify ACP dependencies and adapter imports, then exit",
|
||||
)
|
||||
acp_parser.add_argument(
|
||||
"--setup",
|
||||
action="store_true",
|
||||
help="Run interactive Hermes provider/model setup for ACP terminal auth",
|
||||
)
|
||||
|
||||
def cmd_acp(args):
|
||||
"""Launch Hermes Agent as an ACP server."""
|
||||
try:
|
||||
from acp_adapter.entry import main as acp_main
|
||||
|
||||
acp_main()
|
||||
acp_argv = []
|
||||
if getattr(args, "acp_version", False):
|
||||
acp_argv.append("--version")
|
||||
if getattr(args, "check", False):
|
||||
acp_argv.append("--check")
|
||||
if getattr(args, "setup", False):
|
||||
acp_argv.append("--setup")
|
||||
acp_main(acp_argv)
|
||||
except ImportError:
|
||||
print("ACP dependencies not installed.")
|
||||
print("Install them with: pip install -e '.[acp]'")
|
||||
print("ACP dependencies not installed.", file=sys.stderr)
|
||||
print("Install them with: pip install -e '.[acp]'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
acp_parser.set_defaults(func=cmd_acp)
|
||||
|
|
|
|||
26
packages/hermes-agent-acp/README.md
Normal file
26
packages/hermes-agent-acp/README.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# @nousresearch/hermes-agent-acp
|
||||
|
||||
ACP launcher for Hermes Agent.
|
||||
|
||||
This package is intended for clients such as Zed that install agents through the official ACP Registry. It launches the Python Hermes ACP server with:
|
||||
|
||||
```bash
|
||||
uvx --from 'hermes-agent[acp]==0.13.0' hermes-acp
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 18+
|
||||
- `uv` or `uvx` on PATH
|
||||
- Hermes provider credentials configured with `hermes model`, or through Hermes' normal `~/.hermes/.env` / `~/.hermes/config.yaml` setup
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npx @nousresearch/hermes-agent-acp@0.13.0 --version
|
||||
npx @nousresearch/hermes-agent-acp@0.13.0 --check
|
||||
npx @nousresearch/hermes-agent-acp@0.13.0 --setup
|
||||
npx @nousresearch/hermes-agent-acp@0.13.0
|
||||
```
|
||||
|
||||
Normal no-argument mode reserves stdout for ACP JSON-RPC traffic. Diagnostics are emitted on stderr by Hermes.
|
||||
66
packages/hermes-agent-acp/bin/hermes-agent-acp.js
Executable file
66
packages/hermes-agent-acp/bin/hermes-agent-acp.js
Executable file
|
|
@ -0,0 +1,66 @@
|
|||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
|
||||
const HERMES_AGENT_VERSION = '0.13.0';
|
||||
const HERMES_SPEC = `hermes-agent[acp]==${HERMES_AGENT_VERSION}`;
|
||||
|
||||
function commandExists(command) {
|
||||
const result = spawnSync(command, ['--version'], { stdio: 'ignore' });
|
||||
return !result.error && result.status === 0;
|
||||
}
|
||||
|
||||
function buildCommand(argv, exists = commandExists) {
|
||||
if (exists('uvx')) {
|
||||
return {
|
||||
command: 'uvx',
|
||||
args: ['--from', HERMES_SPEC, 'hermes-acp', ...argv],
|
||||
};
|
||||
}
|
||||
|
||||
if (exists('uv')) {
|
||||
return {
|
||||
command: 'uv',
|
||||
args: ['tool', 'run', '--from', HERMES_SPEC, 'hermes-acp', ...argv],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const command = buildCommand(argv);
|
||||
|
||||
if (!command) {
|
||||
console.error('Hermes Agent ACP requires uv or uvx to launch the Python package.');
|
||||
console.error('Install uv from https://docs.astral.sh/uv/getting-started/installation/');
|
||||
console.error('Then retry this agent from Zed.');
|
||||
process.exit(127);
|
||||
}
|
||||
|
||||
const child = spawn(command.command, command.args, {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(`Failed to start Hermes Agent ACP: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { buildCommand, HERMES_AGENT_VERSION, HERMES_SPEC };
|
||||
24
packages/hermes-agent-acp/package.json
Normal file
24
packages/hermes-agent-acp/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@nousresearch/hermes-agent-acp",
|
||||
"version": "0.13.0",
|
||||
"description": "ACP launcher for Hermes Agent",
|
||||
"bin": {
|
||||
"hermes-agent-acp": "bin/hermes-agent-acp.js"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
"README.md"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/NousResearch/hermes-agent.git",
|
||||
"directory": "packages/hermes-agent-acp"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
23
packages/hermes-agent-acp/test/launcher.test.js
Normal file
23
packages/hermes-agent-acp/test/launcher.test.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildCommand, HERMES_SPEC } = require('../bin/hermes-agent-acp.js');
|
||||
|
||||
test('uses uvx when available and forwards args', () => {
|
||||
const command = buildCommand(['--version'], (name) => name === 'uvx');
|
||||
|
||||
assert.equal(command.command, 'uvx');
|
||||
assert.deepEqual(command.args, ['--from', HERMES_SPEC, 'hermes-acp', '--version']);
|
||||
});
|
||||
|
||||
test('falls back to uv tool run and forwards setup args', () => {
|
||||
const command = buildCommand(['--setup'], (name) => name === 'uv');
|
||||
|
||||
assert.equal(command.command, 'uv');
|
||||
assert.deepEqual(command.args, ['tool', 'run', '--from', HERMES_SPEC, 'hermes-acp', '--setup']);
|
||||
});
|
||||
|
||||
test('returns null when neither uvx nor uv is available', () => {
|
||||
assert.equal(buildCommand([], () => false), null);
|
||||
});
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
"""Tests for acp_adapter.auth — provider detection."""
|
||||
|
||||
from acp_adapter.auth import has_provider, detect_provider
|
||||
from acp_adapter.auth import (
|
||||
TERMINAL_SETUP_AUTH_METHOD_ID,
|
||||
build_auth_methods,
|
||||
has_provider,
|
||||
detect_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestHasProvider:
|
||||
|
|
@ -54,3 +59,44 @@ class TestDetectProvider:
|
|||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _boom)
|
||||
assert detect_provider() is None
|
||||
|
||||
def test_detect_provider_strips_and_lowercases_provider(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda: {"provider": " OpenRouter ", "api_key": " sk-or-test "},
|
||||
)
|
||||
assert detect_provider() == "openrouter"
|
||||
|
||||
|
||||
class TestBuildAuthMethods:
|
||||
def test_build_auth_methods_returns_provider_and_terminal_when_configured(self, monkeypatch):
|
||||
monkeypatch.setattr("acp_adapter.auth.detect_provider", lambda: "openrouter")
|
||||
|
||||
methods = build_auth_methods()
|
||||
payloads = [method.model_dump(by_alias=True, exclude_none=True) for method in methods]
|
||||
|
||||
assert payloads[0]["id"] == "openrouter"
|
||||
assert payloads[0]["name"] == "openrouter runtime credentials"
|
||||
assert any(payload["id"] == TERMINAL_SETUP_AUTH_METHOD_ID for payload in payloads)
|
||||
terminal = next(payload for payload in payloads if payload["id"] == TERMINAL_SETUP_AUTH_METHOD_ID)
|
||||
assert terminal["type"] == "terminal"
|
||||
assert terminal["args"] == ["--setup"]
|
||||
|
||||
def test_build_auth_methods_returns_terminal_setup_when_unconfigured(self, monkeypatch):
|
||||
monkeypatch.setattr("acp_adapter.auth.detect_provider", lambda: None)
|
||||
|
||||
methods = build_auth_methods()
|
||||
payloads = [method.model_dump(by_alias=True, exclude_none=True) for method in methods]
|
||||
|
||||
assert payloads == [
|
||||
{
|
||||
"args": ["--setup"],
|
||||
"description": (
|
||||
"Open Hermes' interactive model/provider setup in a terminal. "
|
||||
"Use this when Hermes has not been configured on this machine yet."
|
||||
),
|
||||
"id": TERMINAL_SETUP_AUTH_METHOD_ID,
|
||||
"name": "Configure Hermes provider",
|
||||
"type": "terminal",
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,39 @@ def test_main_enables_unstable_protocol(monkeypatch):
|
|||
monkeypatch.setattr(entry, "_load_env", lambda: None)
|
||||
monkeypatch.setattr(acp, "run_agent", fake_run_agent)
|
||||
|
||||
entry.main()
|
||||
entry.main([])
|
||||
|
||||
assert calls["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")))
|
||||
|
||||
entry.main(["--version"])
|
||||
|
||||
output = capsys.readouterr().out.strip()
|
||||
assert output
|
||||
assert "Starting hermes-agent ACP adapter" not in output
|
||||
|
||||
|
||||
def test_main_check_prints_ok_without_starting_server(monkeypatch, capsys):
|
||||
monkeypatch.setattr(entry, "_setup_logging", lambda: (_ for _ in ()).throw(AssertionError("started server")))
|
||||
|
||||
entry.main(["--check"])
|
||||
|
||||
assert capsys.readouterr().out.strip() == "Hermes ACP check OK"
|
||||
|
||||
|
||||
def test_main_setup_runs_model_configuration(monkeypatch):
|
||||
calls = {}
|
||||
|
||||
def fake_hermes_main():
|
||||
import sys
|
||||
|
||||
calls["argv"] = sys.argv[:]
|
||||
|
||||
monkeypatch.setattr("hermes_cli.main.main", fake_hermes_main)
|
||||
|
||||
entry.main(["--setup"])
|
||||
|
||||
assert calls["argv"][1:] == ["model"]
|
||||
|
|
|
|||
96
tests/acp/test_registry_manifest.py
Normal file
96
tests/acp/test_registry_manifest.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""Tests for ACP Registry metadata shipped with Hermes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MANIFEST = ROOT / "acp_registry" / "agent.json"
|
||||
ICON = ROOT / "acp_registry" / "icon.svg"
|
||||
FORBIDDEN_MANIFEST_KEYS = {"schema_version", "display_name"}
|
||||
ALLOWED_DISTRIBUTIONS = {"binary", "npx", "uvx"}
|
||||
|
||||
|
||||
def _manifest() -> dict:
|
||||
return json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _pyproject_version() -> str:
|
||||
data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
return data["project"]["version"]
|
||||
|
||||
|
||||
def test_agent_json_matches_official_registry_required_fields():
|
||||
data = _manifest()
|
||||
|
||||
assert FORBIDDEN_MANIFEST_KEYS.isdisjoint(data)
|
||||
assert data["id"] == "hermes-agent"
|
||||
assert re.fullmatch(r"[a-z][a-z0-9-]*", data["id"])
|
||||
assert data["name"] == "Hermes Agent"
|
||||
assert data["description"]
|
||||
assert data["repository"] == "https://github.com/NousResearch/hermes-agent"
|
||||
assert data["website"].startswith("https://hermes-agent.nousresearch.com/")
|
||||
assert data["authors"] == ["Nous Research"]
|
||||
assert data["license"] == "MIT"
|
||||
assert set(data["distribution"]) <= ALLOWED_DISTRIBUTIONS
|
||||
|
||||
|
||||
def test_agent_json_uses_npx_distribution_without_local_command_fields():
|
||||
data = _manifest()
|
||||
|
||||
assert set(data["distribution"]) == {"npx"}
|
||||
assert set(data["distribution"]["npx"]) == {"package"}
|
||||
assert data["distribution"]["npx"]["package"] == (
|
||||
f"@nousresearch/hermes-agent-acp@{data['version']}"
|
||||
)
|
||||
assert "type" not in data["distribution"]
|
||||
assert "command" not in data["distribution"]
|
||||
assert "args" not in data["distribution"]
|
||||
|
||||
|
||||
def test_agent_json_version_matches_pyproject():
|
||||
assert _manifest()["version"] == _pyproject_version()
|
||||
|
||||
|
||||
def test_npm_launcher_versions_match_pyproject_and_manifest():
|
||||
version = _pyproject_version()
|
||||
package = json.loads(
|
||||
(ROOT / "packages" / "hermes-agent-acp" / "package.json").read_text(encoding="utf-8")
|
||||
)
|
||||
launcher = (ROOT / "packages" / "hermes-agent-acp" / "bin" / "hermes-agent-acp.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert package["version"] == version
|
||||
assert f"const HERMES_AGENT_VERSION = '{version}';" in launcher
|
||||
assert _manifest()["distribution"]["npx"]["package"] == (
|
||||
f"@nousresearch/hermes-agent-acp@{version}"
|
||||
)
|
||||
|
||||
|
||||
def test_icon_svg_is_16x16_current_color():
|
||||
root = ET.fromstring(ICON.read_text(encoding="utf-8"))
|
||||
|
||||
assert root.attrib["viewBox"] == "0 0 16 16"
|
||||
assert root.attrib["width"] == "16"
|
||||
assert root.attrib["height"] == "16"
|
||||
|
||||
|
||||
def test_icon_svg_has_no_hardcoded_colors_or_gradients():
|
||||
text = ICON.read_text(encoding="utf-8")
|
||||
|
||||
assert "linearGradient" not in text
|
||||
assert "radialGradient" not in text
|
||||
assert "url(#" not in text
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", text)
|
||||
|
||||
root = ET.fromstring(text)
|
||||
for element in root.iter():
|
||||
for attr in ("fill", "stroke"):
|
||||
value = element.attrib.get(attr)
|
||||
if value is not None:
|
||||
assert value in {"currentColor", "none"}
|
||||
|
|
@ -33,6 +33,7 @@ from acp.schema import (
|
|||
UsageUpdate,
|
||||
UserMessageChunk,
|
||||
)
|
||||
from acp_adapter.auth import TERMINAL_SETUP_AUTH_METHOD_ID
|
||||
from acp_adapter.server import HermesACPAgent, HERMES_VERSION
|
||||
from acp_adapter.session import SessionManager
|
||||
from hermes_state import SessionDB
|
||||
|
|
@ -92,6 +93,41 @@ class TestInitialize:
|
|||
assert "list" in session_caps
|
||||
assert "resume" in session_caps
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_advertises_provider_and_terminal_auth_methods(self, agent, monkeypatch):
|
||||
monkeypatch.setattr("acp_adapter.auth.detect_provider", lambda: "openrouter")
|
||||
monkeypatch.setattr("acp_adapter.server.detect_provider", lambda: "openrouter")
|
||||
|
||||
resp = await agent.initialize(protocol_version=1)
|
||||
payloads = [method.model_dump(by_alias=True, exclude_none=True) for method in resp.auth_methods]
|
||||
|
||||
assert payloads[0]["id"] == "openrouter"
|
||||
assert payloads[0]["name"] == "openrouter runtime credentials"
|
||||
terminal = next(payload for payload in payloads if payload["id"] == TERMINAL_SETUP_AUTH_METHOD_ID)
|
||||
assert terminal["type"] == "terminal"
|
||||
assert terminal["args"] == ["--setup"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_advertises_terminal_setup_auth_when_no_provider(self, agent, monkeypatch):
|
||||
monkeypatch.setattr("acp_adapter.auth.detect_provider", lambda: None)
|
||||
monkeypatch.setattr("acp_adapter.server.detect_provider", lambda: None)
|
||||
|
||||
resp = await agent.initialize(protocol_version=1)
|
||||
payloads = [method.model_dump(by_alias=True, exclude_none=True) for method in resp.auth_methods]
|
||||
|
||||
assert payloads == [
|
||||
{
|
||||
"args": ["--setup"],
|
||||
"description": (
|
||||
"Open Hermes' interactive model/provider setup in a terminal. "
|
||||
"Use this when Hermes has not been configured on this machine yet."
|
||||
),
|
||||
"id": TERMINAL_SETUP_AUTH_METHOD_ID,
|
||||
"name": "Configure Hermes provider",
|
||||
"type": "terminal",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# authenticate
|
||||
|
|
@ -135,6 +171,24 @@ class TestAuthenticate:
|
|||
resp = await agent.authenticate(method_id="openrouter")
|
||||
assert resp is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_accepts_terminal_setup_after_provider_configured(self, agent, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"acp_adapter.server.detect_provider",
|
||||
lambda: "openrouter",
|
||||
)
|
||||
resp = await agent.authenticate(method_id=TERMINAL_SETUP_AUTH_METHOD_ID)
|
||||
assert isinstance(resp, AuthenticateResponse)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_rejects_terminal_setup_without_provider(self, agent, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"acp_adapter.server.detect_provider",
|
||||
lambda: None,
|
||||
)
|
||||
resp = await agent.authenticate(method_id=TERMINAL_SETUP_AUTH_METHOD_ID)
|
||||
assert resp is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# new_session / cancel / load / resume
|
||||
|
|
|
|||
|
|
@ -24,12 +24,15 @@ Key implementation files:
|
|||
```text
|
||||
hermes acp / hermes-acp / python -m acp_adapter
|
||||
-> acp_adapter.entry.main()
|
||||
-> parse --version / --check / --setup before server startup
|
||||
-> load ~/.hermes/.env
|
||||
-> configure stderr logging
|
||||
-> construct HermesACPAgent
|
||||
-> acp.run_agent(agent, use_unstable_protocol=True)
|
||||
```
|
||||
|
||||
The Zed ACP Registry path launches the same adapter through `npx @nousresearch/hermes-agent-acp@<version>`, which delegates to `uvx --from 'hermes-agent[acp]==<version>' hermes-acp`.
|
||||
|
||||
Stdout is reserved for ACP JSON-RPC transport. Human-readable logs go to stderr.
|
||||
|
||||
## Major components
|
||||
|
|
@ -146,7 +149,7 @@ Instead it reuses Hermes' runtime resolver:
|
|||
- `acp_adapter/auth.py`
|
||||
- `hermes_cli/runtime_provider.py`
|
||||
|
||||
So ACP advertises and uses the currently configured Hermes provider/credentials.
|
||||
So ACP advertises and uses the currently configured Hermes provider/credentials. It also always advertises a terminal setup auth method (`hermes-setup`, args `--setup`) so first-run registry clients can open Hermes' interactive model/provider configuration before starting a normal ACP session.
|
||||
|
||||
## Working directory binding
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,14 @@ This installs the `agent-client-protocol` dependency and enables:
|
|||
- `hermes-acp`
|
||||
- `python -m acp_adapter`
|
||||
|
||||
For Zed registry installs, Zed launches Hermes through the official ACP Registry entry. That entry uses the npm launcher package `@nousresearch/hermes-agent-acp`, which runs:
|
||||
|
||||
```bash
|
||||
uvx --from 'hermes-agent[acp]==<version>' hermes-acp
|
||||
```
|
||||
|
||||
Make sure `uv` or `uvx` is available on `PATH` before using the registry install path.
|
||||
|
||||
## Launching the ACP server
|
||||
|
||||
Any of the following starts Hermes in ACP mode:
|
||||
|
|
@ -63,6 +71,13 @@ python -m acp_adapter
|
|||
|
||||
Hermes logs to stderr so stdout remains reserved for ACP JSON-RPC traffic.
|
||||
|
||||
For non-interactive checks:
|
||||
|
||||
```bash
|
||||
hermes acp --version
|
||||
hermes acp --check
|
||||
```
|
||||
|
||||
## Editor setup
|
||||
|
||||
### VS Code
|
||||
|
|
@ -90,7 +105,19 @@ If you want to define Hermes manually, add it through VS Code settings under `ac
|
|||
|
||||
### Zed
|
||||
|
||||
Example settings snippet:
|
||||
Zed v0.221.x and newer installs external agents through the official ACP Registry.
|
||||
|
||||
1. Open the Agent Panel.
|
||||
2. Click **Add Agent**, or run the `zed: acp registry` command.
|
||||
3. Search for **Hermes Agent**.
|
||||
4. Install it and start a new Hermes external-agent thread.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Configure Hermes provider credentials first with `hermes model`, or set them in `~/.hermes/.env` / `~/.hermes/config.yaml`.
|
||||
- Install `uv` so the registry launcher can run `uvx --from 'hermes-agent[acp]==<version>' hermes-acp`.
|
||||
|
||||
For local development before the registry entry is available, use a custom agent server in Zed settings:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -98,9 +125,9 @@ Example settings snippet:
|
|||
"hermes-agent": {
|
||||
"type": "custom",
|
||||
"command": "hermes",
|
||||
"args": ["acp"],
|
||||
},
|
||||
},
|
||||
"args": ["acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -114,18 +141,23 @@ Use an ACP-compatible plugin and point it at:
|
|||
|
||||
## Registry manifest
|
||||
|
||||
The ACP registry manifest lives at:
|
||||
The source copy of Hermes' official ACP Registry metadata lives at:
|
||||
|
||||
```text
|
||||
acp_registry/agent.json
|
||||
acp_registry/icon.svg
|
||||
```
|
||||
|
||||
It advertises a command-based agent whose launch command is:
|
||||
The upstream registry PR copies those files into the top-level `hermes-agent/` directory in `agentclientprotocol/registry`.
|
||||
|
||||
The registry entry uses an `npx` distribution:
|
||||
|
||||
```text
|
||||
hermes acp
|
||||
npx @nousresearch/hermes-agent-acp@<version>
|
||||
```
|
||||
|
||||
The launcher then runs `hermes-acp` from the matching Python package version.
|
||||
|
||||
## Configuration and credentials
|
||||
|
||||
ACP mode uses the same Hermes configuration as the CLI:
|
||||
|
|
@ -135,7 +167,7 @@ ACP mode uses the same Hermes configuration as the CLI:
|
|||
- `~/.hermes/skills/`
|
||||
- `~/.hermes/state.db`
|
||||
|
||||
Provider resolution uses Hermes' normal runtime resolver, so ACP inherits the currently configured provider and credentials.
|
||||
Provider resolution uses Hermes' normal runtime resolver, so ACP inherits the currently configured provider and credentials. Hermes also advertises a terminal auth method (`--setup`) for first-run registry clients; this opens Hermes' interactive model/provider setup.
|
||||
|
||||
## Session behavior
|
||||
|
||||
|
|
@ -171,29 +203,36 @@ On timeout or error, the approval bridge denies the request.
|
|||
|
||||
Check:
|
||||
|
||||
- the editor is pointed at the correct `acp_registry/` path
|
||||
- Hermes is installed and on your PATH
|
||||
- the ACP extra is installed (`pip install -e '.[acp]'`)
|
||||
- In Zed, open the ACP Registry with `zed: acp registry` and search for **Hermes Agent**.
|
||||
- For manual/local development, verify the custom `agent_servers` command points to `hermes acp`.
|
||||
- Hermes is installed and on your PATH.
|
||||
- The ACP extra is installed (`pip install -e '.[acp]'`).
|
||||
- `uv` or `uvx` is installed if launching from the official Zed registry entry.
|
||||
|
||||
### ACP starts but immediately errors
|
||||
|
||||
Try these checks:
|
||||
|
||||
```bash
|
||||
hermes acp --version
|
||||
hermes acp --check
|
||||
hermes doctor
|
||||
hermes status
|
||||
hermes acp
|
||||
```
|
||||
|
||||
### Missing credentials
|
||||
|
||||
ACP mode does not have its own login flow. It uses Hermes' existing provider setup. Configure credentials with:
|
||||
ACP mode uses Hermes' existing provider setup. Configure credentials with:
|
||||
|
||||
```bash
|
||||
hermes model
|
||||
```
|
||||
|
||||
or by editing `~/.hermes/.env`.
|
||||
or by editing `~/.hermes/.env`. Registry clients can also trigger Hermes' terminal auth flow, which runs the same interactive provider/model setup.
|
||||
|
||||
### Zed registry launcher cannot find uv
|
||||
|
||||
Install `uv` from the official uv installation docs, then retry the Hermes Agent thread from Zed.
|
||||
|
||||
## See also
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue