feat(a2a): consolidated Agent-to-Agent protocol plugin (closes #514) - #41711
feat(a2a): consolidated Agent-to-Agent protocol plugin (closes #514)#41711teknium1 wants to merge 3 commits into
Conversation
🔎 Lint report:
|
| 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.
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 Tier 1 — protocol + security loop (real HTTP, stub agent): ✓ Tier 2 — real gateway + real model: ✓ Tier 3 — CLI agent choosing the tools: ✓ Bugs the live runs caught (fixed in 2c31741)
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 |
Agent Message Bus — a complementary internal orchestration approachHi 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:
Why it is complementary to the A2A plugin
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 pluginFollowing your The MCP server registration pattern (from your # __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 stepsWe have the full implementation running in production across multiple Hermes profiles. If the team is interested, we would be happy to:
Happy to discuss further — either in this PR or a dedicated issue. (Full standalone repo: https://github.com/kriszmac4/a2a-communication-protocol) |
kuangmi-bit
left a comment
There was a problem hiding this comment.
Multi-profile A2A test: main ↔ ops-agent ✅
Test setup:
- Hermes v0.16.0 with PR #41711 plugin files manually applied
- Two running profiles:
default(port 9900) +ops-agent(port 9901) - Both on same WSL host, localhost-only (no bearer token)
What worked perfectly:
- Agent Card discovery on both ports (
/.well-known/agent.json) - JSON-RPC
message/send→ Task lifecycle → LLM response, full round-trip - Security pairing (
hermes pairing approve a2a <code>) then auto-recognition - Ops-agent successfully performed a real task: checked all 4 gateway processes, CPU, memory, and returned actual system data via its
terminaltool
Bug found: home channel / slash command deadlock
security.wrap_inbound() unconditionally prepends PRIVACY_PREFIX to every inbound message. This prefix ([A2A inbound — message from a remote agent peer...]) hides Hermes slash commands from the gateway's command processor — /sethome is never recognized, so the home channel can never be set via A2A. Every new contextId is a "new chat" that triggers the "no home channel" prompt, and there's no way to escape it.
Fix (tested and verified on ops-agent):
def wrap_inbound(peer: str, text: str) -> str:
stripped = (text or "").strip()
# Pass gateway slash commands through unwrapped so the command
# handler sees them — the prefix breaks leading-slash detection.
if stripped.startswith("/"):
return stripped
return PRIVACY_PREFIX.format(peer=peer or "unknown") + filter_inbound(stripped)After applying this and restarting the ops-agent gateway, /sethome via A2A was properly processed and subsequent tasks flowed without interruption.
Minor note: The contextId-as-chat_id mapping means each new context is a "new user" from the gateway's perspective, so pairing is needed per-context. This is fine for localhost testing but worth documenting for multi-tenant deployments.
Overall: plugin architecture is solid, protocol compliance is spot-on, and the zero-core-edits approach is exactly right. The slash-command fix is the only blocker for frictionless multi-agent use.
|
I am running this on a k8s deploy and discovered that the Agent Card advertises the bind address rather than a routable one. With A2A_HOST=0.0.0.0 (needed so the Service can reach the pod), _build_card() ends up publishing http://0.0.0.0:9900/, so peers fetch a card they can't call back. Since the card is served per-request in do_GET, you can just derive the URL from how the caller has reached you, whenever directly or through a proxy or any kind of local https combination behind a reverse proxy and in _build_card (with a fixed override) : url = os.getenv("A2A_PUBLIC_URL", "").strip() or request_base_url or f"http://{self.host}:{self.port}/" More or less how real servers dot that since forever Happy to open a PR against the branch if useful |
Record upstream PR NousResearch#41711 as an isolated A2A carry candidate in FORK.md and add the matching DEVLOG entry with watcher and verification details.
|
Heads-up: this platform will trip the core home-channel onboarding prompt (a core-side gap, not a plugin issue) Been running this plugin as a live A2A inbound peer (repeated fresh-context Symptom: every inbound task on a new
prepended instead of a clean reply. For a human chat platform that's a one-time nudge; for an agent-to-agent caller it lands in the response body of every fresh-context call, so the peer has to parse around it. Root cause — the one-time onboarding guard in if not history and source.platform and source.platform != Platform.LOCAL and source.platform != Platform.WEBHOOK:
if not os.getenv(_home_target_env_var(platform_name)):
await self._deliver_platform_notice(source, notice)The skip-list is hardcoded to Why I'm raising it here rather than PR'ing it: the fix is core-side and this PR is deliberately plugin-only, so it doesn't belong in your diff. The clean shape is probably a platform trait the adapter can declare (e.g. Happy to send that as a separate core PR if you'd like it decoupled from this one. Either way, didn't want it to surprise anyone once the a2a platform lands. 🙂 |
|
Stacked follow-up PR opened against this branch: #56437. It fixes a live-session reply-capture bug we hit while testing A2A-to-Hermes: Also opened tracking issues for the pieces that should stay separate from the narrow bugfix:
Related contributor work is linked in the PR body: #4135, #11025, #45996, #53756, #53757. |
Consolidates 5 follow-up PRs onto the a2a-work branch: 1. Reply-capture fix (NousResearch#56437): adapter.send() now only resolves the blocked RPC Future when metadata['notify'] is True (the gateway's final-reply marker). Interim sends no longer short-circuit the response. Also accepts **kwargs in connect() for reconnect compat. 2. Slash command passthrough (NousResearch#53743): wrap_inbound() passes /-prefixed text through unwrapped so the gateway command processor sees it. Fixes /sethome deadlock during A2A onboarding. Documented security trade-off (bearer auth at network layer compensates). 3. Routable URL in Agent Card (NousResearch#53736): _build_card() now derives URL from A2A_PUBLIC_URL env > X-Forwarded-Host/Host header > bind host. Fixes k8s bug where Agent Card advertised 0.0.0.0. 4. contextId multi-turn memory (NousResearch#53756): _handle_inbound_task() now checks top-level params.contextId first (A2A spec), falls back to params.message.contextId (legacy). Outbound a2a_call also sends contextId at both top-level and inside message. 5. Type checker fixes (NousResearch#53759): TypedDict for _SCHEMAS, _FunctionSchema, _ToolSchema. Removes str() band-aid casts. All 45 tests pass including new tests for each fix. Zero core files modified — only plugins/platforms/a2a/ and tests/. Credits: @davidrobertson (NousResearch#56437), @knoal (NousResearch#53736, NousResearch#53743, NousResearch#53756, NousResearch#53759), @kuangmi-bit (slash command bug report), @gfdsa (k8s URL bug report), @shivasymbl (NousResearch#45996 userContext OBO).
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.
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.
2c31741 to
9bf2dac
Compare
Updated follow-up branch: v1.0 protocol alignment, tenant isolation, and forwarding fixesI've updated the forked follow-up work rather than adding a duplicate comment. Recommended review branch: This branch is based directly on the current PR head ( The older What this adds/fixesA2A v1.0 JSON-RPC method compatibilityThe server now accepts the canonical v1.0 PascalCase methods while preserving the older path-like aliases for compatibility:
Client calls now send canonical v1.0 response and stream shapes
Tenant/task isolation
Cross-tenant task IDs are hidden as TaskNotFound rather than readable/cancelable by another tenant. Profile forwarding create/resume semanticsThe profile-forwarding path no longer assumes New behavior:
Hardening
Regression coverage added
Verification on
|
a2a plugin: two A2A v1.0 JSON-RPC conformance bugs break a2a-sdk clientsThe a2a plugin (branch 1. Streaming frames aren't JSON-RPC-wrapped. §9.4 requires 2. With both fixed, |
|
@gfdsa good catch on the v1.0 JSON-RPC conformance issues. Two questions:
I can help with fixes — I maintain the a2a-go SDK and a2a-tck conformance suite and have been deep in A2A protocol wire-level details. |
two different ones
https://github.com/gfdsa/a2a-hermes |
Two bugs reported by gfdsa (PR NousResearch#41711 comment, Jul 12) that break the official a2a-sdk 1.1.0 Python client: 1. Task objects serialized non-spec createdAt/lastModified fields. The A2A v1.0 Task proto (lf.a2a.v1.Task) only has id, contextId, status, artifacts, history, metadata. Strict ProtoJSON parsers reject unknown fields with ParseError. Removed both fields from build_task(); created_at param kept for call-site compatibility. 2. SSE streaming frames were not JSON-RPC wrapped. A2A v1.0 §9.4 requires data: {"jsonrpc":"2.0","id":...,"result":{StreamResponse}}. sse_data() now accepts req_id and wraps in JSON-RPC envelope. sse_done() changed from 'data: {}' to SSE comment ': done' so SDK doesn't try to parse an empty JSON-RPC response. All call sites in adapter.py (_emit_terminal, _rpc_message_stream, _rpc_tasks_subscribe) updated to thread req_id through. Tests updated: 153 pass (151 unit + 17 integration, including 2 new tests for JSON-RPC envelope wrapping and fallback behavior). Refs: gfdsa/a2a-hermes reproduction repo
A2A authenticates every inbound request via bearer token in do_POST
(401 before dispatch). Without overriding authorization_is_upstream=True,
the gateway's per-platform user allow-list ({PLATFORM}_ALLOWED_USERS)
rejects A2A peers because their identity is a token-derived name or
pod IP, not a platform account in any configured allow-list. Messages
never reach the agent and callers get empty replies.
This is authorization delegated to the bearer-token transport, not a
fail-open: every request is 401'd if the credential is wrong.
Reported by kuangmi-bit (PR NousResearch#41711 comment, Jun 27).
Attribution: gfdsa's a2a-hermes repro fixture (LOCAL PATCH triad-hermoperator).
The other two patches from gfdsa's fixture were already in our branch:
- k8s Agent Card URL derivation (_request_public_url, commit ea59b85)
- send() gating on metadata['notify'] (reply-capture fix, commit ea59b85)
All 168 tests pass (151 unit + 17 integration).
|
Thanks @gfdsa for the thorough repro — both conformance bugs are fixed on our branch now: 1. 2. SSE frames not JSON-RPC wrapped: Also picked up the Branch with all fixes: All 168 tests pass (151 unit + 17 integration), including 2 new tests for the JSON-RPC envelope wrapping. @kuangmi-bit — if you're still willing to run the |
Consolidates 5 follow-up PRs onto the a2a-work branch: 1. Reply-capture fix (#56437): adapter.send() now only resolves the blocked RPC Future when metadata['notify'] is True (the gateway's final-reply marker). Interim sends no longer short-circuit the response. Also accepts **kwargs in connect() for reconnect compat. 2. Slash command passthrough (#53743): wrap_inbound() passes /-prefixed text through unwrapped so the gateway command processor sees it. Fixes /sethome deadlock during A2A onboarding. Documented security trade-off (bearer auth at network layer compensates). 3. Routable URL in Agent Card (#53736): _build_card() now derives URL from A2A_PUBLIC_URL env > X-Forwarded-Host/Host header > bind host. Fixes k8s bug where Agent Card advertised 0.0.0.0. 4. contextId multi-turn memory (#53756): _handle_inbound_task() now checks top-level params.contextId first (A2A spec), falls back to params.message.contextId (legacy). Outbound a2a_call also sends contextId at both top-level and inside message. 5. Type checker fixes (#53759): TypedDict for _SCHEMAS, _FunctionSchema, _ToolSchema. Removes str() band-aid casts. All 45 tests pass including new tests for each fix. Zero core files modified — only plugins/platforms/a2a/ and tests/. Credits: @davidrobertson (#56437), @knoal (#53736, #53743, #53756, #53759), @kuangmi-bit (slash command bug report), @gfdsa (k8s URL bug report), @shivasymbl (#45996 userContext OBO).
Two bugs reported by gfdsa (PR #41711 comment, Jul 12) that break the official a2a-sdk 1.1.0 Python client: 1. Task objects serialized non-spec createdAt/lastModified fields. The A2A v1.0 Task proto (lf.a2a.v1.Task) only has id, contextId, status, artifacts, history, metadata. Strict ProtoJSON parsers reject unknown fields with ParseError. Removed both fields from build_task(); created_at param kept for call-site compatibility. 2. SSE streaming frames were not JSON-RPC wrapped. A2A v1.0 §9.4 requires data: {"jsonrpc":"2.0","id":...,"result":{StreamResponse}}. sse_data() now accepts req_id and wraps in JSON-RPC envelope. sse_done() changed from 'data: {}' to SSE comment ': done' so SDK doesn't try to parse an empty JSON-RPC response. All call sites in adapter.py (_emit_terminal, _rpc_message_stream, _rpc_tasks_subscribe) updated to thread req_id through. Tests updated: 153 pass (151 unit + 17 integration, including 2 new tests for JSON-RPC envelope wrapping and fallback behavior). Refs: gfdsa/a2a-hermes reproduction repo
A2A authenticates every inbound request via bearer token in do_POST
(401 before dispatch). Without overriding authorization_is_upstream=True,
the gateway's per-platform user allow-list ({PLATFORM}_ALLOWED_USERS)
rejects A2A peers because their identity is a token-derived name or
pod IP, not a platform account in any configured allow-list. Messages
never reach the agent and callers get empty replies.
This is authorization delegated to the bearer-token transport, not a
fail-open: every request is 401'd if the credential is wrong.
Reported by kuangmi-bit (PR #41711 comment, Jun 27).
Attribution: gfdsa's a2a-hermes repro fixture (LOCAL PATCH triad-hermoperator).
The other two patches from gfdsa's fixture were already in our branch:
- k8s Agent Card URL derivation (_request_public_url, commit ea59b85)
- send() gating on metadata['notify'] (reply-capture fix, commit ea59b85)
All 168 tests pass (151 unit + 17 integration).
|
Landed on main via #77109 (rebase-merge, all commits preserved): the consolidated A2A plugin upgraded to protocol v1.0, incorporating the full community follow-up stack from this thread — @bennybuoy's v1.0 upgrade + tenant isolation + push CRUD (a2a-pr-head-update branch), @gfdsa's two a2a-sdk conformance fixes and the A2A_PUBLIC_URL routable-card fix, @davidrobertson's notify-gated final-reply capture (#56437), and @kuangmi-bit's authorization_is_upstream allow-list fix. Conformance verified live against the official a2a-sdk, plus new website docs. Thanks all — outstanding collaboration on this one. |
Consolidates 5 follow-up PRs onto the a2a-work branch: 1. Reply-capture fix (NousResearch#56437): adapter.send() now only resolves the blocked RPC Future when metadata['notify'] is True (the gateway's final-reply marker). Interim sends no longer short-circuit the response. Also accepts **kwargs in connect() for reconnect compat. 2. Slash command passthrough (NousResearch#53743): wrap_inbound() passes /-prefixed text through unwrapped so the gateway command processor sees it. Fixes /sethome deadlock during A2A onboarding. Documented security trade-off (bearer auth at network layer compensates). 3. Routable URL in Agent Card (NousResearch#53736): _build_card() now derives URL from A2A_PUBLIC_URL env > X-Forwarded-Host/Host header > bind host. Fixes k8s bug where Agent Card advertised 0.0.0.0. 4. contextId multi-turn memory (NousResearch#53756): _handle_inbound_task() now checks top-level params.contextId first (A2A spec), falls back to params.message.contextId (legacy). Outbound a2a_call also sends contextId at both top-level and inside message. 5. Type checker fixes (NousResearch#53759): TypedDict for _SCHEMAS, _FunctionSchema, _ToolSchema. Removes str() band-aid casts. All 45 tests pass including new tests for each fix. Zero core files modified — only plugins/platforms/a2a/ and tests/. Credits: @davidrobertson (NousResearch#56437), @knoal (NousResearch#53736, NousResearch#53743, NousResearch#53756, NousResearch#53759), @kuangmi-bit (slash command bug report), @gfdsa (k8s URL bug report), @shivasymbl (NousResearch#45996 userContext OBO).
Two bugs reported by gfdsa (PR NousResearch#41711 comment, Jul 12) that break the official a2a-sdk 1.1.0 Python client: 1. Task objects serialized non-spec createdAt/lastModified fields. The A2A v1.0 Task proto (lf.a2a.v1.Task) only has id, contextId, status, artifacts, history, metadata. Strict ProtoJSON parsers reject unknown fields with ParseError. Removed both fields from build_task(); created_at param kept for call-site compatibility. 2. SSE streaming frames were not JSON-RPC wrapped. A2A v1.0 §9.4 requires data: {"jsonrpc":"2.0","id":...,"result":{StreamResponse}}. sse_data() now accepts req_id and wraps in JSON-RPC envelope. sse_done() changed from 'data: {}' to SSE comment ': done' so SDK doesn't try to parse an empty JSON-RPC response. All call sites in adapter.py (_emit_terminal, _rpc_message_stream, _rpc_tasks_subscribe) updated to thread req_id through. Tests updated: 153 pass (151 unit + 17 integration, including 2 new tests for JSON-RPC envelope wrapping and fallback behavior). Refs: gfdsa/a2a-hermes reproduction repo
A2A authenticates every inbound request via bearer token in do_POST
(401 before dispatch). Without overriding authorization_is_upstream=True,
the gateway's per-platform user allow-list ({PLATFORM}_ALLOWED_USERS)
rejects A2A peers because their identity is a token-derived name or
pod IP, not a platform account in any configured allow-list. Messages
never reach the agent and callers get empty replies.
This is authorization delegated to the bearer-token transport, not a
fail-open: every request is 401'd if the credential is wrong.
Reported by kuangmi-bit (PR NousResearch#41711 comment, Jun 27).
Attribution: gfdsa's a2a-hermes repro fixture (LOCAL PATCH triad-hermoperator).
The other two patches from gfdsa's fixture were already in our branch:
- k8s Agent Card URL derivation (_request_public_url, commit ea59b85)
- send() gating on metadata['notify'] (reply-capture fix, commit ea59b85)
All 168 tests pass (151 unit + 17 integration).
Consolidates 5 follow-up PRs onto the a2a-work branch: 1. Reply-capture fix (NousResearch#56437): adapter.send() now only resolves the blocked RPC Future when metadata['notify'] is True (the gateway's final-reply marker). Interim sends no longer short-circuit the response. Also accepts **kwargs in connect() for reconnect compat. 2. Slash command passthrough (NousResearch#53743): wrap_inbound() passes /-prefixed text through unwrapped so the gateway command processor sees it. Fixes /sethome deadlock during A2A onboarding. Documented security trade-off (bearer auth at network layer compensates). 3. Routable URL in Agent Card (NousResearch#53736): _build_card() now derives URL from A2A_PUBLIC_URL env > X-Forwarded-Host/Host header > bind host. Fixes k8s bug where Agent Card advertised 0.0.0.0. 4. contextId multi-turn memory (NousResearch#53756): _handle_inbound_task() now checks top-level params.contextId first (A2A spec), falls back to params.message.contextId (legacy). Outbound a2a_call also sends contextId at both top-level and inside message. 5. Type checker fixes (NousResearch#53759): TypedDict for _SCHEMAS, _FunctionSchema, _ToolSchema. Removes str() band-aid casts. All 45 tests pass including new tests for each fix. Zero core files modified — only plugins/platforms/a2a/ and tests/. Credits: @davidrobertson (NousResearch#56437), @knoal (NousResearch#53736, NousResearch#53743, NousResearch#53756, NousResearch#53759), @kuangmi-bit (slash command bug report), @gfdsa (k8s URL bug report), @shivasymbl (NousResearch#45996 userContext OBO).
Two bugs reported by gfdsa (PR NousResearch#41711 comment, Jul 12) that break the official a2a-sdk 1.1.0 Python client: 1. Task objects serialized non-spec createdAt/lastModified fields. The A2A v1.0 Task proto (lf.a2a.v1.Task) only has id, contextId, status, artifacts, history, metadata. Strict ProtoJSON parsers reject unknown fields with ParseError. Removed both fields from build_task(); created_at param kept for call-site compatibility. 2. SSE streaming frames were not JSON-RPC wrapped. A2A v1.0 §9.4 requires data: {"jsonrpc":"2.0","id":...,"result":{StreamResponse}}. sse_data() now accepts req_id and wraps in JSON-RPC envelope. sse_done() changed from 'data: {}' to SSE comment ': done' so SDK doesn't try to parse an empty JSON-RPC response. All call sites in adapter.py (_emit_terminal, _rpc_message_stream, _rpc_tasks_subscribe) updated to thread req_id through. Tests updated: 153 pass (151 unit + 17 integration, including 2 new tests for JSON-RPC envelope wrapping and fallback behavior). Refs: gfdsa/a2a-hermes reproduction repo
A2A authenticates every inbound request via bearer token in do_POST
(401 before dispatch). Without overriding authorization_is_upstream=True,
the gateway's per-platform user allow-list ({PLATFORM}_ALLOWED_USERS)
rejects A2A peers because their identity is a token-derived name or
pod IP, not a platform account in any configured allow-list. Messages
never reach the agent and callers get empty replies.
This is authorization delegated to the bearer-token transport, not a
fail-open: every request is 401'd if the credential is wrong.
Reported by kuangmi-bit (PR NousResearch#41711 comment, Jun 27).
Attribution: gfdsa's a2a-hermes repro fixture (LOCAL PATCH triad-hermoperator).
The other two patches from gfdsa's fixture were already in our branch:
- k8s Agent Card URL derivation (_request_public_url, commit ea59b85)
- send() gating on metadata['notify'] (reply-capture fix, commit ea59b85)
All 168 tests pass (151 unit + 17 integration).
Consolidates 5 follow-up PRs onto the a2a-work branch: 1. Reply-capture fix (NousResearch#56437): adapter.send() now only resolves the blocked RPC Future when metadata['notify'] is True (the gateway's final-reply marker). Interim sends no longer short-circuit the response. Also accepts **kwargs in connect() for reconnect compat. 2. Slash command passthrough (NousResearch#53743): wrap_inbound() passes /-prefixed text through unwrapped so the gateway command processor sees it. Fixes /sethome deadlock during A2A onboarding. Documented security trade-off (bearer auth at network layer compensates). 3. Routable URL in Agent Card (NousResearch#53736): _build_card() now derives URL from A2A_PUBLIC_URL env > X-Forwarded-Host/Host header > bind host. Fixes k8s bug where Agent Card advertised 0.0.0.0. 4. contextId multi-turn memory (NousResearch#53756): _handle_inbound_task() now checks top-level params.contextId first (A2A spec), falls back to params.message.contextId (legacy). Outbound a2a_call also sends contextId at both top-level and inside message. 5. Type checker fixes (NousResearch#53759): TypedDict for _SCHEMAS, _FunctionSchema, _ToolSchema. Removes str() band-aid casts. All 45 tests pass including new tests for each fix. Zero core files modified — only plugins/platforms/a2a/ and tests/. Credits: @davidrobertson (NousResearch#56437), @knoal (NousResearch#53736, NousResearch#53743, NousResearch#53756, NousResearch#53759), @kuangmi-bit (slash command bug report), @gfdsa (k8s URL bug report), @shivasymbl (NousResearch#45996 userContext OBO).
Two bugs reported by gfdsa (PR NousResearch#41711 comment, Jul 12) that break the official a2a-sdk 1.1.0 Python client: 1. Task objects serialized non-spec createdAt/lastModified fields. The A2A v1.0 Task proto (lf.a2a.v1.Task) only has id, contextId, status, artifacts, history, metadata. Strict ProtoJSON parsers reject unknown fields with ParseError. Removed both fields from build_task(); created_at param kept for call-site compatibility. 2. SSE streaming frames were not JSON-RPC wrapped. A2A v1.0 §9.4 requires data: {"jsonrpc":"2.0","id":...,"result":{StreamResponse}}. sse_data() now accepts req_id and wraps in JSON-RPC envelope. sse_done() changed from 'data: {}' to SSE comment ': done' so SDK doesn't try to parse an empty JSON-RPC response. All call sites in adapter.py (_emit_terminal, _rpc_message_stream, _rpc_tasks_subscribe) updated to thread req_id through. Tests updated: 153 pass (151 unit + 17 integration, including 2 new tests for JSON-RPC envelope wrapping and fallback behavior). Refs: gfdsa/a2a-hermes reproduction repo
A2A authenticates every inbound request via bearer token in do_POST
(401 before dispatch). Without overriding authorization_is_upstream=True,
the gateway's per-platform user allow-list ({PLATFORM}_ALLOWED_USERS)
rejects A2A peers because their identity is a token-derived name or
pod IP, not a platform account in any configured allow-list. Messages
never reach the agent and callers get empty replies.
This is authorization delegated to the bearer-token transport, not a
fail-open: every request is 401'd if the credential is wrong.
Reported by kuangmi-bit (PR NousResearch#41711 comment, Jun 27).
Attribution: gfdsa's a2a-hermes repro fixture (LOCAL PATCH triad-hermoperator).
The other two patches from gfdsa's fixture were already in our branch:
- k8s Agent Card URL derivation (_request_public_url, commit ea59b85)
- send() gating on metadata['notify'] (reply-capture fix, commit ea59b85)
All 168 tests pass (151 unit + 17 integration).
Summary
A single plugin —
plugins/platforms/a2a/— gives Hermes fullA2A (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 sincegrown
ctx.register_platform()(the plugin platform-adapter API used by irc,line, teams, ntfy, simplex, …) and
ctx.register_tool(). That makes thestanding policy achievable — plugins must not touch core files — so A2A now
lives entirely under
plugins/platforms/a2a/.What it does
Outbound — client tools (
a2atoolset)a2a_discover(url)— fetch + summarize a peer's Agent Carda2a_call(agent, message, context_id?)— send a JSON-RPCmessage/sendtask, return the reply (multi-turn viacontext_id)a2a_list()— configured peers + persisted conversationsPeers from
config.yaml→a2a_agents, or a direct URL. Works with anyA2A-compliant peer (Hermes, LangChain, CrewAI, Google ADK, OpenClaw, …).
Inbound — platform adapter
http.serveron a daemon thread (no asyncio loop atregister()time — sidesteps the a2a_fleet "register outside a loop" bug class)GET /.well-known/agent.json; JSON-RPCmessage/sendatPOST /MessageEvent→handle_messagepath keyed by the A2AcontextId, so the agent that answers is the same one serving the user — full memory/context, not a clone. The reply returns throughadapter.send(), which fulfils a per-contextFuturethe HTTP request blocks on.Security (on by default)
A2A_BEARER_TOKEN⇒ bind127.0.0.1only; a token alone does not widen the bind (remote exposure needs token and explicitA2A_HOST)hmac.compare_digest)sk-…,ghp_…, JWTs, bearer tokens, emails)~/.hermes/a2a_audit.jsonl)~/.hermes/a2a_conversations/— survive context compaction and restartsRequirements traced to the cluster
protocol.build_agent_card, adapter GETtools.pyadapter._handle_inbound_tasksecurity.pyprotocol.persist_messagesecurity.resolve_bind_hostDeliberately out of scope (future)
a2a-sdk/ SSE streaming — wire format here is spec-compatible; an optional[a2a]extra can upgrade transport later without changing the contractFiles
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: realmessage/send→ live-session injection → reply, and a bearer-auth 401 path.git diff --stattouches onlyplugins/platforms/a2a/andtests/plugins/.Infographic