Skip to content

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

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

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

@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

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)

@kuangmi-bit kuangmi-bit left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 terminal tool

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.

@gfdsa

gfdsa commented Jun 17, 2026

Copy link
Copy Markdown

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

  def _request_base_url(self):
      def first(h): return (self.headers.get(h) or "").split(",")[0].strip()
      host = first("X-Forwarded-Host") or first("Host")
      if not host:
          return ""
      scheme = first("X-Forwarded-Proto") or "http"
      return f"{scheme}://{host}/"

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

Codename-11 added a commit to Codename-11/hermes-agent that referenced this pull request Jun 18, 2026
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.
@kuangmi-bit

Copy link
Copy Markdown

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 message/send calls), and hit a small but consistent rough edge that's worth flagging since it sits in core, outside this PR's clean "zero core edits" boundary.

Symptom: every inbound task on a new contextId gets the onboarding notice

📬 No home channel is set for A2A. … Type /sethome … or ignore to skip.

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 gateway/run.py:

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 LOCAL/WEBHOOK. A2A is a request/response transport (same shape as WEBHOOK, whose own comment says it "delivers directly to configured targets") — but it's a plugin-registered platform, so core has no way to know it shouldn't be onboarded. API_SERVER looks like it's in the same boat.

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. delivers_to_home_channel = False / is_interactive = False) that the onboarding guard consults, replacing the hardcoded LOCAL/WEBHOOK check — then webhook, api_server, and a2a all skip generically without core knowing each platform by name.

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. 🙂

@davidrobertson

Copy link
Copy Markdown
Contributor

Stacked follow-up PR opened against this branch: #56437.

It fixes a live-session reply-capture bug we hit while testing A2A-to-Hermes: A2AAdapter.send() currently resolves the blocked message/send RPC on the first send for a context, which can be a progress/status/steering banner rather than the final answer. The patch gates resolution on the existing gateway metadata["notify"] == True final-send marker, while preserving the pending-lock race fix shape from #53757.

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.

bennybuoy added a commit to bennybuoy/hermes-agent that referenced this pull request Jul 5, 2026
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).
teknium1 added 3 commits July 6, 2026 02:15
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.
@teknium1
teknium1 force-pushed the hermes/hermes-8d223d48 branch from 2c31741 to 9bf2dac Compare July 6, 2026 09:24
@bennybuoy

bennybuoy commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Updated follow-up branch: v1.0 protocol alignment, tenant isolation, and forwarding fixes

I've updated the forked follow-up work rather than adding a duplicate comment.

Recommended review branch: bennybuoy/hermes-agent:a2a-pr-head-update
https://github.com/bennybuoy/hermes-agent/tree/a2a-pr-head-update

This branch is based directly on the current PR head (NousResearch:hermes/hermes-8d223d48, currently 9bf2dac6b) and then cherry-picks the A2A follow-up stack on top. I could not push directly to the PR branch because GitHub rejected write access to NousResearch/hermes-agent.git for bennybuoy, so the updated code lives on the fork branch above.

The older a2a-work branch is also updated, but a2a-pr-head-update is the cleaner branch for reviewing against this PR because it starts from the live PR head.

What this adds/fixes

A2A v1.0 JSON-RPC method compatibility

The server now accepts the canonical v1.0 PascalCase methods while preserving the older path-like aliases for compatibility:

Operation v1.0 method Legacy alias
Send message SendMessage message/send
Stream message SendStreamingMessage message/stream
Get task GetTask tasks/get
List tasks ListTasks tasks/list
Cancel task CancelTask tasks/cancel
Subscribe to task SubscribeToTask tasks/subscribe
Create push config CreateTaskPushNotificationConfig tasks/pushNotificationConfig/create / old set aliases
Get push config GetTaskPushNotificationConfig tasks/pushNotificationConfig/get
List push configs ListTaskPushNotificationConfigs tasks/pushNotificationConfig/list
Delete push config DeleteTaskPushNotificationConfig tasks/pushNotificationConfig/delete

Client calls now send canonical SendMessage plus A2A-Version: 1.0, and client parsing unwraps v1.0 { "task": ... } / { "message": ... } results while still tolerating legacy bare Tasks.

v1.0 response and stream shapes

  • Canonical SendMessage returns a v1.0 SendMessageResponse wrapper ({"task": ...} / {"message": ...}).
  • Legacy message/send still returns the old bare Task for backward compatibility.
  • SendStreamingMessage begins with a v1.0 StreamResponse {"task": Task} event, followed by statusUpdate / artifactUpdate events. No v0.x kind or final fields.

Tenant/task isolation

