feat(a2a): migrate to a2a-sdk 1.0 with 0.3 wire-compat shim (Release-As: 4.1.0)#261
Merged
feat(a2a): migrate to a2a-sdk 1.0 with 0.3 wire-compat shim (Release-As: 4.1.0)#261
Conversation
Full migration from ``a2a-sdk>=0.3.0,<1.0`` to ``a2a-sdk>=1.0.1,<2.0``. The 1.0 SDK is a protobuf-typed rewrite of the same A2A protocol at a new wire version (0.3 → 1.0): JSON shapes, enum casing (``submitted`` → ``TASK_STATE_SUBMITTED``), and AgentCard structure all changed. Wire compatibility is preserved end-to-end via ``create_jsonrpc_routes(enable_v0_3_compat=True)`` on our server and the SDK's ``Client`` factory on our outbound side. An existing 0.3 A2A agent talking to our 1.0-based server sees identical JSON on the wire (``"state": "completed"`` lowercase, ``"kind": "task"``, camelCase field names) and our AgentCard endpoint dual-serves 0.3 ``url`` / ``preferredTransport`` / ``protocolVersion`` alongside the 1.0 ``supportedInterfaces`` array. No coordinated buyer migration needed. Guarded by ``tests/integration/test_a2a_wire_compat.py`` which POSTs a hand-crafted 0.3 JSON-RPC request via raw httpx (no a2a-sdk on the client side) and asserts both the enum casing and the dual-interface AgentCard shape. Source: a2a_server.py (routes factory + dual AgentInterface); protocols/a2a.py (ClientFactory + _send_and_aggregate, state-commit ordering preserved); translate.py (A2AError subclasses direct); webhooks.py (_normalize_a2a_task_state_to_v03 for 0.3 buyer receivers); client.py (WhichOneof-based webhook handling); __init__.py (Checkpoint export); examples/a2a_db_tasks.py (MessageToJson serialization). Test infra: tests/a2a_compat_shim.py (new; 0.3-name factories atop 1.0 proto types) + conftest.py autouse patch of _send_and_aggregate. Known compat break (cutting as 4.1.0 rather than 5.0.0 because A2A adopter pool is tiny <48h after 4.0.0 shipped): client.get_agent_info() no longer returns adcp_version / protocols_supported — use client.fetch_capabilities() which dispatches get_adcp_capabilities. Verification: ruff clean, mypy clean (684 files), 2152 passed + 21 skipped on non-integration suite, 7 passed + 1 skipped on integration. Release-As: 4.1.0 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
types-protobuf was already in the uv ``[dependency-groups] dev`` section but ``[project.optional-dependencies] dev`` — which CI installs via ``pip install -e \".[dev]\"`` — didn't list it. Result: CI mypy ran without the protobuf type stubs and surfaced ``import-untyped`` and ``no-any-return`` errors on the new a2a-sdk 1.0 code paths even though local mypy was clean. Duplicating the entry in both sections is the pragmatic fix — uv sync and pip install -e ".[dev]" now both pull in the stubs.
Two small introspection / control adds on top of the 1.0 migration: 1. ``ADCPClient.a2a_protocol_versions`` — read-only property returning the sorted list of ``protocol_version`` strings the peer's AgentCard advertises (e.g. ``["0.3", "1.0"]`` against our dual-advertising server; ``["0.3"]`` against a 0.3-only peer). Returns ``None`` until the first operation fetches the card so callers distinguish "not yet known" from "peer advertises nothing" (empty list). ``None`` for non-A2A clients. Also surfaced under ``a2a_protocol_versions`` in ``get_agent_info()``. 2. ``ADCPClient(config, force_a2a_version="0.3")`` — optional kwarg that pins the wire version by filtering the peer's advertised ``supported_interfaces`` before ``ClientFactory.create()`` picks a transport. Intended for tests or for forcing a 0.3-speaking path against a dual-advertising peer. Raises ``ADCPConnectionError`` on first call if no advertised interface matches. Passing with a non-A2A protocol raises ``TypeError`` at construction (symmetric with the ``context_id=`` rejection). Default behavior is unchanged: the SDK's ``ClientFactory`` still picks the most capable transport automatically via the card's ``supported_interfaces``.
Rolls up SHOULD FIX items from code-reviewer / security-reviewer / ad-tech-protocol-expert passes on PR #261. No behavior change for the common path; closes latent bugs and tightens guards. - **Webhook normalizer now walks ``Task.history[]``** (``src/adcp/webhooks.py::_normalize_a2a_task_state_to_v03``). Prior code only rewrote ``status.state`` and ``status.message.role``; hand-built Task payloads or proxies that populated ``history`` would leak ``ROLE_AGENT`` strings to 0.3 webhook receivers. Added four tests in ``tests/test_webhooks_deliver.py`` covering state, role, history, and no-op passthrough. - **Compat shim is now pytest-gated** (``tests/a2a_compat_shim.py``). The module mutates ``a2a.types`` at import scope (``pb.Role.user``, ``pb.TaskState.completed``, ``pb.TaskStatus.__init__`` wrap). Raises ``RuntimeError`` if imported outside pytest so a notebook / reproducer that accidentally picks it up fails loudly instead of silently accepting 0.3 string enums in outbound proto construction. - **Conftest integration detection uses a real path check** (``tests/conftest.py``). ``"integration" in request.node.nodeid`` was a fragile substring match; replaced with ``node_path.relative_to(tests/integration)`` so future files named ``test_integration_*.py`` outside that dir aren't misclassified. - **Documented ``supported_interfaces`` ordering invariant** (``src/adcp/server/a2a_server.py``). a2a-sdk's v0.3 compat converter picks ``primary_interface = compat_interfaces[0]``, so 0.3 must stay at index 0 for the top-level 0.3 ``url`` / ``preferredTransport`` back-fill to work. Added a comment pinning the ordering. - **``push_notifications`` capability gated on store presence** (``src/adcp/server/a2a_server.py``). Previously advertised unconditionally, which meant clients hit ``UnsupportedOperationError`` after a successful capability probe. Now only ``True`` when ``push_config_store`` is wired. - **Added negative wire-compat tests** (``tests/integration/test_a2a_wire_compat.py``). Two new tests assert malformed ``message/send`` params return a clean JSON-RPC error envelope (not a 500), and unknown method names return ``-32601`` (Method Not Found). Guards against future a2a-sdk upgrades quietly narrowing the 0.3 adapter's validator. Verification: - ruff / mypy clean - ``pytest tests/ --ignore=tests/integration``: 2164 passed (baseline 2160 + 4 normalizer tests) - Wire-compat integration suite: 4 passed (baseline 2 + 2 negative cases) - 4-way interop matrix still PASS with card_fetches=1 per session
This was referenced Apr 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Full migration from `a2a-sdk>=0.3.0,<1.0` to `a2a-sdk>=1.0.1,<2.0`. The 1.0 Python SDK is a protobuf-typed rewrite at a new A2A protocol wire version — JSON shapes, enum casing (`submitted` → `TASK_STATE_SUBMITTED`), and AgentCard structure all change.
Wire compatibility is preserved end-to-end. The 1.0 SDK ships `enable_v0_3_compat=True` on `create_jsonrpc_routes`, which makes the server dual-serve: a 0.3 client hitting our 1.0-based server sees `"state": "completed"` lowercase, `"kind": "task"`, camelCase fields, and the AgentCard endpoint carries both `url`/`preferredTransport`/`protocolVersion` (0.3 shape) AND `supportedInterfaces` (1.0 shape) at once. Existing A2A integrators need to do nothing.
Why 4.1.0 not 5.0.0
4.0.0 shipped <48h ago. Per the roadmap, salesagent adoption is on MCP; A2A adoption is deferred behind pluggable TaskStore / push-notif / middleware work that isn't merged. The A2A adopter population is near-zero today. Semver-strict would be 5.0.0, but pragmatically the blast radius is tiny and we'd rather not burn a major release 48h after the last one. `Release-As: 4.1.0` footer overrides release-please's default MAJOR inference.
Known breaking change
`client.get_agent_info()` no longer returns `adcp_version` / `protocols_supported`. The 1.0 `AgentCard` is a protobuf message with no duck-typeable `extensions` surface the 0.3 fixture code leaned on. Callers who need these fields should use `client.fetch_capabilities()`, which dispatches the `get_adcp_capabilities` skill to get the live declaration. This is the one user-visible Python-API ripple; all other behavior is wire-preserving.
If you subclass `ADCPHandler` with `from a2a.types import ...` type annotations or catch `a2a.utils.errors.ServerError`: those imports changed under you — update to proto types and `A2AError` subclasses respectively. Our production code does this internally; the ripple is only for external subclassers.
What changed
Server (`src/adcp/server/a2a_server.py`, `translate.py`):
Client (`src/adcp/protocols/a2a.py`):
Webhooks (`src/adcp/webhooks.py`, `client.py`):
Test infrastructure:
Test plan
Follows
Also rolls in what was going to be PR #260 (closed, superseded):
🤖 Generated with Claude Code