Skip to content

feat(a2a): consolidated Agent-to-Agent protocol plugin (closes #514)#41711

Open
teknium1 wants to merge 3 commits into
mainfrom
hermes/hermes-8d223d48
Open

feat(a2a): consolidated Agent-to-Agent protocol plugin (closes #514)#41711
teknium1 wants to merge 3 commits into
mainfrom
hermes/hermes-8d223d48

Conversation

@teknium1

@teknium1 teknium1 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Summary

A single pluginplugins/platforms/a2a/ — gives Hermes full
A2A (Agent-to-Agent) protocol support in both
directions
with zero core edits, consolidating the entire A2A
PR/issue cluster (#514 and friends) into one cohesive, policy-correct
implementation.

Closes #514. Supersedes #4135, #11025, #14559, #4948, #4952, #17439, #23871,
#12904 and folds in requirements from #8948, #25176, #689.

Opened as a draft for review of the architecture before we polish.

Why a plugin (not core)

Every prior A2A attempt added a standalone server package (a2a_adapter/)
and/or patched gateway/run.py + gateway/config.py. The codebase has since
grown 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 — so A2A now
lives entirely under plugins/platforms/a2a/.

What it does

Outbound — client tools (a2a toolset)

  • a2a_discover(url) — fetch + summarize a peer's Agent Card
  • a2a_call(agent, message, context_id?) — send a JSON-RPC message/send task, return the reply (multi-turn via context_id)
  • a2a_list() — configured peers + persisted conversations

Peers from config.yamla2a_agents, or a direct URL. Works with any
A2A-compliant peer (Hermes, LangChain, CrewAI, Google ADK, OpenClaw, …).

Inbound — platform adapter

  • Stdlib http.server on a daemon thread (no asyncio loop at register() time — sidesteps the a2a_fleet "register outside a loop" bug class)
  • Agent Card at GET /.well-known/agent.json; JSON-RPC message/send at POST /
  • Live-session injection (the feat: add A2A (Agent-to-Agent) protocol support #11025 insight): inbound tasks route through the normal MessageEventhandle_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 a per-context Future the HTTP request blocks on.

Security (on by default)

  • No A2A_BEARER_TOKEN ⇒ bind 127.0.0.1 only; a token alone does not widen the bind (remote exposure needs token and explicit A2A_HOST)
  • Constant-time bearer auth (hmac.compare_digest)
  • Inbound prompt-injection filtering (ChatML / role-prefix / override patterns) + untrusted-peer framing prefix
  • Outbound credential redaction (sk-…, ghp_…, JWTs, bearer tokens, emails)
  • Append-only audit log (~/.hermes/a2a_audit.jsonl)
  • Conversations persisted to ~/.hermes/a2a_conversations/ — survive context compaction and restarts

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._handle_inbound_task
#11025 Privacy filters + outbound redaction + audit security.py
#11025 Conversation persistence outside compaction protocol.persist_message
#514, #11025 Bearer auth, localhost-default security.resolve_bind_host
#25176, #689 Agent↔agent messaging across machines client tools + inbound adapter

Deliberately out of scope (future)

Files

plugins/platforms/a2a/
├── plugin.yaml      # manifest (kind: platform)
├── __init__.py      # register(): platform adapter + client tools
├── adapter.py       # inbound A2A server (stdlib http.server)
├── tools.py         # outbound client tools
├── protocol.py      # Agent Card, JSON-RPC framing, persistence
├── security.py      # auth, injection filters, redaction, audit
├── DESIGN.md
└── README.md
tests/plugins/test_a2a_plugin.py

Validation

  • tests/plugins/test_a2a_plugin.py: 37 passed — security (bind safety, bearer, injection, redaction, audit), protocol (Agent Card, framing, persistence), client tools (HTTP mocked), and two live HTTP round-trips: real message/send → live-session injection → reply, and a bearer-auth 401 path.
  • Zero core files modified — git diff --stat touches only plugins/platforms/a2a/ and tests/plugins/.

Infographic

a2a-protocol-plugin

Single platform-adapter plugin under plugins/platforms/a2a/ — zero core
edits — that supersedes the entire A2A PR/issue cluster. Built on the
ctx.register_platform + ctx.register_tool surface the codebase now exposes.

Outbound (a2a toolset): a2a_discover / a2a_call / a2a_list let the agent
call any A2A-compliant peer over JSON-RPC message/send. Inbound (platform
adapter): a stdlib http.server serves an Agent Card at
/.well-known/agent.json and routes incoming tasks into the agent's LIVE
gateway session (the #11025 insight) — same agent, full memory — returning
the reply over A2A.

Security on by default: no bearer token => 127.0.0.1-only bind; constant-
time bearer auth; inbound prompt-injection filtering + untrusted-peer
framing; outbound credential redaction; append-only audit log; per-context
conversation persistence outside the compaction pipeline.

Stdlib only (no a2a-sdk). 37 tests incl. a live HTTP round-trip
(card + message/send + reply) and a bearer-auth 401 path.
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: hermes/hermes-8d223d48 vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 10105 on HEAD, 10100 on base (🆕 +5)

🆕 New issues (4):

Rule Count
invalid-assignment 2
unresolved-import 1
invalid-argument-type 1
First entries
tests/plugins/test_a2a_plugin.py:17: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/plugins/test_a2a_plugin.py:405: [invalid-assignment] invalid-assignment: Object of type `object` is not assignable to attribute `_message_handler` of type `((MessageEvent, /) -> Awaitable[str | None]) | None`
plugins/platforms/a2a/tools.py:313: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `bound method str.__getitem__(key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> str` cannot be called with key of type `Literal["description"]` on object of type `str`
plugins/platforms/a2a/protocol.py:151: [invalid-assignment] invalid-assignment: Invalid subscript assignment with key of type `Literal["message"]` and value of type `dict[Unknown, Unknown]` on object of type `dict[str, str]`

✅ Fixed issues: none

Unchanged: 5230 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

teknium1 added 2 commits June 7, 2026 20:30
The a2a client tools are registered unconditionally by the plugin, but a
newly-registered plugin toolset defaults to ENABLED for every platform until
the user has seen it in 'hermes tools'. That force-injected 'a2a' into every
agent's enabled_toolsets, leaking 3 tool schemas to all users and breaking
tests that assert exact toolset membership
(test_api_server_toolset::test_create_agent_respects_config_override).

Add 'a2a' to _DEFAULT_OFF_TOOLSETS so it stays opt-in (user enables via
'hermes tools'), matching the spotify precedent. The inbound platform
adapter is already opt-in (only instantiated when the a2a platform is
enabled); this aligns the outbound client tools with the same posture.
…e alias

Live Tier-3 testing (CLI agent -> a2a tools -> live peer gateway -> model)
surfaced two bugs the kwarg-style unit tests masked:

1. registry.dispatch calls handlers as handler(args, **kwargs) — args is the
   whole dict positional. The handlers used keyword params (url=, agent=), so
   the dict bound to the first param and .strip() raised
   'dict object has no attribute strip'. Rewrote all three handlers to take
   args: dict (matching the spotify/google_meet convention). Added a
   registry-dispatch regression test that exercises the real call path the
   direct-kwarg tests never hit.

2. The model repeatedly reached for agent_name= instead of agent= (6 retries
   before success). Accept agent_name/name and message/text/task aliases so a
   reasonable guess succeeds first try.

Verified live: client agent discovers the peer's Agent Card, calls it, and
gets the reply back (PONG round-trip confirmed on both client audit log and
peer conversation log). 39 plugin tests pass.
@teknium1

teknium1 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Live validation (Tier 1–3)

Ran the plugin end-to-end against real HTTP and a real model (gemini-2.5-flash on OpenRouter), in isolated HERMES_HOMEs — did not touch any live gateway.

Tier 1 — protocol + security loop (real HTTP, stub agent):
Real client tools → real adapter → reply. Injection markers defanged, secrets redacted both directions, persistence + audit written, bearer-auth 401 enforced.

Tier 2 — real gateway + real model:
message/send → live gateway → live session → model → reply over A2A. Confirmed "17 times 4 is 68." round-trip. Also confirmed A2A goes through the same first-contact flow as every platform (pairing auth via A2A_ALLOW_ALL_USERS, home-channel via A2A_HOME_CHANNEL) because it's a real platform adapter.

Tier 3 — CLI agent choosing the tools:
Agent discovered the peer's Agent Card, called it, got PONG — verified on both client audit log and peer conversation log.

Bugs the live runs caught (fixed in 2c31741)

  1. Handler calling convention. registry.dispatch calls handler(args, **kwargs) (args = dict positional). The handlers used keyword params, so the dict bound to the first param → 'dict' object has no attribute 'strip'. Rewrote to args: dict (spotify/google_meet convention) + added a registry-dispatch regression test that exercises the real call path the kwarg-style tests missed.
  2. Param-name ergonomics. The model repeatedly tried agent_name= instead of agent= (6 retries). Now accepts agent_name/name + message/text/task aliases.

Known design note for review (not yet addressed)

A2A is synchronous request/response, but the gateway's first-contact onboarding notices (pairing code, "no home channel set") are delivered via the same send() path — so my adapter resolves the reply Future on that notice and returns it to the peer agent as the task answer instead of an actual response. A human on Telegram reads the notice and continues; a peer agent gets the notice as the reply with no way to act on it. Options: (a) pre-seed/skip onboarding for the a2a platform, (b) suppress one-time notices when the platform is request/response, (c) treat A2A peers as pre-authorized when a bearer token gates the surface. Worth deciding before promotion.

@teknium1 teknium1 marked this pull request as ready for review June 8, 2026 06:41
@kriszmac4

kriszmac4 commented Jun 8, 2026

Copy link
Copy Markdown

Agent Message Bus — a complementary internal orchestration approach

Hi team, great work on the A2A plugin! We have been working on a related but complementary solution for local multi-agent orchestration (which the DESIGN.md explicitly lists as a different problem left for future work). Sharing our approach in case it is useful.

What we built — Agent Message Bus (AMB)

AMB is a pull-based internal agent-to-agent message bus — Hermes profiles (general, dev, research, study) communicate via a shared SQLite store with MCP tool access:

# Every agent checks their inbox at turn start
messages = mcp_agent_message_bus_agent_read_messages()
if messages:
    for msg in messages:
        result = process_task(msg)
        mcp_agent_message_bus_agent_mark_done(message_id=msg.id, result=result)

Architecture highlights:

  • SQLite backbone — messages are rows with status (pending/delivered/done/failed), priority, chain depth tracking, correlation IDs
  • MCP toolsagent_read_messages, agent_send_message, agent_mark_done, agent_discover, agent_list_cards — registered via mcp_servers in config.yaml
  • Autonomous cron layer — watchdog (2min), auto-responder (5min), message router (30s), dream engine (nightly) — all Hermes cron jobs
  • Bridge engine — loop protection (max chain depth 3, rate limit 3/60s, auto_reply filter)
  • LLM bridges — each specialist agent gets a bridge for autonomous task processing
  • Permissions — AuthZ matrix per agent (who can message whom, what types)

Why it is complementary to the A2A plugin

Dimension A2A Plugin (PR #41711) Agent Message Bus
Scope Cross-machine, external agents Same-machine, Hermes profiles
Transport HTTP / JSON-RPC 2.0 SQLite + MCP + cron
Discovery /.well-known/agent.json agent_discover() MCP tool
Auth Bearer token (HMAC) Permission matrix (SQLite)
Latency Network (ms–s) Local (μs–ms)
Persistence Filesystem conversations SQLite with correlation tracing

AMB is what lets a Study profile agent ask Dev for a file path, or Research send a status update back to General — all on the same host, with zero network config.

How AMB could be packaged as a Hermes plugin

Following your plugins/platforms/a2a/ architecture, an equivalent plugins/platforms/amb/ would contain:

plugins/platforms/amb/
├── plugin.yaml          # manifest (kind: mcp_server)
├── __init__.py          # register(): MCP server + optional cron jobs
├── amb_bus.py           # SQLite CRUD, schema, correlation
├── amb_permissions.py   # AuthZ matrix
├── amb_bridge.py        # LLM bridge with loop protection
├── amb_watchdog.py      # Stale message monitoring
├── DESIGN.md
└── README.md

The MCP server registration pattern (from your register_tool API) would make the bus tools available to any profile without manual config:

# __init__.py
def register(ctx):
    # Register MCP tools
    ctx.register_tool("agent_read_messages", agent_read_messages_handler)
    ctx.register_tool("agent_send_message", agent_send_message_handler)
    ...
    
    # Optional: register cron jobs
    ctx.register_cron("amb-watchdog", schedule="*/2 * * * *", handler=watchdog_run)

Next steps

We have the full implementation running in production across multiple Hermes profiles. If the team is interested, we would be happy to:

  1. Contribute an amb plugin PR following your architecture conventions
  2. Add documentation and tests matching the existing patterns
  3. Keep it aligned with the A2A plugin so both external and internal multi-agent are covered

Happy to discuss further — either in this PR or a dedicated issue.

(Full standalone repo: https://github.com/kriszmac4/a2a-communication-protocol)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: A2A (Agent-to-Agent) Protocol Support — Remote Agent Discovery, Communication & Interoperability

3 participants