feat(node): add A2A support - #347
Conversation
There was a problem hiding this comment.
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.
| chat = self.gemini.aio.chats.create( | ||
| model=MODEL, | ||
| config=types.GenerateContentConfig( | ||
| thinking_config=types.ThinkingConfig(thinking_level="minimal") | ||
| ), | ||
| ) |
There was a problem hiding this comment.
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.
| 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") | |
| ), | |
| ) |
There was a problem hiding this comment.
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.
| resp.Body = io.NopCloser(bytes.NewReader(out)) | ||
| resp.ContentLength = int64(len(out)) | ||
| resp.Header.Set("Content-Length", strconv.Itoa(len(out))) | ||
| return nil |
There was a problem hiding this comment.
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 nilThere was a problem hiding this comment.
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.
| func egressModifyResponse(resp *http.Response) error { | ||
| parts := strings.SplitN(strings.TrimPrefix(resp.Request.URL.Path, "/"), "/", 2) |
There was a problem hiding this comment.
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)There was a problem hiding this comment.
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.
|
|
||
| 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. |
There was a problem hiding this comment.
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?
| if err != nil { | ||
| return err | ||
| } | ||
| var card map[string]any |
There was a problem hiding this comment.
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
| t.Fatal(err) | ||
| } | ||
| body, _ := io.ReadAll(resp.Body) | ||
| var got map[string]any |
There was a problem hiding this comment.
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.
|
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
a3d9c4f — stock-SDK coverage at every level, and three real bugs it surfaced
All gates green locally: |
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.
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.
a2aservice type: newSERVICE_TYPE_A2Aenum value and string mapping. An a2a service is a local agent process registered withtarget_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./sam/{peer}/a2a/{svc}/...) now honorsX-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.GET .../.well-known/agent-card.jsonare 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
A provider that cannot prove the label is refused with 403 before the request body leaves the caller's node.
Testing
TestA2ACUJ: two nodes, provider enrolled with--labels region=eu; asserts the rewritten card, a label-matchedmessage/sendsucceeding across the mesh, a mismatched label refused with 403 with the backend request counter unchanged, and the labels header never reaching the backend.