Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
bennybuoy
2 changes: 1 addition & 1 deletion hermes_cli/tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ def gui_toolset_label(label: str) -> str:
# `hermes tools` → X (Twitter) Search setup walks users through credential
# setup. The tool's check_fn means the schema still won't appear to the
# model if the credential later goes missing or expires.
_DEFAULT_OFF_TOOLSETS = {"homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search"}
_DEFAULT_OFF_TOOLSETS = {"homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search", "a2a"}


# Config-only capabilities: they appear in `hermes tools` for provider/API-key
Expand Down
165 changes: 165 additions & 0 deletions plugins/platforms/a2a/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# A2A Platform Plugin — Design

Consolidates the entire A2A (Agent-to-Agent) feature cluster (#514 and friends)
into one **plugin** with **zero core edits**, built on capabilities the current
codebase already exposes. Implements **A2A Protocol v1.0** (JSON-RPC binding).

## Why a plugin, not a core feature

Earlier A2A attempts (#4135, #4948, #4952, #11025) added a standalone server
package (`a2a_adapter/`) and/or patched `gateway/run.py` + `gateway/config.py`.
Since then the codebase grew `ctx.register_platform()` (the plugin
platform-adapter API — used by irc, line, teams, ntfy, simplex, …) and
`ctx.register_tool()`. That makes the standing policy achievable: **plugins
must not touch core files.** A2A now lives entirely under
`plugins/platforms/a2a/`.

## Two directions

### Outbound — client tools (`a2a` toolset)
- `a2a_discover(url)` — fetch + summarize a peer's Agent Card (v1.0
`supportedInterfaces` aware, tolerates 0.3 cards).
- `a2a_call(agent, message, context_id?)` — send a JSON-RPC `message/send`
task to a peer, return the reply. Multi-turn via `context_id` (carried
inside the Message per v1.0). Surfaces `TASK_STATE_INPUT_REQUIRED` so the
model knows to answer and continue the context.
- `a2a_list()` — configured peers + persisted conversations + metrics.
- `a2a_history(context_id, limit?)` — recall a persisted conversation
(this is the production consumer of the persistence layer).
- `a2a_orchestrate(capability, message, mode?)` — fan-out one task to every
configured peer advertising a capability. Modes: `all` (every reply),
`first` (first success), `best` (longest successful reply — a deliberately
coarse heuristic; errors never win, and an all-error fan-out reports the
failures instead of picking one).

Peers resolved from `config.yaml` → `a2a_agents`, or a direct URL.

### Inbound — platform adapter
- Stdlib `http.server` on a daemon thread (no asyncio loop needed at
`register()` time — sidesteps the a2a_fleet "register outside a loop" bug
class that killed inbound serving in forks). The request handler is a
module-level class (`A2ARequestHandler`) reached through
`server.adapter`, so RPC handlers are unit-testable without HTTP.
- Agent Card at `GET /.well-known/agent-card.json` (canonical v1.0 path; legacy `agent.json` also answers) (v1.0: `supportedInterfaces[]`,
`provider`, `capabilities.extendedAgentCard`). **Dynamic**: skills are
built from the live tool registry at serve time
(`A2A_ADVERTISED_TOOLSETS` / `extra.advertised_toolsets` restricts them).
- JSON-RPC methods: `message/send`, `message/stream` (SSE), `tasks/get`,
`tasks/list`, `tasks/cancel`, `tasks/subscribe`,
`tasks/pushNotificationConfig/create` (legacy `set` names accepted).
- **Live-session injection (the #11025 insight):** inbound tasks route through
the normal `MessageEvent` → `handle_message` path keyed by the A2A
`contextId`, so the agent that answers is the same one serving the user —
full memory/context, not a clone. The reply returns through `adapter.send()`,
which fulfils the pending per-**task** `Future` the HTTP request is blocked
on (per-context FIFO, so concurrent same-context requests can't cross-talk);
`on_processing_complete` resolves failures/cancellations promptly.
- **Task store:** every task (including terminal ones, bounded to the last
500) stays queryable via `tasks/get` / `tasks/list`, and `tasks/subscribe`
reattaches to a running task's stream via store watchers. A watchdog fails
orphaned tasks after 5 minutes (idempotent transitions — no double
counting in metrics).
- **input-required:** the platform hint tells the agent to start a reply with
`[INPUT_REQUIRED]` when it needs clarification; the adapter maps that to
`TASK_STATE_INPUT_REQUIRED` with the question in `status.message`.
- **Push notifications:** config accepted inline in `message/send`
(`configuration.taskPushNotificationConfig`) or via the create method
(returns `configId` + `createdAt`). On terminal transition the callback
receives a v1.0 `StreamResponse` (`statusUpdate`) payload, HMAC-SHA256
signed (`X-A2A-Signature`, secret `A2A_PUSH_SECRET` falling back to the
bearer token), with SSRF-guarded callback URLs.

## v1.0 wire format notes
- Task states / roles are SCREAMING_SNAKE_CASE (TASK_STATE_*, ROLE_*).
- Parts are member-presence discriminated — no kind field. All three
Part types are supported: text (text + mediaType), file
(url|raw + filename + mediaType), and data (data + mediaType).
extract_text renders file/data Parts into the text stream (URL +
filename for files, JSON for data) so the agent sees them; it also
accepts v0.3 (kind) and pre-0.3 (type) shapes from older peers.
Outbound replies are still text-only — the agent produces text, and
file/data Parts are for inbound richness.
- Push notification config: full CRUD — create (inline in message/send
via configuration.taskPushNotificationConfig, or via the create
method), get, list, delete. Each config has a configId and createdAt.
One config per task (v1.0 allows multiple; we keep one).
- SSE events are StreamResponse objects (statusUpdate / artifactUpdate
members); stream closure signals the terminal state — no final field.
- contextId lives inside the Message (legacy top-level accepted inbound).
- Timestamps are ISO 8601 with millisecond precision; Tasks carry
createdAt / lastModified.
- Error codes: A2A-reserved codes are used only with their spec semantics
(`-32001` TaskNotFound, `-32002` TaskNotCancelable); custom errors sit at
`-32050..-32052` (unauthorized / rate-limited / untrusted).

## Security (on by default)
- **Bind safety:** no token configured (`A2A_BEARER_TOKEN` or
`A2A_PEER_TOKENS`) ⇒ bind `127.0.0.1` only. A token alone does not widen
the bind; remote exposure requires token **and** explicit `A2A_HOST`.
- **Peer identity:** `A2A_PEER_TOKENS="alice:tok1,bob:tok2"` gives each peer
its own credential; the matched name is the authenticated identity used
for rate limiting, the trust gate, message framing, and audit. A shared
`A2A_BEARER_TOKEN` authenticates as `ip:<addr>`. Nothing in the request
body can assert identity. Comparisons are constant-time.
- **Trust gate:** `A2A_TRUSTED_PEERS` (or config `a2a.trusted_peers`)
optionally restricts which authenticated identities may run tasks.
- **Injection filters:** ALL inbound text (including `/`-prefixed — remote
peers can never reach operator slash commands) is defanged (ChatML /
role-prefix / override patterns → `[filtered]`) and framed with a privacy
prefix marking it untrusted peer input.
- **Outbound redaction:** credential-shaped strings (`sk-…`, `ghp_…`, JWTs,
bearer tokens, emails) scrubbed before anything leaves.
- **Rate limiting:** sliding window per authenticated identity
(`A2A_RATE_LIMIT`/min).
- **Anti-loop:** per-context turn cap (`A2A_MAX_PINGPONG_TURNS`, default 5,
hard max 20) rejects (v1.0 `TASK_STATE_REJECTED`) runaway agent↔agent
ping-pong; `tasks/cancel` resets the counter for the task's context.
- **Audit log:** append-only `~/.hermes/a2a_audit.jsonl` for every exchange.

## State placement
Task store, turn tracker, and rate limiter are **adapter-instance** objects
(classes in `protocol.py`). The metrics counter bag stays a module singleton
because it is intentionally shared between the inbound adapter and the
outbound client tools (`/metrics` and `a2a_list` report both directions).

## Persistence (survives compaction)
A2A conversations are written to `~/.hermes/a2a_conversations/<context>.jsonl`,
outside the context-compaction pipeline — compaction and restarts can't lose
them (#11025 requirement). The `a2a_history` tool recalls them by context id.

## Requirements traced to the cluster

| Source | Requirement | Where |
|---|---|---|
| #514, #23871, #4135 | Agent Card discovery | `protocol.build_agent_card`, adapter GET |
| #4135, #14559, #8948 | Client: discover / call / list | `tools.py` |
| #11025 | Live-session injection (not a clone) | `adapter._prepare_task` |
| #11025 | Privacy filters + outbound redaction + audit | `security.py` |
| #11025 | Conversation persistence outside compaction | `protocol.persist_message`, `a2a_history` |
| #514, #11025 | Auth, localhost-default | `security.authenticate`, `resolve_bind_host` |
| #56434 | Trusted peer approval | `security.is_trusted_peer` |
| #56435 | Task completion notifications | push notifications (`_send_push_notification`) |
| #25176, #689 | Agent↔agent messaging across machines | client tools + inbound adapter |
| #7517 et al. | Multi-peer orchestration | `a2a_orchestrate` |

## Deliberately out of scope (future, not this pass)
- **a2a-sdk / gRPC + HTTP+JSON bindings.** Only the JSONRPC binding is
served; the card advertises exactly that.
- **`tenant` field, extended Agent Card, `stateTransitionHistory`.**
- **True task abort:** `tasks/cancel` marks the task canceled and drops the
reply, but cannot abort the live session's in-flight turn.
- **DID / Ed25519 identity, OAuth2 scopes, x402 micropayments** (#14559
bindu) — heavy, niche; revisit if there's real demand.

## Files
```
plugins/platforms/a2a/
├── plugin.yaml # manifest (kind: platform)
├── __init__.py # register(): platform adapter + client tools
├── adapter.py # inbound A2A v1.0 server (stdlib http.server)
├── tools.py # outbound client tools
├── protocol.py # Agent Card, JSON-RPC framing, task store, persistence
├── security.py # auth/identity, injection filters, redaction, audit
├── DESIGN.md
└── README.md
```
90 changes: 90 additions & 0 deletions plugins/platforms/a2a/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# A2A — Agent-to-Agent protocol for Hermes

Talk to other agents, and let other agents talk to you, over the open
[A2A protocol](https://a2a-protocol.org) **v1.0**. Works with any A2A-compliant
peer (another Hermes, LangChain, CrewAI, Google ADK, OpenClaw, …). Stdlib only —
no `a2a-sdk` dependency.

## Enable

```bash
hermes gateway setup # pick A2A, or:
```

```yaml
# ~/.hermes/config.yaml
gateway:
platforms:
a2a:
enabled: true
extra:
port: 9900

# peers you want to call (outbound):
a2a_agents:
researcher:
url: "http://localhost:9999"
auth: { type: bearer, token: "sk-..." }
timeout: 120
capabilities: [web_search, research]
```

## Outbound — call other agents

The agent gets five tools:

- `a2a_discover(url)` — what can this agent do?
- `a2a_call(agent, message, context_id?)` — send it a task, get the reply.
- `a2a_list()` — configured peers, saved conversations, metrics.
- `a2a_history(context_id)` — recall a saved A2A conversation.
- `a2a_orchestrate(capability, message, mode?)` — fan-out a task to every
peer advertising a capability (`all` / `first` / `best`).

## Inbound — be callable

When the `a2a` platform is enabled, Hermes serves a v1.0 Agent Card at
`http://<host>:<port>/.well-known/agent-card.json` (the legacy
`/.well-known/agent.json` path is also answered for pre-1.0 clients) and
accepts JSON-RPC
`message/send`, `message/stream` (SSE), `tasks/get|list|cancel|subscribe`,
and push notification configs (inline or via
`tasks/pushNotificationConfig/create`). Incoming tasks are injected into your
**live** agent session — the same agent that's talking to you, with full
memory — and the reply is returned over A2A. Completed tasks stay queryable
via `tasks/get`.

## Security

- **No token ⇒ localhost only.** The server binds `127.0.0.1` and refuses to
widen unless you configure a token *and* set `A2A_HOST`.
- **Per-peer tokens**: `A2A_PEER_TOKENS="alice:tok1,bob:tok2"` gives each
remote agent its own credential; that authenticated name (never anything
in the request body) drives rate limiting, trust, and audit.
- Inbound text — including `/`-prefixed text — is run through
prompt-injection filters and framed as untrusted peer input; remote peers
cannot invoke operator slash commands.
- Outbound text is scrubbed of credential-shaped strings.
- Push callbacks are SSRF-guarded and HMAC-SHA256 signed (`X-A2A-Signature`).
- Every exchange is logged to `~/.hermes/a2a_audit.jsonl`.
- Conversations persist to `~/.hermes/a2a_conversations/` — they survive context
compaction and restarts (`a2a_history` recalls them).

## Env vars

| Var | Default | Meaning |
|---|---|---|
| `A2A_PEER_TOKENS` | _(unset)_ | Per-peer credentials `name:token,…` (preferred). |
| `A2A_BEARER_TOKEN` | _(unset)_ | Shared token; identity falls back to caller IP. |
| `A2A_HOST` | `127.0.0.1` | Bind host. Only widens with a token set. |
| `A2A_PORT` | `9900` | Inbound port. |
| `A2A_AGENT_NAME` | hostname-derived | Name on the Agent Card. |
| `A2A_PUBLIC_URL` | _(unset)_ | Routable URL advertised on the card (reverse proxies). |
| `A2A_TRUSTED_PEERS` | _(unset)_ | Allow-list of authenticated identities. |
| `A2A_ALLOW_ALL_USERS` | `false` | Allow any authed peer (dev only). |
| `A2A_RATE_LIMIT` | `60` | Requests/minute per identity. |
| `A2A_MAX_PINGPONG_TURNS` | `5` | Anti-loop turn cap per context (max 20). |
| `A2A_REPLY_TIMEOUT` | `300` | Seconds to wait for the agent's reply. |
| `A2A_PUSH_SECRET` | bearer token | HMAC secret for push signing. |
| `A2A_ADVERTISED_TOOLSETS` | all registered | Restrict skills on the Agent Card. |

See `DESIGN.md` for architecture and the requirement-tracing table.
Loading
Loading