Skip to content

feat(node): add A2A support - #347

Merged
aojea merged 16 commits into
google:mainfrom
kaisoz:kaisoz/a2a-support
Sep 2, 2026
Merged

feat(node): add A2A support#347
aojea merged 16 commits into
google:mainfrom
kaisoz:kaisoz/a2a-support

Conversation

@kaisoz

@kaisoz kaisoz commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

What

Adds first-class support for A2A (Agent2Agent protocol) services to sam-node, so agents on the mesh can speak the standard inter-agent protocol with SAM's attested-label sovereignty gating underneath.

  • a2a service type: new SERVICE_TYPE_A2A enum value and string mapping. An a2a service is a local agent process registered with target_url (command backends are rejected); the node reverse-proxies to it, same shape as inference. Ingress authorization, DHT announce/discovery, and registration work through the existing type-generic paths.
  • Label-gated egress: the raw egress proxy (/sam/{peer}/a2a/{svc}/...) now honors X-Sam-Required-Labels: key=value[,key=value]. The caller's node verifies the provider's control-plane-attested labels and refuses fail-closed (HTTP 403) before any payload leaves the node; malformed headers get 400. The header is stripped before forwarding. The type match is case-insensitive to mirror ingress parsing.
  • Agent-card rewrite: responses to GET .../.well-known/agent-card.json are rewritten on the caller's node so a stock A2A client works against the mesh unmodified — interface URLs point back at the mesh path, transports the mesh cannot carry (gRPC) are dropped, and streaming is advertised off until verified over libp2p.

All A2A logic is confined to internal/node/a2a_service.go; other files carry only dispatch (enum/string cases, one factory case, two insertions in the egress proxy).

Why

A2A is the emerging standard for inter-agent communication, but it has no notion of data sovereignty. Routing it through SAM gives every A2A call a fail-closed, infrastructure-level guarantee about where the counterpart runs (e.g. region=eu-west-1), attested by the control plane at enrollment — something neither prompts nor the remote agent's self-description can provide.

Demo

curl http://localhost:8080/sam/<peer>/a2a/<svc>/ \
  -H "X-Sam-Authentication: Bearer <token>" \
  -H "X-Sam-Required-Labels: region=eu-west-1" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{...}}}'

A provider that cannot prove the label is refused with 403 before the request body leaves the caller's node.

Testing

  • Unit tests for the service factory, the egress hook (gate engagement, malformed labels, invalid peer, case variance, non-a2a passthrough) and the card rewrite (URL rewrite, gRPC drop, streaming off, non-200/content-encoded skip).
  • Integration test TestA2ACUJ: two nodes, provider enrolled with --labels region=eu; asserts the rewritten card, a label-matched message/send succeeding across the mesh, a mismatched label refused with 403 with the backend request counter unchanged, and the labels header never reaching the backend.

@kaisoz
kaisoz requested a review from aojea September 1, 2026 20:38

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces support for Agent2Agent (A2A) services in the SAM mesh, allowing standard A2A clients to communicate over libp2p. It adds protobuf definitions, implements an A2AService that proxies traffic and rewrites agent cards in transit, and includes a complete Gemini-backed chat agent example with integration tests. The review feedback highlights three key issues: a missing await on an asynchronous Gemini chat creation call in agent.py, a potential protocol conflict in a2a_service.go where TransferEncoding should be cleared when overriding Content-Length, and a potential nil-pointer panic in sidecar.go if resp.Request is nil.

