Skip to content

feat(a2a): migrate to a2a-sdk 1.0 with 0.3 wire-compat shim (Release-As: 4.1.0)#261

Merged
bokelley merged 4 commits intomainfrom
bokelley/a2a-sdk-1.0
Apr 23, 2026
Merged

feat(a2a): migrate to a2a-sdk 1.0 with 0.3 wire-compat shim (Release-As: 4.1.0)#261
bokelley merged 4 commits intomainfrom
bokelley/a2a-sdk-1.0

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

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`):

  • `A2AStarletteApplication` → `create_agent_card_routes + create_jsonrpc_routes(enable_v0_3_compat=True)` fed into `Starlette(routes=...)`
  • AgentCard carries two `AgentInterface` entries (0.3 + 1.0) so clients of either era negotiate the right transport
  • `push_notifications=True` required on `AgentCapabilities` — the 1.0 handler gates push-notif ops on this flag
  • `translate.py` returns `InternalError`/`InvalidParamsError` proto types directly (no `ServerError` wrapping)

Client (`src/adcp/protocols/a2a.py`):

  • `A2AClient` → `ClientFactory`
  • New `_send_and_aggregate` drains the 1.0 async-iterator `send_message` → single `StreamResponse` for our non-streaming call sites
  • `NONTERMINAL_TASK_STATES` typed as `frozenset[int]` of `pb.TaskState.TASK_STATE*`
  • State-commit ordering preserved — `_context_id` / `_active_task_id` commit only after `_process_task_response` and the idempotency check both succeed

Webhooks (`src/adcp/webhooks.py`, `client.py`):

  • Proto-native Task / TaskStatusUpdateEvent construction
  • `_normalize_a2a_task_state_to_v03` post-processes webhook JSON to rewrite `TASK_STATE_COMPLETED` → `completed` and `ROLE_AGENT` → `agent` so 0.3-era buyer webhook receivers keep parsing
  • `client.handle_a2a_webhook` uses proto `Part.WhichOneof("content")` instead of 0.3 isinstance dispatch

Test infrastructure:

  • `tests/a2a_compat_shim.py` (new, ~190 lines) — exposes 0.3 Pydantic-era names (`DataPart`, `TextPart`, `Part(root=...)`, `Role.user`, `TaskState.completed`) as factories over 1.0 proto types. Dramatically cheaper than rewriting every proto construction site in ~20 test files.
  • `tests/conftest.py` — autouse patches `A2AAdapter._send_and_aggregate` for unit tests so the existing mock pattern (`send_message` returning a `SendMessageSuccessResponse`) keeps working.
  • `tests/integration/test_a2a_wire_compat.py` (new) — POSTs a hand-crafted 0.3 JSON-RPC request via raw httpx, asserts response lands in 0.3 shape. Guards against future accidental removal of `enable_v0_3_compat=True`.

Test plan

  • `ruff check src/` — clean
  • `mypy src/adcp/` — clean (684 files)
  • `pytest tests/ --ignore=tests/integration`: 2152 passed, 21 skipped (baseline 2069)
  • `pytest tests/integration`: 7 passed, 1 skipped
  • Wire-compat smoke test explicitly verifies a 0.3 client still parses our responses correctly
  • POC showed the compat shim works outbound (`"state": "completed"` preserved)

Follows

Also rolls in what was going to be PR #260 (closed, superseded):

  • `Checkpoint` TypedDict exported from top-level `adcp`
  • `_process_task_response` uses `TaskState` enum comparisons

🤖 Generated with Claude Code

bokelley and others added 4 commits April 23, 2026 12:22
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
@bokelley bokelley merged commit 28a4a13 into main Apr 23, 2026
10 checks passed
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.

1 participant