TaskStore now records the routed agent_slug and tenant. All task and push-config operations are scoped by routed agent/tenant:

  • task get/list/cancel/subscribe
  • push-config create/get/list/delete

Cross-tenant task IDs are hidden as TaskNotFound rather than readable/cancelable by another tenant.

Profile forwarding create/resume semantics

The profile-forwarding path no longer assumes hermes chat --continue a2a-... can create a missing session.

New behavior:

  1. First contact creates a normal source=a2a Hermes CLI session.
  2. Adapter records the concrete session id.
  3. Adapter titles it deterministically as a2a-<agent>-<sanitized-context>.
  4. Later calls resume by the concrete session id.
  5. Per-context locks prevent concurrent hermes chat processes resuming the same session.
  6. Attacker-controlled contextId is sanitized/capped before use in titles.

Hardening

  • /health no longer exposes served-agent topology to unauthenticated remote callers.
  • Non-dict JSON-RPC params return ERR_INVALID_PARAMS instead of crashing.
  • Reserved path segments (health, metrics, .well-known) are ignored in served-agent config.
  • Duplicate tenant values are ignored at config load.
  • A2A-Version is checked; unsupported explicit versions are rejected.

Regression coverage added

  • Real HTTP SendMessage v1 wrapper and canonical GetTask / ListTasks methods.
  • Client canonical method/header behavior and v1 response unwrapping.
  • Cross-tenant task and push-config isolation.
  • Malformed params JSON-RPC error path.
  • Health topology auth gating.
  • Reserved path / duplicate tenant config rejection.
  • Fake hermes executable proving forwarding first-contact create and later resume-by-session-id behavior.
  • Explicit integration coverage for SSE stream shape and push notifications.

Verification on a2a-pr-head-update

python3 -m py_compile \
  plugins/platforms/a2a/adapter.py \
  plugins/platforms/a2a/protocol.py \
  plugins/platforms/a2a/tools.py \
  tests/plugins/test_a2a_plugin.py \
  tests/plugins/test_a2a_phase23.py

python3 -m pytest tests/plugins/test_a2a_plugin.py tests/plugins/test_a2a_phase23.py -q
# 149 passed, 17 deselected

python3 -m pytest tests/plugins/test_a2a_plugin.py tests/plugins/test_a2a_phase23.py -q -m integration
# 17 passed, 149 deselected

Happy to split this into smaller PRs if that is easier to review, but the branch above is the current complete follow-up stack against this PR head.

@gfdsa

gfdsa commented Jul 7, 2026

Copy link
Copy Markdown

a2a plugin: two A2A v1.0 JSON-RPC conformance bugs break a2a-sdk clients

The a2a plugin (branch a2a-pr-head-update) is A2A v1.0, but two JSON-RPC-binding details break the official a2a-sdk 1.1.0 client — for both message/send and message/stream. Refs: A2A v1.0.1.

1. Streaming frames aren't JSON-RPC-wrapped. §9.4 requires data: {"jsonrpc":"2.0","id":…,"result":{StreamResponse}}; protocol.sse_data emits bare data: {"statusUpdate":…} (that's the REST binding). → client raises ValueError: Either result or error should be used. (The data: {} done-frame trips it too.)
Fix: wrap each frame in the JSON-RPC envelope with the request id; drop the data: {} frame (v1.0 signals terminal by stream closure).

2. Task has non-spec createdAt/lastModified. a2a.proto message Task = id, context_id, status, artifacts, history, metadata only (createdAt/lastModified appear only in the §5.6.1 timestamp-format example). ProtoJSON (ADR-001) rejects unknown fields → ParseError: "lf.a2a.v1.Task" has no field named "createdAt".
Fix: don't serialize them on Task (build_task); TaskPushNotificationConfig.createdAt is fine.

With both fixed, a2a-sdk parses task → statusUpdate → artifactUpdate → statusUpdate(COMPLETED) cleanly.

@kuangmi-bit

Copy link
Copy Markdown

@gfdsa good catch on the v1.0 JSON-RPC conformance issues. Two questions:

  1. Are these the same root cause as what a2a-sdk clients hit (missing jsonrpc field in responses), or separate bugs?
  2. Do you have a minimal reproduction I can test against?

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.

@gfdsa

gfdsa commented Jul 12, 2026

Copy link
Copy Markdown

@kuangmi-bit @bennybuoy

1. Are these the same root cause as what a2a-sdk clients hit (missing `jsonrpc` field in responses), or separate bugs?

two different ones

2. Do you have a minimal reproduction I can test against?

https://github.com/gfdsa/a2a-hermes
complete setup to compare @bennybuoy 's forks PR with modifications that fix the incompatibility with a2a sdk

