Skip to content

feat(transport): bridge to the official a2a-sdk (#91) - #95

Merged
imran-siddique merged 1 commit into
mainfrom
feat/a2a-sdk-bridge
Aug 10, 2026
Merged

feat(transport): bridge to the official a2a-sdk (#91)#95
imran-siddique merged 1 commit into
mainfrom
feat/a2a-sdk-bridge

Conversation

@imran-siddique

Copy link
Copy Markdown
Member

Closes #91, the P0 adoption item.

The gap

cA2A calls itself a profile on A2A and integrated with no A2A implementation:

$ grep -rn "import a2a\|from a2a\|a2a-sdk" src/ pyproject.toml
(nothing)

transport.a2a_adapter hand-parsed A2A-shaped dicts; transport.server was a bespoke stdlib HTTP server. Both are honest about being a reference, but the practical effect was that a team already on the official SDK could only adopt the profile by replacing their transport with ours — which nobody does to try an alpha. A2A hit v1.0 in April 2026 under the LF with SDKs in six languages, wired into Google ADK, Azure AI Foundry, Bedrock AgentCore and Copilot Studio. The profile reached none of it.

The bridge

ca2a_runtime.transport.a2a_sdk, deliberately thin. The SDK carries metadata as a google.protobuf.Struct, so converting it to a plain mapping hands the existing adapter exactly what it already parses:

from a2a.types import Message
from ca2a_runtime.transport import a2a_sdk

request = a2a_sdk.parse_sdk_message(message)      # None if not a cA2A message
result  = node.handle({"metadata": a2a_sdk.metadata_from_sdk_message(message)})

One parser, one set of tests, and the profile stays transport-agnostic. Nothing in the module verifies, enforces, or appraises — it converts and delegates. Optional extra (pip install 'ca2a[a2a-sdk]'); the base install still depends on no A2A implementation.

Also attach_to_sdk_message for the outbound direction (which appends the extension URI to Message.extensions, idempotently) and opted_in() for the A2A-Extensions header.

⚠️ The protobuf round trip nearly broke every chain

Struct has no integer type. A credential's depth of 0 comes back as 0.0:

>>> json_format.ParseDict({"depth": 3}, msg.metadata)
>>> json_format.MessageToDict(msg.metadata)["depth"]
3.0

And credential signatures cover the RFC 8785 canonical bytes of the body, where our canonicalizer refuses floats outright:

TypeError: RFC 8785 canonicalization of floats is not supported in cA2A

So the naive bridge would have broken every chain it carried. It works because DelegationCredential.from_dict coerces depth with int() before anything is canonicalized, so the bytes that get verified are the integer form the signer signed.

I checked the other direction too, since a coercion that silently accepts anything would be worse than a crash: a non-integral float cannot be smuggled past the signature. Rewriting a signed depth of 1 to 2.5 coerces to 2, the canonical bytes differ from the signed ones, and verification fails with InvalidCredential. The test deliberately uses 2.5 rather than 1.5, because 1.5 coerces back to the signed 1 and would prove nothing.

Tested over multi-hop chains, so non-zero depths actually cross the boundary — a one-hop chain only ever exercises depth 0, the value least likely to expose a float problem. (My first attempt hand-set depth on a one-hop chain and failed on the unrelated "root credential must have depth 0" rule, which is why the helper now builds real chains.)

Tests run in CI, not skip

The SDK is in the dev extra as well as the optional one. A bridge whose tests only ever skip is a bridge nobody has exercised — which is exactly the failure mode I found in agent-manifest today, where 12 post-quantum tests had been reporting as skips because their dependency was unpublishable.

22 new tests against the real SDK 1.1.2, including the full inbound pipeline and mutual attestation over the bridge. Suite: 389 passed, 3 skipped. ruff check, ruff format --check and mypy clean.

Not in this PR

Publishing the extension URI in the Agent Card (#92). The SDK's find_extension_by_uri and AgentExtension make that straightforward now, and the decision to advertise require_caller_attestation in AgentExtension.params is recorded there.

🤖 Generated with Claude Code

cA2A calls itself a profile on A2A and integrated with no A2A
implementation. transport.a2a_adapter parsed A2A-shaped dicts and
transport.server was a bespoke stdlib HTTP server; both are honest about
being a reference, but a team already running the official SDK could only
adopt the profile by replacing their transport with ours, which nobody
does to try an alpha.

The bridge is thin on purpose. The SDK carries A2A metadata as a
google.protobuf.Struct, so converting it to a plain mapping hands the
existing adapter what it already parses: one parser, one set of tests,
and the profile stays transport-agnostic. Optional extra; the base
install still depends on no A2A implementation.

The protobuf round trip nearly broke every chain. Struct has no integer
type, so a credential depth of 0 arrives as 0.0, and credential
signatures cover RFC 8785 canonical bytes where canonicalize refuses
floats outright. Chains verify because from_dict coerces depth with int()
before anything is canonicalized, so the bytes verified are the integer
form the signer signed; a non-integral float coerces to a different
integer and the signature then fails. Both directions are tested against
the real SDK over multi-hop chains, so the non-zero depths actually cross
the boundary rather than only depth 0.

The SDK is in the dev extra, not just the optional one, so those tests
run in CI instead of skipping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

adoption: cA2A is a profile on A2A and integrates with no A2A SDK

1 participant