Skip to content
Closed
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
162 changes: 162 additions & 0 deletions .plans/a2a-protocol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# A2A (Agent2Agent) Protocol Server for Hermes Agent

## Motivation

[A2A](https://a2a-protocol.org) is the open standard for **agent-to-agent**
interoperability: it lets one agent discover another, delegate a task, and
stream back status/artifacts over plain HTTP — regardless of framework, vendor,
or language. Where MCP connects an agent to _tools_, A2A connects an agent to
_other agents_. Exposing Hermes over A2A makes it a first-class participant in
multi-agent systems: any A2A-speaking orchestrator (LangGraph, CrewAI, Google
ADK, custom routers, the `a2a-inspector`) can call Hermes as a remote worker.

Hermes already ships sibling protocol adapters — `acp_adapter/` (editor
integration over stdio) and `mcp_serve.py` (tools over MCP). A2A is the missing
third edge, implemented through the bundled platform plugin surface:
**Hermes as a callable agent for other agents.**

## What It Enables

```
┌────────────────────┐ ┌──────────────────────┐
│ A2A client / peer │ GET /.well-known/agent-card.json │ hermes-a2a │
│ • LangGraph │ ────────────────────────────────► │ (A2A plugin) │
│ • CrewAI │ │ │
│ • Google ADK │ POST / message/send │ ┌────────────────┐ │
│ • a2a-inspector │ ────────────────────────────────► │ │ HermesAgent │ │
│ • another Hermes │ │ │ Executor │ │
│ │ POST / message/stream (SSE) │ └───────┬────────┘ │
│ │ ◄──────────────────────────────── │ │ │
│ │ TaskStatusUpdate / Artifact │ run_conversation() │
└────────────────────┘ │ AIAgent │
└──────────────────────┘
```

A user would:

1. `pip install hermes-agent[a2a]`
2. `hermes-a2a --host 0.0.0.0 --port 9100` (or `python -m plugins.platforms.a2a`)
3. Point any A2A client at `http://localhost:9100` — it fetches the Agent Card,
then sends messages and receives streamed task updates.

## Scope (this cut: working vertical slice)

**In:**

- Agent Card served at `/.well-known/agent-card.json` (A2A v0.3, JSON-RPC transport).
- `message/send` — synchronous request/response (returns a completed `Task`).
- `message/stream` — SSE streaming of `TaskStatusUpdateEvent` + `TaskArtifactUpdateEvent`.
- `tasks/get` / `tasks/cancel` — provided by the SDK's `DefaultRequestHandler` +
a bounded in-memory store; `cancel` wired into Hermes interruption with
monotonic terminal-state persistence.
- Conversation continuity: A2A `contextId` ↔ a persistent Hermes session
(one `AIAgent` + history per context).
- Live agent progress: Hermes tool-calls, reasoning, and streamed text mapped
to A2A working-status updates; the final answer delivered as an artifact.

**Out (deferred, not designed away):**

- Push-notification webhooks (`tasks/pushNotificationConfig/*`).
- Persistent (DB-backed) task store and `tasks/resubscribe`.
- Auth schemes on the card (served unauthenticated; document `0.0.0.0` risk).
- gRPC / HTTP+JSON transports (JSON-RPC only for the slice).
- Multimodal input parts (text-only in; the seam accepts more later).

## Protocol ↔ Hermes mapping

| A2A concept | Hermes equivalent |
| --------------------- | ---------------------------------------------------------------------------------------------- |
| Agent Card | Built from `hermes_cli.__version__` + a curated skill list (mirrors `acp_registry/agent.json`) |
| `contextId` | A Hermes session: one `AIAgent` instance + `conversation_history` |
| `taskId` | One `run_conversation()` turn within a context |
| `message/send` (text) | `agent.run_conversation(user_message=..., conversation_history=..., task_id=...)` |
| streamed text delta | `agent.stream_delta_callback` → `TaskUpdater.update_status(working, msg)` |
| tool start | `agent.tool_progress_callback` (`tool.started`) → working status + tool metadata |
| tool result / step | `agent.step_callback` → working status with result metadata |
| model reasoning | `agent.reasoning_callback` → working status (metadata `kind=reasoning`) |
| final response | `result["final_response"]` → `TaskUpdater.add_artifact(...)` + `complete()` |
| `tasks/cancel` | `session.cancel_event.set()` + `agent.interrupt()` |

This reuses the **exact** callback seam that `acp_adapter/events.py` uses; the
only difference is the translation target (A2A `TaskUpdater` events instead of
ACP `session_update`s).

## Architecture

`AIAgent.run_conversation()` is **synchronous and blocking**, while the a2a-sdk
`AgentExecutor.execute()` is **async** and owns the request's event loop. So,
mirroring the ACP adapter, the agent turn runs in a dedicated bounded worker
pool and its callbacks marshal A2A events back onto the loop
with `asyncio.run_coroutine_threadsafe`. The SDK's `EventQueue` +
`DefaultRequestHandler` turn those events into the JSON-RPC response (or SSE
stream); `A2AStarletteApplication` serves the card and RPC endpoint over uvicorn.

### Module layout (`plugins/platforms/a2a/`)

| File | Responsibility |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `adapter.py` | `BasePlatformAdapter` lifecycle + `ctx.register_platform()` integration |
| `card.py` | `build_agent_card(url)` → `AgentCard` (version, skills, capabilities) |
| `sessions.py` | `ContextSessionStore`: `contextId → HermesSession(agent, history, cancel_event)`; `agent_factory` injection for tests; real `AIAgent` build mirrors `acp_adapter.session._make_agent` |
| `events.py` | Callback factories: AIAgent callbacks → `TaskUpdater` events via a thread-safe scheduler (no heavy Hermes imports, so the adapter is unit-testable standalone) |
| `executor.py` | `HermesAgentExecutor(AgentExecutor)`: `execute()` (resolve session → new task → wire callbacks → run turn in thread → stream → artifact + complete) and `cancel()` |
| `entry.py` | CLI: load `~/.hermes/.env`, logging, args (`--host/--port/--check/--version`), build card+handler+app, `uvicorn.run` |
| `__main__.py` | `python -m plugins.platforms.a2a` |

### Data flow (`message/stream`)

```
client ──POST message/stream──► DefaultRequestHandler ──► HermesAgentExecutor.execute()
│ │ new_task() → enqueue Task
│ │ TaskUpdater.start_work()
│ │ to_thread(agent.run_conversation)
│ │ ├─ stream_delta_cb → update_status(working, text)
│ ◄────────── SSE: TaskStatusUpdateEvent (working) ────────┤ ├─ tool_progress_cb → update_status(working, tool meta)
│ │ └─ step_cb → update_status(working, result meta)
│ │ add_artifact(final_response)
│ ◄────────── SSE: TaskArtifactUpdateEvent ────────────────┤ complete()
│ ◄────────── SSE: TaskStatusUpdateEvent (completed) ──────┘
```

## Packaging

Mirrors the ACP adapter exactly:

- `[project.optional-dependencies]`: `a2a = ["a2a-sdk[http-server]==0.3.26"]`
(pydantic-based 0.3.x line — matches the broad A2A client ecosystem and
Hermes' pydantic idioms; pinned exact per repo policy; published 2026-04-09,
clears the 7-day cooldown).
- `[project.scripts]`: `hermes-a2a = "plugins.platforms.a2a.entry:main"`.
- Bundled discovery: `plugins/platforms/a2a/plugin.yaml` +
`ctx.register_platform(name="a2a", ...)`.
- `[all]`: add `hermes-agent[a2a]` (parity with `acp`; not lazy-installable).

## Testing

Unit/integration tests under `tests/a2a/`, runnable without model credentials by
injecting a `FakeAgent` (same pattern as `tests/acp_adapter`):

- `test_card.py` — card has required fields and is served at the well-known URL
(Starlette `TestClient`).
- `test_executor.py` — a fake agent drives `execute()`; assert the emitted event
sequence is `Task → working → artifact(final_response) → completed`.
- `test_sessions.py` — same `contextId` reuses one agent/history; cancel sets the
event and calls `interrupt()`.
- `test_end_to_end_echo.py` — build the real Starlette app around an echo
executor and drive it in-process via `httpx.ASGITransport` with the A2A client,
proving the full JSON-RPC + SSE path with no network/LLM.

## Why a2a-sdk 0.3.26 (not 1.1.0)

The 1.x line is protobuf-first (`AgentCard`/`Message`/`Part` are proto messages,
verbose to construct, and the ASGI app builder moved). 0.3.26 is the pydantic
line the entire A2A tutorial/client/inspector ecosystem targets today, it reads
naturally alongside Hermes' pydantic code, and it still exposes
`A2AStarletteApplication` + helper builders. For a clean, interoperable slice
it's the better engineering choice; revisit 1.x when the ecosystem's clients move.

## Non-goals / known gaps

- Served unauthenticated by default — bind to `127.0.0.1` unless fronted by a
reverse proxy / auth layer. Documented in `entry.py --help` and the card.
- Bounded in-memory task store: tasks are lost on restart (acceptable for the slice).
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ hermes-agent/
│ └── src/ # entry.tsx, app.tsx, gatewayClient.ts + app/components/hooks/lib
├── tui_gateway/ # Python JSON-RPC backend for the TUI
├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
├── plugins/platforms/a2a/ # A2A server plugin (agent-to-agent task delegation)
├── cron/ # Scheduler — jobs.py, scheduler.py
├── scripts/ # run_tests.sh, release.py, auxiliary scripts
├── website/ # Docusaurus docs site
Expand Down
5 changes: 3 additions & 2 deletions hermes_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@

This module fixes both on Windows *only* — POSIX is untouched. It
should be imported at the very top of every Hermes entry point
(``hermes``, ``hermes-agent``, ``hermes-acp``, ``python -m gateway.run``,
``batch_runner.py``, ``cron/scheduler.py``) before any other imports
(``hermes``, ``hermes-agent``, ``hermes-acp``, ``hermes-a2a``,
``python -m gateway.run``, ``batch_runner.py``, ``cron/scheduler.py``)
before any other imports
that might do file I/O or print to stdout.

What this module does on Windows:
Expand Down
38 changes: 38 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2918,6 +2918,44 @@ def _ensure_hermes_home_managed(home: Path):
"force_ipv4": False,
},