[FAIL] unmodified send   ParseError: Message type "lf.a2a.v1.Task" has no field named "createdAt" at "SendMessageResponse.task".
 Available Fields(except extensions): "['id', 'contextId', 'status', 'artifacts', 'history', 'metadata']"
[FAIL] unmodified stream ValueError: Either result or error should be used
[PASS] modified   send   [{'task': {'id': 'task-d2be0e96a7f548d8', 'contextId': 'ctx-413adde50dbd4307', 'status': {'state': 'TASK_STATE_COMPLETED', 'message': {'messageId': 'e7aed5d4197b469db535861a31db874e', 'contextId': 'ctx-413adde50dbd4307', 'role': 'ROLE_AGENT', 'parts': [{'text': 'SDK_REPRO_OK', 'mediaType': 'text/plain'}]}, 'timestamp': '2026-07-12T11:40:48.262Z'}, 'artifacts': [{'artifactId': '6c19ca1c6861438397e0783f094f689a', 'parts': [{'text': 'SDK_REPRO_OK', 'mediaType': 'text/plain'}]}]}}]
[PASS] modified   stream [{'task': {'id': 'task-ac68d969345f4eb6', 'contextId': 'ctx-20b9cba69ca34cf1', 'status': {'state': 'TASK_STATE_SUBMITTED', 'timestamp': '2026-07-12T11:40:48.261Z'}}}, {'statusUpdate': {'taskId': 'task-ac68d969345f4eb6', 'contextId': 'ctx-20b9cba69ca34cf1', 'status': {'state': 'TASK_STATE_WORKING', 'timestamp': '2026-07-12T11:40:48.261Z'}}}, {'artifactUpdate': {'taskId': 'task-ac68d969345f4eb6', 'contextId': 'ctx-20b9cba69ca34cf1', 'artifact': {'artifactId': '56afdc93f5ab41daa5d21a2617a7f613', 'parts': [{'text': 'SDK_REPRO_OK', 'mediaType': 'text/plain'}]}}}, {'statusUpdate': {'taskId': 'task-ac68d969345f4eb6', 'contextId': 'ctx-20b9cba69ca34cf1', 'status': {'state': 'TASK_STATE_COMPLETED', 'timestamp': '2026-07-12T11:40:48.262Z'}}}]

bennybuoy pushed a commit to bennybuoy/hermes-agent that referenced this pull request Jul 13, 2026
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
bennybuoy pushed a commit to bennybuoy/hermes-agent that referenced this pull request Jul 13, 2026
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).
@bennybuoy

Copy link
Copy Markdown
Contributor

Thanks @gfdsa for the thorough repro — both conformance bugs are fixed on our branch now:

1. createdAt/lastModified on Task: removed from build_task(). The created_at parameter is kept in the signature for call-site compatibility but no longer serialized. TaskPushNotificationConfig.createdAt is untouched (that's a different proto message where the field is valid).

2. SSE frames not JSON-RPC wrapped: sse_data() now accepts a req_id and wraps each frame as {"jsonrpc":"2.0","id":...,"result":{StreamResponse}} per §9.4. sse_done() is now an SSE comment (: done) instead of data: {}, so clients don't try to parse an empty JSON-RPC response. The request ID is threaded through _emit_terminal, _rpc_message_stream, and _rpc_tasks_subscribe.

Also picked up the authorization_is_upstream override from your fixture — without it the gateway's per-platform allow-list rejects A2A peers before the message reaches the agent. Attributed in the commit message.

Branch with all fixes: bennybuoy/hermes-agent:a2a-pr-head-update (latest commit b0c50cb71)
https://github.com/bennybuoy/hermes-agent/tree/a2a-pr-head-update

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 a2a-tck conformance suite against the branch, that would be a great independent validation. Happy to help with anything needed to point it at the update.

teknium1 pushed a commit that referenced this pull request Aug 2, 2026
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).
teknium1 pushed a commit that referenced this pull request Aug 2, 2026
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
teknium1 pushed a commit that referenced this pull request Aug 2, 2026
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).
@teknium1

teknium1 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

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.

@teknium1 teknium1 closed this Aug 2, 2026
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
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).
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
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
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
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).
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
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).
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
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
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
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).
murraysu pushed a commit to murraysu/hermes-agent that referenced this pull request Aug 14, 2026
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).
murraysu pushed a commit to murraysu/hermes-agent that referenced this pull request Aug 14, 2026
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
murraysu pushed a commit to murraysu/hermes-agent that referenced this pull request Aug 14, 2026
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).
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

7 participants