Skip to content

Commit 4aa7a6d

Browse files
authored
feat(sdk): per-instance adcp_version pin + wire emission (Stage 2 + 3a) (#294)
* feat(sdk): per-instance adcp_version constructor option (Stage 2) Adds Stripe-style per-instance protocol-version pinning. Each ADCPClient / ADCPMultiAgentClient / ADCPServerBuilder accepts an adcp_version constructor option (release-precision string, e.g. "3.0", "3.1", "3.1-beta"). Default = the SDK's compile-time pin (ADCP_VERSION packaged with the wheel). Each surface exposes get_adcp_version(). Stage 2 is plumbing only — the value is stored per-instance and exposed via the getter; no wire emission yet. Stage 3 lifts the cross-major fence and threads the pin through schema/validator selection and outbound wire emission. The protocol RFC for the matching wire field is filed at adcontextprotocol/adcp#3493. Cross-major pins raise ConfigurationError at construction — within major 3 every accepted pin agrees with the wire's ADCP_MAJOR_VERSION constant; no silent drift. Patch-precision strings ("3.0.1") are accepted for backwards compatibility but the spec defines negotiation at release precision only. 44 new tests in tests/test_adcp_version_option.py cover defaults, valid pins, cross-major rejection, unparseable strings, and all four constructor surfaces. * feat(sdk): wire emission for adcp_version pin (Stage 3a) Lifts the per-instance adcp_version pin from plumbing-only (Stage 2) to actual wire emission. Builds against the upstream RFC at adcontextprotocol/adcp#3493 — assumes that lands. Client side: - ProtocolAdapter gains envelope_enricher hook + _enrich_outgoing_params helper. Hook runs after idempotency injection, before validation. - MCPAdapter._call_mcp_tool and A2AAdapter._call_a2a_tool both apply the enricher to the outbound params dict. - ADCPClient.__init__ installs an enricher that prepends adcp_version=self._adcp_version to every outbound request. Caller- supplied values on the params dict win over the pin (per-call override remains available once generated request types declare the field). Server side: - capabilities_response() accepts adcp_version, supported_versions, build_version. Emits supported_versions (release-precision list) and build_version (advisory full semver) on the adcp block, plus top-level adcp_version on the response envelope. Legacy major_versions still emitted for back-compat through 3.x. - ADCPServerBuilder's auto-generated get_adcp_capabilities handler threads the builder's pinned adcp_version into capabilities_response(). 10 new tests in tests/test_adcp_version_wire.py cover envelope injection, default-value behavior, caller override, capability response shape, and the auto-capabilities handler. * fix(sdk): normalize adcp_version to release-precision before wire emission Per the merged AdCP version-negotiation spec (adcontextprotocol/adcp#3493, core/version-envelope.json): "SDKs that read full-semver values from bundle metadata (e.g. ComplianceIndex.published_version = '3.1.0-beta.1') MUST normalize to release-precision ('3.1-beta.1') before emitting on the wire — meta-field values are NOT valid wire values." The packaged ADCP_VERSION file ships full-semver ('3.0.0' today). We were passing it through unchanged to the wire, which is non-compliant. Adds normalize_to_release_precision() to _version.py and applies it in resolve_adcp_version() so: - Patch-precision input ('3.0.0', '3.0.1') → stored/emitted as '3.0' - Release-precision input ('3.0', '3.1-beta') → unchanged - Pre-release tags preserved ('3.1.0-rc.1' → '3.1-rc.1') get_adcp_version() returns the normalized form regardless of what the caller passed — wire values are the canonical form. 13 new normalization tests; existing tests updated to assert normalized output where the input was patch-precision. * fix(sdk): code review polish on adcp_version pin Addresses code-reviewer findings on PR #294: - Re-export ConfigurationError from adcp.__init__ so callers can import it from the public package surface (matches every other ADCPError subclass). - Accept SemVer build metadata (3.0.1+canary, 3.1.0-beta+sha.5) on the regex; strip it on wire emission alongside patch. Build metadata is purely a build identifier and never part of a contract. - Document the caller-wins precedence on per-call params dict in ADCPClient.__init__'s adcp_version section. - Drop dead `if TYPE_CHECKING: pass` block in _version.py. Tests: 3 new build-metadata normalization cases. 70/70 passing. * docs(sdk): dx-expert polish on adcp_version pin Addresses dx-expert findings on PR #294: - ConfigurationError docstring trimmed from past-tense narrative to the actionable rule: install the SDK major that targets the wire version you want. Staging history lives in _version.py module docstring (where it belongs). - force_a2a_version docstring gains explicit cross-reference: "Not for AdCP protocol pinning — see adcp_version for that." The two string-shaped version kwargs sit side-by-side; agents skimming the signature will guess wrong without the disambiguation. - adcp_version docstring documents the migration from the legacy adcp_major_version (integer) wire field — both coexist on the wire through 3.x, servers prefer the new field, generated request types still expose the legacy field until tracked schema sync (issue #306) lands. * feat(sdk): per-agent adcp_version map on ADCPMultiAgentClient Addresses adtech-product-expert finding: a holdco trade desk almost always has one seller on a newer release than the others during rollout. Forcing all sub-clients to share a uniform pin makes the multi-client useless the moment one seller ships a new release. ADCPMultiAgentClient now accepts adcp_version: str | dict[str, str] | None: - None → every agent uses the SDK default (unchanged). - str → every agent uses this pin (unchanged). - dict[str, str] → per-agent override map. Agents missing from the map fall back to the SDK default. Each entry is independently validated; cross-major in any entry raises ConfigurationError. multi.get_adcp_version() returns the uniform pin when all agents agree (including the all-same dict case); raises ValueError with the per-agent map in the message when pins are heterogeneous, pointing callers at multi.agent(id).get_adcp_version() for per-agent reads. Also addresses python-expert findings: - responses.py: build_version emit guard tightened from `if x:` to `is not None` for consistency with the rest of the function. - protocols/base.py: envelope_enricher docstring documents the contract that top-level Request models must declare extra="allow" (so the injected adcp_version key passes the post-enrichment schema validator). 6 new tests covering per-agent map, fall-back, heterogeneous get_adcp_version() behavior, and cross-major rejection within a map. 75/75 passing. * chore(types): regen public API snapshot for ConfigurationError Snapshot test caught the new public symbol added in commit ebed151 (re-export ConfigurationError from adcp.__init__). Regenerated via scripts/regenerate_public_api_snapshot.py — addition is intentional.
1 parent 2b36d67 commit 4aa7a6d

12 files changed

Lines changed: 833 additions & 19 deletions

File tree

src/adcp/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
ADCPToolNotFoundError,
4444
ADCPWebhookError,
4545
ADCPWebhookSignatureError,
46+
ConfigurationError,
4647
IdempotencyConflictError,
4748
IdempotencyExpiredError,
4849
IdempotencyUnsupportedError,
@@ -838,6 +839,7 @@ def get_adcp_version() -> str:
838839
"AdagentsValidationError",
839840
"AdagentsNotFoundError",
840841
"AdagentsTimeoutError",
842+
"ConfigurationError",
841843
"RegistryError",
842844
# Validation utilities
843845
"SchemaValidationError",

src/adcp/_version.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Internal helpers for AdCP protocol version pinning.
2+
3+
Version pinning is per-instance (Stripe model): each ``ADCPClient`` /
4+
``ADCPMultiAgentClient`` / ``ADCPServerBuilder`` accepts an
5+
``adcp_version`` constructor option that selects which AdCP release the
6+
SDK speaks for that instance. Default is the SDK's compile-time pin
7+
(``ADCP_VERSION`` packaged with the wheel).
8+
9+
Stage 2 (this module): validates the pin at construction and exposes
10+
the resolved value via ``get_adcp_version()``. Cross-major pins raise
11+
:class:`adcp.exceptions.ConfigurationError`. No wire behavior change
12+
yet — Stage 3 lifts the cross-major fence and threads per-instance
13+
schema/validator selection through the validation hooks.
14+
15+
Release-precision strings are the canonical input form (``"3.0"``,
16+
``"3.1"``, ``"3.1-beta"``). Patch-precision strings (``"3.0.1"``) are
17+
accepted for backwards compatibility with the legacy ADCP_VERSION file
18+
shape, but the SDK normalizes to release precision internally and on
19+
the wire — patches are not part of the negotiation contract per the
20+
spec's three-tier model. See specs/version-negotiation.md upstream.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import re
26+
27+
# Release-precision versions this SDK can speak. Patch-level pinning is
28+
# intentionally absent — patches don't change the wire contract by
29+
# definition, so making them part of the pin is a category error.
30+
COMPATIBLE_ADCP_VERSIONS: tuple[str, ...] = ("3.0", "3.1")
31+
32+
# Major version this SDK is built for. Cross-major pins are rejected at
33+
# construction. To speak a different major, install the SDK major that
34+
# targets it.
35+
ADCP_MAJOR_VERSION: int = 3
36+
37+
# Matches release-precision (3.0, 3.1) and patch-precision (3.0.0,
38+
# 3.0.1) semver, with optional pre-release tag (3.1-beta, 3.1.0-rc.1)
39+
# and optional build metadata (3.0.1+canary, 3.1.0-beta+exp.sha.5114f85).
40+
# Captures the major as group 1.
41+
_VERSION_RE: re.Pattern[str] = re.compile(
42+
r"^(\d+)\.(\d+)(?:\.(\d+))?(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$"
43+
)
44+
45+
46+
def normalize_to_release_precision(version: str) -> str:
47+
"""Strip patch component (and build metadata) for wire emission.
48+
49+
Per the AdCP version-negotiation spec
50+
(``core/version-envelope.json``), wire values for ``adcp_version``
51+
are release-precision only. SDKs that read full-semver values
52+
from bundle metadata (``ADCP_VERSION`` file, ``published_version``,
53+
etc.) MUST normalize before emitting on the wire — meta-field
54+
values are not valid wire values.
55+
56+
Pre-release tags are preserved (they describe the release line);
57+
build metadata is dropped (it's purely a build identifier, never
58+
part of a contract).
59+
60+
Examples:
61+
62+
- ``"3.0"`` → ``"3.0"`` (already release-precision)
63+
- ``"3.0.0"`` → ``"3.0"``
64+
- ``"3.0.1"`` → ``"3.0"``
65+
- ``"3.1-beta"`` → ``"3.1-beta"``
66+
- ``"3.1.0-beta"`` → ``"3.1-beta"``
67+
- ``"3.1.0-rc.1"`` → ``"3.1-rc.1"``
68+
- ``"3.0.1+canary"`` → ``"3.0"``
69+
- ``"3.1.0-beta+sha.5"`` → ``"3.1-beta"``
70+
71+
Raises :class:`ValueError` on unparseable strings.
72+
"""
73+
match = _VERSION_RE.match(version)
74+
if match is None:
75+
raise ValueError(f"adcp_version {version!r} is not a valid semver-shaped string.")
76+
major, release = match.group(1), match.group(2)
77+
# Skip past patch component (group 3) if present, then take whatever's
78+
# left (pre-release tag and/or build metadata) and drop the +build half.
79+
rest_start = match.end(3) if match.group(3) is not None else match.end(2)
80+
rest = version[rest_start:]
81+
if "+" in rest:
82+
rest = rest.split("+", 1)[0]
83+
return f"{major}.{release}{rest}"
84+
85+
86+
def parse_adcp_major_version(version: str) -> int:
87+
"""Extract the major component from a release- or patch-precision version string.
88+
89+
Accepts ``"3.0"``, ``"3.1"``, ``"3.0.1"``, ``"3.1-beta"``,
90+
``"3.1.0-rc.1"``, etc. Raises :class:`ValueError` (caught by
91+
:func:`resolve_adcp_version` and reraised as
92+
:class:`ConfigurationError`) on anything else.
93+
94+
The integer return value is the only thing the cross-major fence
95+
cares about — release-vs-patch precision is preserved for downstream
96+
use elsewhere.
97+
"""
98+
match = _VERSION_RE.match(version)
99+
if match is None:
100+
raise ValueError(
101+
f"adcp_version {version!r} is not a valid semver-shaped string. "
102+
f"Expected release-precision (e.g. '3.0', '3.1') or "
103+
f"patch-precision (e.g. '3.0.1'); pre-release tags allowed "
104+
f"(e.g. '3.1-beta')."
105+
)
106+
return int(match.group(1))
107+
108+
109+
def _read_packaged_version() -> str:
110+
"""Return the ``ADCP_VERSION`` value packaged with the wheel."""
111+
from importlib.resources import files
112+
113+
return (files("adcp") / "ADCP_VERSION").read_text().strip()
114+
115+
116+
def resolve_adcp_version(pin: str | None) -> str:
117+
"""Validate and resolve a constructor-supplied ``adcp_version`` pin.
118+
119+
- ``None`` → reads the packaged ``ADCP_VERSION`` file (SDK default).
120+
- Same-major pin → accepted.
121+
- Cross-major pin → raises :class:`ConfigurationError`.
122+
- Unparseable string → raises :class:`ConfigurationError`.
123+
124+
All resolved pins are normalized to release-precision before being
125+
returned, per the spec's wire-value rule
126+
(``core/version-envelope.json``). Patch-precision inputs like
127+
``"3.0.1"`` are accepted (the ``ADCP_VERSION`` file ships in this
128+
shape today) but stored and emitted as ``"3.0"``. ``get_adcp_version()``
129+
therefore returns release-precision regardless of what the caller
130+
passed; this is intentional — wire values are the canonical form.
131+
"""
132+
# Imported here to avoid a circular import at module load time.
133+
from adcp.exceptions import ConfigurationError
134+
135+
raw = pin if pin is not None else _read_packaged_version()
136+
137+
try:
138+
major = parse_adcp_major_version(raw)
139+
except ValueError as exc:
140+
raise ConfigurationError(str(exc)) from exc
141+
142+
if major != ADCP_MAJOR_VERSION:
143+
raise ConfigurationError(
144+
f"adcp_version={raw!r} targets major {major}, but this SDK speaks "
145+
f"AdCP {ADCP_MAJOR_VERSION}.x. Install the SDK major that targets "
146+
f"AdCP {major}.x — cross-major pinning is not supported."
147+
)
148+
149+
return normalize_to_release_precision(raw)

src/adcp/client.py

Lines changed: 138 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import httpx
2222
from mcp import ClientSession
2323

24+
from adcp._version import resolve_adcp_version
2425
from adcp.capabilities import TASK_FEATURE_MAP, FeatureResolver
2526
from adcp.exceptions import ADCPError, ADCPWebhookSignatureError
2627
from adcp.protocols.a2a import A2AAdapter
@@ -336,6 +337,7 @@ def __init__(
336337
context_id: str | None = None,
337338
validation: ValidationHookConfig | None = None,
338339
force_a2a_version: str | None = None,
340+
adcp_version: str | None = None,
339341
):
340342
"""
341343
Initialize ADCP client for a single agent.
@@ -403,12 +405,13 @@ def __init__(
403405
both ``context_id`` AND ``active_task_id``.
404406
405407
Raises ``TypeError`` if passed with a non-A2A protocol.
406-
force_a2a_version: A2A-only. Pin the wire version by
407-
filtering the peer's advertised
408-
``supported_interfaces`` to entries whose
409-
``protocol_version`` matches. Intended for tests or
410-
for forcing a 0.3-speaking path against a
411-
dual-advertising peer. Raises
408+
force_a2a_version: A2A-only. Pin the **A2A transport
409+
version** (e.g. ``"0.3"``, ``"1.0"``) by filtering the
410+
peer's advertised ``supported_interfaces`` to entries
411+
whose ``protocol_version`` matches. Not for AdCP
412+
protocol pinning — see ``adcp_version`` for that.
413+
Intended for tests or for forcing a 0.3-speaking path
414+
against a dual-advertising peer. Raises
412415
:class:`ADCPConnectionError` on the first call if no
413416
advertised interface matches. ``None`` (default) lets
414417
the SDK's ``ClientFactory`` pick the most capable
@@ -417,7 +420,41 @@ def __init__(
417420
advertises before pinning.
418421
419422
Raises ``TypeError`` if passed with a non-A2A protocol.
423+
adcp_version: AdCP protocol release this client speaks
424+
(release-precision string, e.g. ``"3.0"``, ``"3.1"``,
425+
``"3.1-beta"``). Stripe-style per-instance pin: the
426+
value is sent as ``adcp_version`` on every outbound
427+
request once Stage 3 wires it through the validation
428+
hooks; today (Stage 2), it's plumbing only — stored on
429+
the instance and exposed via :meth:`get_adcp_version`,
430+
with no wire impact yet. ``None`` (default) resolves
431+
to the SDK's compile-time pin (``ADCP_VERSION``
432+
packaged with the wheel). Cross-major pins raise
433+
:class:`ConfigurationError` at construction; install
434+
the SDK major that targets your wire version instead.
435+
Patch-precision strings (``"3.0.1"``) and build
436+
metadata (``"3.0.1+canary"``) are accepted at construction
437+
but normalized to release-precision before wire emission
438+
per the spec — patches and build metadata are not part
439+
of the negotiation contract. ``get_adcp_version()``
440+
returns the normalized form.
441+
442+
Caller-supplied ``adcp_version`` on a per-call params
443+
dict wins over the constructor pin: the enricher is
444+
the default, not an override. Once Stage 3 threads
445+
schema selection through, this becomes a supported
446+
per-call override; today it's plumbing-level only.
447+
448+
Migration from ``adcp_major_version`` (legacy integer
449+
wire field): generated request types still expose
450+
``adcp_major_version: int | None`` from the pre-#3493
451+
schema. Both fields will coexist on the wire through
452+
3.x; servers prefer the new ``adcp_version`` when both
453+
are present. Stop populating ``adcp_major_version`` on
454+
request models once your seller advertises 3.1 in
455+
``supported_versions``.
420456
"""
457+
self._adcp_version: str = resolve_adcp_version(adcp_version)
421458
self.agent_config = agent_config
422459
self.webhook_url_template = webhook_url_template
423460
self.webhook_secret = webhook_secret
@@ -462,6 +499,17 @@ def __init__(
462499
# Apply schema validation modes (default: requests=warn, responses=strict
463500
# in dev/test, warn in production — see ``ValidationHookConfig`` docs).
464501
self.adapter.configure_validation(validation)
502+
# Auto-inject the per-instance ``adcp_version`` pin into every
503+
# outbound request envelope. Caller-supplied values on the
504+
# request object win — the enricher is the default, not an
505+
# override — so per-call overrides remain available once the
506+
# generated request types declare the field.
507+
_pinned_version = self._adcp_version
508+
509+
def _inject_adcp_version(params: dict[str, Any]) -> dict[str, Any]:
510+
return {"adcp_version": _pinned_version, **params}
511+
512+
self.adapter.envelope_enricher = _inject_adcp_version
465513

466514
if context_id:
467515
# Empty string is treated as "not provided" — callers using
@@ -479,6 +527,22 @@ def __init__(
479527

480528
self.simple = SimpleAPI(self)
481529

530+
def get_adcp_version(self) -> str:
531+
"""Return the AdCP protocol release this client is pinned to.
532+
533+
Resolved at construction from the ``adcp_version`` kwarg, with
534+
fallback to the SDK's compile-time pin (``ADCP_VERSION``
535+
packaged with the wheel) when the caller didn't pin
536+
explicitly. Same value across the client's lifetime — the pin
537+
is per-instance, not per-call.
538+
539+
See ``__init__``'s ``adcp_version`` parameter for the full
540+
semantics, including the cross-major fence and the Stage 2 vs
541+
Stage 3 distinction (today the pin is plumbing only; Stage 3
542+
threads it through schema/validator selection).
543+
"""
544+
return self._adcp_version
545+
482546
@property
483547
def context_id(self) -> str | None:
484548
"""Current A2A conversation context_id.
@@ -4216,6 +4280,7 @@ def __init__(
42164280
on_activity: Callable[[Activity], None] | None = None,
42174281
handlers: dict[str, Callable[..., Any]] | None = None,
42184282
signing: SigningConfig | None = None,
4283+
adcp_version: str | dict[str, str] | None = None,
42194284
):
42204285
"""
42214286
Initialize multi-agent client.
@@ -4229,19 +4294,76 @@ def __init__(
42294294
signing: Optional RFC 9421 signing config forwarded to every
42304295
per-agent ADCPClient. The same identity signs traffic to
42314296
all agents. See ADCPClient.__init__ for details.
4297+
adcp_version: AdCP protocol release pin. Three forms:
4298+
4299+
- ``None`` (default): every per-agent ADCPClient resolves
4300+
the SDK's compile-time pin.
4301+
- ``str`` (e.g. ``"3.1"``): every agent uses this pin.
4302+
- ``dict[str, str]`` (e.g.
4303+
``{"seller_a": "3.0", "seller_b": "3.1"}``): per-agent
4304+
override map keyed by ``agent.id``. Agents missing
4305+
from the map fall back to the SDK default — useful
4306+
for holdco/multi-tenant operators where one seller is
4307+
ahead of the others on the upgrade cadence.
4308+
4309+
See ADCPClient.__init__ for per-instance semantics.
4310+
Cross-major pins raise ConfigurationError at construction.
42324311
"""
4233-
self.agents = {
4234-
agent.id: ADCPClient(
4235-
agent,
4236-
webhook_url_template=webhook_url_template,
4237-
webhook_secret=webhook_secret,
4238-
on_activity=on_activity,
4239-
signing=signing,
4240-
)
4241-
for agent in agents
4242-
}
4312+
# Per-agent map → resolve each pin individually for the dict form;
4313+
# otherwise use the uniform pin for all agents.
4314+
if isinstance(adcp_version, dict):
4315+
self._adcp_version: str | None = None # mixed pins
4316+
self._per_agent_versions: dict[str, str] = {
4317+
agent_id: resolve_adcp_version(pin) for agent_id, pin in adcp_version.items()
4318+
}
4319+
default_pin = resolve_adcp_version(None)
4320+
self.agents = {
4321+
agent.id: ADCPClient(
4322+
agent,
4323+
webhook_url_template=webhook_url_template,
4324+
webhook_secret=webhook_secret,
4325+
on_activity=on_activity,
4326+
signing=signing,
4327+
adcp_version=self._per_agent_versions.get(agent.id, default_pin),
4328+
)
4329+
for agent in agents
4330+
}
4331+
else:
4332+
self._adcp_version = resolve_adcp_version(adcp_version)
4333+
self._per_agent_versions = {}
4334+
self.agents = {
4335+
agent.id: ADCPClient(
4336+
agent,
4337+
webhook_url_template=webhook_url_template,
4338+
webhook_secret=webhook_secret,
4339+
on_activity=on_activity,
4340+
signing=signing,
4341+
adcp_version=self._adcp_version,
4342+
)
4343+
for agent in agents
4344+
}
42434345
self.handlers = handlers or {}
42444346

4347+
def get_adcp_version(self) -> str:
4348+
"""Return the AdCP protocol release pin for this multi-client.
4349+
4350+
Returns the uniform pin when all agents share one. Raises
4351+
:class:`ValueError` when agents have heterogeneous pins (the
4352+
``dict[str, str]`` constructor form) — in that case, query
4353+
the per-agent pin via ``multi.agent(agent_id).get_adcp_version()``.
4354+
"""
4355+
if self._adcp_version is not None:
4356+
return self._adcp_version
4357+
# Heterogeneous: surface uniformly if all agents agree at runtime.
4358+
versions = {client.get_adcp_version() for client in self.agents.values()}
4359+
if len(versions) == 1:
4360+
return next(iter(versions))
4361+
raise ValueError(
4362+
"Multi-agent client has heterogeneous adcp_version pins; "
4363+
"use multi.agent(agent_id).get_adcp_version() to read per-agent. "
4364+
f"Pins by agent: { {a: c.get_adcp_version() for a, c in self.agents.items()} }"
4365+
)
4366+
42454367
def agent(self, agent_id: str) -> ADCPClient:
42464368
"""Get client for specific agent."""
42474369
if agent_id not in self.agents:

0 commit comments

Comments
 (0)