# Agent2Agent protocol server. This is a bundled platform plugin, but its
# standalone console entry point reads the same section so gateway-managed
# and standalone launches share one behavioral configuration surface.
"a2a": {
"enabled": False,
"host": "127.0.0.1",
"port": 9100,
"public_url": None,
# Maximum number of blocking AIAgent turns serviced concurrently.
"max_concurrency": 16,
# In-memory context/session LRU cap.
"max_sessions": 512,
# Retained protocol tasks and per-task status-history bounds.
"max_tasks": 2048,
"max_task_history": 100,
# Peer-visible tool metadata: preview (bounded), none, or full.
"tool_io": "preview",
},

# Default tools for the bundled A2A platform plugin. Keeping this in the
# generic platform tool configuration makes `hermes tools` selections and
# agent.disabled_toolsets authoritative for remotely callable sessions.
"platform_toolsets": {
"a2a": [
"web",
"terminal",
"file",
"vision",
"skills",
"browser",
"todo",
"memory",
"session_search",
"code_execution",
"delegation",
],
},

# Gateway settings — control how messaging platforms (Telegram, Discord,
# Slack, etc.) deliver agent-produced files as native attachments.
"gateway": {
Expand Down
2 changes: 1 addition & 1 deletion nix/checks.nix
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
entry-points-sync = pkgs.runCommand "hermes-entry-points-sync" { } ''
set -e
echo "=== Checking entry points match pyproject.toml [project.scripts] ==="
for bin in hermes hermes-agent hermes-acp; do
for bin in hermes hermes-agent hermes-acp hermes-a2a; do
test -x ${hermes-agent}/bin/$bin || (echo "FAIL: $bin binary missing from Nix package"; exit 1)
echo "PASS: $bin present"
done
Expand Down
1 change: 1 addition & 0 deletions nix/hermes-agent.nix
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ stdenv.mkDerivation (finalAttrs: {
"hermes"
"hermes-agent"
"hermes-acp"
"hermes-a2a"
]
}

Expand Down
2 changes: 1 addition & 1 deletion packaging/homebrew/hermes-agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def install

pkgshare.install "skills", "optional-skills"

%w[hermes hermes-agent hermes-acp].each do |exe|
%w[hermes hermes-agent hermes-acp hermes-a2a].each do |exe|
next unless (libexec/"bin"/exe).exist?

(bin/exe).write_env_script(
Expand Down
26 changes: 26 additions & 0 deletions plugins/platforms/a2a/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""A2A (Agent2Agent) protocol server for Hermes Agent.

Exposes the Hermes ``AIAgent`` as an A2A-compliant remote agent so any
A2A-speaking client or peer agent can discover it (via the Agent Card) and
delegate tasks over JSON-RPC + SSE. Sibling to ``acp_adapter`` (editor
integration over stdio) and ``mcp_serve`` (tools over MCP).

Run it with ``hermes-a2a`` or ``python -m plugins.platforms.a2a``. See
``.plans/a2a-protocol.md`` for the design.
"""

from typing import Any


def register(ctx: Any) -> None:
"""Load the platform adapter only when plugin registration runs.

Keeping this import lazy lets the standalone entry point execute its
bootstrap and import-path hardening before gateway modules are imported.
"""
from .adapter import register as register_adapter

register_adapter(ctx)


__all__ = ["register"]
5 changes: 5 additions & 0 deletions plugins/platforms/a2a/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Allow running the server as ``python -m plugins.platforms.a2a``."""

from .entry import main

main()
Loading