Comment on lines +40 to +45
chat = self.gemini.aio.chats.create(
model=MODEL,
config=types.GenerateContentConfig(
thinking_config=types.ThinkingConfig(thinking_level="minimal")
),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The client.aio.chats.create method in the google-genai SDK is asynchronous and returns a coroutine. It must be awaited, otherwise chat will be a coroutine object, leading to an AttributeError when send_message is called on it later.

Suggested change
chat = self.gemini.aio.chats.create(
model=MODEL,
config=types.GenerateContentConfig(
thinking_config=types.ThinkingConfig(thinking_level="minimal")
),
)
chat = await self.gemini.aio.chats.create(
model=MODEL,
config=types.GenerateContentConfig(
thinking_config=types.ThinkingConfig(thinking_level="minimal")
),
)

@kaisoz kaisoz Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

client.aio.chats.create() is a synchronous factory in google-genai: it builds and returns an AsyncChat without doing any I/O. Besides, the example has been run end to end with multi-turn conversations, which would crash on the first send_message if create returned a coroutine.

Comment thread internal/node/a2a_service.go Outdated
Comment on lines +149 to +152
resp.Body = io.NopCloser(bytes.NewReader(out))
resp.ContentLength = int64(len(out))
resp.Header.Set("Content-Length", strconv.Itoa(len(out)))
return nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

When modifying the response body and setting an explicit Content-Length in a reverse proxy's ModifyResponse hook, it is important to clear TransferEncoding (e.g., if the original response was chunked). Leaving TransferEncoding as chunked while providing a Content-Length can cause protocol conflicts or client-side parsing errors.

	resp.Body = io.NopCloser(bytes.NewReader(out))
	resp.ContentLength = int64(len(out))
	resp.Header.Set("Content-Length", strconv.Itoa(len(out)))
	resp.TransferEncoding = nil
	return nil

@kaisoz kaisoz Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

httputil.ReverseProxy removes TransferEncoding, before ModifyResponse runs, and therefore the outgoing response's framing is determined by the Content-Length header set there. Clearing it would be a no-op.

Comment thread internal/node/sidecar.go Outdated
Comment on lines +702 to +703
func egressModifyResponse(resp *http.Response) error {
parts := strings.SplitN(strings.TrimPrefix(resp.Request.URL.Path, "/"), "/", 2)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Add a defensive check to ensure resp.Request and resp.Request.URL are not nil before accessing resp.Request.URL.Path. While typically populated by the reverse proxy, custom transports or unit tests might pass a response with a nil request, which would cause a runtime panic.

func egressModifyResponse(resp *http.Response) error {
	if resp.Request == nil || resp.Request.URL == nil {
		return nil
	}
	parts := strings.SplitN(strings.TrimPrefix(resp.Request.URL.Path, "/"), "/", 2)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

egressModifyResponse is only ever installed as ReverseProxy.ModifyResponse, and the proxy guarantees resp.Request is the outbound request it just sent, so the nil case can't occur in today's code path.

@kaisoz kaisoz changed the title feat(node): add A2A service type with label-gated egress and agent-card rewrite feat(node): add A2A support Sep 1, 2026

When configuring `type: a2a` services (Agent2Agent protocol agents):
* **URL backends only**: register the agent's local HTTP endpoint as `target_url`. `command` backends are rejected.
* **Raw Proxy Access**: remote peers reach the agent at `http://localhost:8080/sam/{peer}/a2a/{service}/...`. The agent card served at `.../.well-known/agent-card.json` is rewritten in transit so its interface URLs point back at this mesh path; transports the mesh cannot carry (gRPC) are dropped and streaming is advertised off.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

rewriting things in transit is scary ... who is creating that card? is not possible to generate the card at the service registration time with the right url?

Comment thread internal/node/a2a_service.go Outdated
if err != nil {
return err
}
var card map[string]any

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we should use the official typed version https://github.com/a2aproject/a2a-go/blob/03b1f8483cc9cbdd3e95567100d95c5509687eb5/a2a/agent.go#L35 so we detect problems at build time and avoid the map[string]any

Comment thread internal/node/a2a_service.go
Comment thread internal/node/a2a_service_test.go Outdated
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
var got map[string]any

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

avoid dealing with these unstructored types that makes it risky to suffer runtime problems ...

…e rewrite

The caller's node now impersonates the remote agent's card endpoint:
it holds the client request, fetches the card from the agent over the
mesh, and serves a card regenerated from typed a2aproject/a2a-go/v2
structs whose interfaces point at the local mesh path. This replaces
the ReverseProxy ModifyResponse in-flight rewrite, the context-key
plumbing that carried the mesh base URL, and the content-encoding
bail-out (the node negotiates identity encoding on its own fetch).

Typed regeneration also drops the card's original JWS signatures,
which no longer verify once the content changes, and fails closed
with an explicit 502 on cards that advertise no binding the mesh can
carry (gRPC-only or pre-1.0 cards).
…ound

The integration CUJ now runs the official SDK on both ends (a2asrv echo
agent, a2aclient + card resolver through the mesh) and a new e2e bats
CUJ drives a containerized mesh with the stock python a2a-sdk on both
ends, bootstrapping from the regenerated card and exercising the labels
gate over the real control-plane attestation chain.

Testing with real SDK parsers instead of hand-rolled JSON surfaced
three fixes:

- regenerated cards kept nil required list fields, which encoding/json
  marshals as null and strict card parsers (pydantic) reject; they are
  now normalized to empty arrays.
- the official Go resolver treats a pathful base URL as the card URL
  itself (the python resolver appends the well-known path), so the
  regenerated card is now served at the bare service root as well; a
  root GET is not part of any A2A binding, JSON-RPC being POST-only.
- the Helm bootstrap job had no way to grant allowed_labels, so no
  chart-deployed mesh could enroll a labelled node at all; a new
  bootstrap.nodeLabels value carries the grant patterns, empty (fail
  closed) by default.
@aojea

aojea commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Pushed two follow-up commits on top of @kaisoz's work (thanks for the great base — all original commits preserved):

fb5c349 — typed card regeneration via the official SDK, replacing the in-flight rewrite

  • Adopted github.com/a2aproject/a2a-go/v2 (types + client/server used in tests; the a2a types package is stdlib-only, and the client/server packages needed zero new modules — uuid/x/mod/x/sync were already in the graph).
  • The caller's node now impersonates the card endpoint: it holds the client request, fetches the card from the agent over the mesh, and serves a card regenerated from typed structs pointing at the local mesh path. This removes the ReverseProxy.ModifyResponse rewrite, the context-key plumbing for the base URL, and the content-encoding bail-out (the node negotiates identity encoding on its own fetch).
  • Regeneration drops the original JWS signatures: they sign the card bytes including interface URLs, and the mesh URL is caller-relative (every consumer node mints a different base), so no origin signature can ever verify post-regeneration. Verified stock clients don't verify by default (the Go SDK client has no verification code; python is opt-in). Node re-signing with its own key is a possible follow-up for "must-be-signed" client policies.

a3d9c4f — stock-SDK coverage at every level, and three real bugs it surfaced

  • Integration CUJ now runs the official Go SDK on both ends (a2asrv echo agent, a2aclient + card resolver through the mesh, ~2s), and a new e2e bats CUJ (tests/e2e/a2a_mesh.bats) drives the containerized mesh with the stock python a2a-sdk on both ends, registering via node config and exercising the labels gate over the real attestation chain.
  • Testing with real SDK parsers instead of hand-rolled JSON found:
    1. regenerated cards kept nil required list fields → "skills":null, which pydantic-based clients reject for the whole card; now normalized to empty arrays.
    2. the official Go resolver treats a pathful base URL as the card URL itself (python appends /.well-known/agent-card.json), so the regenerated card is now also served at the bare service root (safe: JSON-RPC is POST-only).
    3. the Helm bootstrap job had no way to grant allowed_labels, so no chart-deployed mesh could enroll a labelled node at all; added bootstrap.nodeLabels (default [], fail closed), pinned by chart tests.

All gates green locally: make lint, chart tests 31/31, full internal/node, integration, and the new e2e CUJ.

@aojea
aojea merged commit 510e6ca into google:main Sep 2, 2026
21 checks passed
aojea added a commit to HosniBelfeki/sam that referenced this pull request Sep 2, 2026
PR google#347 added a third parseRequiredLabels call site after the fail-closed
fix was written. The shared parser covers it, but the a2a egress gate now
pins ",," as a 400 alongside the malformed-entry case.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants