From 5e466960d5645973e4f943d4977747b1fee62ad2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Apr 2026 06:52:16 -0400 Subject: [PATCH 1/7] feat(sdk): per-instance adcp_version constructor option (Stage 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/adcp/_version.py | 111 ++++++++++++++++++ src/adcp/client.py | 51 +++++++++ src/adcp/exceptions.py | 18 +++ src/adcp/server/builder.py | 22 +++- tests/test_adcp_version_option.py | 184 ++++++++++++++++++++++++++++++ 5 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 src/adcp/_version.py create mode 100644 tests/test_adcp_version_option.py diff --git a/src/adcp/_version.py b/src/adcp/_version.py new file mode 100644 index 00000000..c7a1d198 --- /dev/null +++ b/src/adcp/_version.py @@ -0,0 +1,111 @@ +"""Internal helpers for AdCP protocol version pinning. + +Version pinning is per-instance (Stripe model): each ``ADCPClient`` / +``ADCPMultiAgentClient`` / ``ADCPServerBuilder`` accepts an +``adcp_version`` constructor option that selects which AdCP release the +SDK speaks for that instance. Default is the SDK's compile-time pin +(``ADCP_VERSION`` packaged with the wheel). + +Stage 2 (this module): validates the pin at construction and exposes +the resolved value via ``get_adcp_version()``. Cross-major pins raise +:class:`adcp.exceptions.ConfigurationError`. No wire behavior change +yet — Stage 3 lifts the cross-major fence and threads per-instance +schema/validator selection through the validation hooks. + +Release-precision strings are the canonical input form (``"3.0"``, +``"3.1"``, ``"3.1-beta"``). Patch-precision strings (``"3.0.1"``) are +accepted for backwards compatibility with the legacy ADCP_VERSION file +shape, but the SDK normalizes to release precision internally and on +the wire — patches are not part of the negotiation contract per the +spec's three-tier model. See specs/version-negotiation.md upstream. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + pass + +# Release-precision versions this SDK can speak. Patch-level pinning is +# intentionally absent — patches don't change the wire contract by +# definition, so making them part of the pin is a category error. +COMPATIBLE_ADCP_VERSIONS: tuple[str, ...] = ("3.0", "3.1") + +# Major version this SDK is built for. Cross-major pins are rejected at +# construction. To speak a different major, install the SDK major that +# targets it. +ADCP_MAJOR_VERSION: int = 3 + +# Matches release-precision (3.0, 3.1) and patch-precision (3.0.0, +# 3.0.1) semver, with optional pre-release tag (3.1-beta, 3.1.0-rc.1). +# Captures the major as group 1. +_VERSION_RE: re.Pattern[str] = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?(?:-[a-zA-Z0-9.-]+)?$") + + +def parse_adcp_major_version(version: str) -> int: + """Extract the major component from a release- or patch-precision version string. + + Accepts ``"3.0"``, ``"3.1"``, ``"3.0.1"``, ``"3.1-beta"``, + ``"3.1.0-rc.1"``, etc. Raises :class:`ValueError` (caught by + :func:`resolve_adcp_version` and reraised as + :class:`ConfigurationError`) on anything else. + + The integer return value is the only thing the cross-major fence + cares about — release-vs-patch precision is preserved for downstream + use elsewhere. + """ + match = _VERSION_RE.match(version) + if match is None: + raise ValueError( + f"adcp_version {version!r} is not a valid semver-shaped string. " + f"Expected release-precision (e.g. '3.0', '3.1') or " + f"patch-precision (e.g. '3.0.1'); pre-release tags allowed " + f"(e.g. '3.1-beta')." + ) + return int(match.group(1)) + + +def _read_packaged_version() -> str: + """Return the ``ADCP_VERSION`` value packaged with the wheel.""" + from importlib.resources import files + + return (files("adcp") / "ADCP_VERSION").read_text().strip() + + +def resolve_adcp_version(pin: str | None) -> str: + """Validate and resolve a constructor-supplied ``adcp_version`` pin. + + - ``None`` → returns the packaged ``ADCP_VERSION`` (SDK default). + - Same-major pin → returned as-is. Release- and patch-precision + both accepted; the SDK does not normalize the string at this + layer (callers see what they passed). + - Cross-major pin → raises :class:`ConfigurationError`. + - Unparseable string → raises :class:`ConfigurationError`. + + The cross-major fence is the only construction-time fail. Within + the same major, release-level pins are accepted optimistically — + Stage 3 (per-instance schema/validator selection) is what + actually validates that the pinned release exists in the SDK's + schema cache. Until Stage 3 lands, the pin is plumbing only. + """ + # Imported here to avoid a circular import at module load time. + from adcp.exceptions import ConfigurationError + + if pin is None: + return _read_packaged_version() + + try: + major = parse_adcp_major_version(pin) + except ValueError as exc: + raise ConfigurationError(str(exc)) from exc + + if major != ADCP_MAJOR_VERSION: + raise ConfigurationError( + f"adcp_version={pin!r} targets major {major}, but this SDK speaks " + f"AdCP {ADCP_MAJOR_VERSION}.x. Install the SDK major that targets " + f"AdCP {major}.x — cross-major pinning is not supported." + ) + + return pin diff --git a/src/adcp/client.py b/src/adcp/client.py index 5428fc37..2d2b18e0 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -21,6 +21,7 @@ import httpx from mcp import ClientSession +from adcp._version import resolve_adcp_version from adcp.capabilities import TASK_FEATURE_MAP, FeatureResolver from adcp.exceptions import ADCPError, ADCPWebhookSignatureError from adcp.protocols.a2a import A2AAdapter @@ -336,6 +337,7 @@ def __init__( context_id: str | None = None, validation: ValidationHookConfig | None = None, force_a2a_version: str | None = None, + adcp_version: str | None = None, ): """ Initialize ADCP client for a single agent. @@ -417,7 +419,23 @@ def __init__( advertises before pinning. Raises ``TypeError`` if passed with a non-A2A protocol. + adcp_version: AdCP protocol release this client speaks + (release-precision string, e.g. ``"3.0"``, ``"3.1"``, + ``"3.1-beta"``). Stripe-style per-instance pin: the + value is sent as ``adcp_version`` on every outbound + request once Stage 3 wires it through the validation + hooks; today (Stage 2), it's plumbing only — stored on + the instance and exposed via :meth:`get_adcp_version`, + with no wire impact yet. ``None`` (default) resolves + to the SDK's compile-time pin (``ADCP_VERSION`` + packaged with the wheel). Cross-major pins raise + :class:`ConfigurationError` at construction; install + the SDK major that targets your wire version instead. + Patch-precision strings (``"3.0.1"``) are accepted but + patches are not part of the negotiation contract per + spec — use release-precision in production. """ + self._adcp_version: str = resolve_adcp_version(adcp_version) self.agent_config = agent_config self.webhook_url_template = webhook_url_template self.webhook_secret = webhook_secret @@ -479,6 +497,22 @@ def __init__( self.simple = SimpleAPI(self) + def get_adcp_version(self) -> str: + """Return the AdCP protocol release this client is pinned to. + + Resolved at construction from the ``adcp_version`` kwarg, with + fallback to the SDK's compile-time pin (``ADCP_VERSION`` + packaged with the wheel) when the caller didn't pin + explicitly. Same value across the client's lifetime — the pin + is per-instance, not per-call. + + See ``__init__``'s ``adcp_version`` parameter for the full + semantics, including the cross-major fence and the Stage 2 vs + Stage 3 distinction (today the pin is plumbing only; Stage 3 + threads it through schema/validator selection). + """ + return self._adcp_version + @property def context_id(self) -> str | None: """Current A2A conversation context_id. @@ -4216,6 +4250,7 @@ def __init__( on_activity: Callable[[Activity], None] | None = None, handlers: dict[str, Callable[..., Any]] | None = None, signing: SigningConfig | None = None, + adcp_version: str | None = None, ): """ Initialize multi-agent client. @@ -4229,7 +4264,13 @@ def __init__( signing: Optional RFC 9421 signing config forwarded to every per-agent ADCPClient. The same identity signs traffic to all agents. See ADCPClient.__init__ for details. + adcp_version: AdCP protocol release pin forwarded to every + per-agent ADCPClient. All agents under this multi-client + speak the same release. See ADCPClient.__init__ for + semantics. Cross-major pins raise ConfigurationError at + construction. """ + self._adcp_version: str = resolve_adcp_version(adcp_version) self.agents = { agent.id: ADCPClient( agent, @@ -4237,11 +4278,21 @@ def __init__( webhook_secret=webhook_secret, on_activity=on_activity, signing=signing, + adcp_version=self._adcp_version, ) for agent in agents } self.handlers = handlers or {} + def get_adcp_version(self) -> str: + """Return the AdCP protocol release this multi-client is pinned to. + + Resolved at construction. All per-agent clients share the same + pin — see ADCPClient.get_adcp_version for the per-instance + semantics. + """ + return self._adcp_version + def agent(self, agent_id: str) -> ADCPClient: """Get client for specific agent.""" if agent_id not in self.agents: diff --git a/src/adcp/exceptions.py b/src/adcp/exceptions.py index 21672173..f157c1bf 100644 --- a/src/adcp/exceptions.py +++ b/src/adcp/exceptions.py @@ -412,6 +412,24 @@ def __init__( super().__init__(message, agent_id, agent_uri, suggestion) +class ConfigurationError(ADCPError): + """Invalid SDK configuration detected at construction time. + + Raised when a value passed to a client/server constructor cannot be + reconciled with the SDK's compile-time pin. Currently used for + cross-major ``adcp_version`` pins — e.g. constructing an + ``ADCPClient`` with ``adcp_version="4.0"`` against an SDK built for + AdCP 3.x. Pre-release tags and unparseable strings also raise this. + + Construction-time validation is the right layer: a cross-major pin + has no recoverable runtime path in this SDK major. Stage 3 (per- + instance schema/validator selection across releases within the same + major) is what lifts the cross-major fence — until then, callers + pinning across majors should install the SDK major that speaks + their target wire version. + """ + + IDEMPOTENCY_ERROR_CODE_MAP: dict[str, type[ADCPTaskError]] = { "IDEMPOTENCY_CONFLICT": IdempotencyConflictError, "IDEMPOTENCY_EXPIRED": IdempotencyExpiredError, diff --git a/src/adcp/server/builder.py b/src/adcp/server/builder.py index f8704eb5..ab4202c3 100644 --- a/src/adcp/server/builder.py +++ b/src/adcp/server/builder.py @@ -115,11 +115,31 @@ async def get_products(params, context=None): serve(server, name="my-seller") """ - def __init__(self, name: str, *, version: str = "1.0.0") -> None: + def __init__( + self, + name: str, + *, + version: str = "1.0.0", + adcp_version: str | None = None, + ) -> None: + from adcp._version import resolve_adcp_version + self.name = name self.version = version + self._adcp_version: str = resolve_adcp_version(adcp_version) self._handlers: dict[str, Callable[..., Any]] = {} + def get_adcp_version(self) -> str: + """Return the AdCP protocol release this server is pinned to. + + Resolved at construction from the ``adcp_version`` kwarg, with + fallback to the SDK's compile-time pin (``ADCP_VERSION`` + packaged with the wheel). Stage 2 plumbing — Stage 3 will use + this to select which schema set the server validates handler + responses against and which capability shape it advertises. + """ + return self._adcp_version + def __getattr__(self, task_name: str) -> Callable[..., Any]: """Return a decorator that registers a handler for the given task.""" if task_name.startswith("_"): diff --git a/tests/test_adcp_version_option.py b/tests/test_adcp_version_option.py new file mode 100644 index 00000000..8ab89ccb --- /dev/null +++ b/tests/test_adcp_version_option.py @@ -0,0 +1,184 @@ +"""Tests for the per-instance ``adcp_version`` constructor option (Stage 2). + +Validates the plumbing only — Stage 3 (per-instance schema/validator +selection, wire emission) is deferred. These tests assert that: + +- Default resolution reads the packaged ``ADCP_VERSION`` file. +- Same-major release- and patch-precision pins are accepted as-is. +- Cross-major pins raise ``ConfigurationError`` at construction. +- Unparseable strings raise ``ConfigurationError``. +- All four constructor surfaces (``ADCPClient``, + ``ADCPMultiAgentClient``, ``ADCPServerBuilder``, ``adcp_server``) + honor the option and expose ``get_adcp_version()``. +""" + +from __future__ import annotations + +import pytest + +from adcp import ADCPClient, ADCPMultiAgentClient, get_adcp_spec_version +from adcp._version import ( + ADCP_MAJOR_VERSION, + COMPATIBLE_ADCP_VERSIONS, + parse_adcp_major_version, + resolve_adcp_version, +) +from adcp.exceptions import ConfigurationError +from adcp.server.builder import ADCPServerBuilder, adcp_server +from adcp.types import AgentConfig, Protocol + +# --------------------------------------------------------------------------- +# parse_adcp_major_version +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "version,expected_major", + [ + ("3.0", 3), + ("3.1", 3), + ("3.0.0", 3), + ("3.0.1", 3), + ("3.1-beta", 3), + ("3.1.0-rc.1", 3), + ("4.0", 4), + ("10.20", 10), + ], +) +def test_parse_adcp_major_version_extracts_major(version: str, expected_major: int) -> None: + assert parse_adcp_major_version(version) == expected_major + + +@pytest.mark.parametrize( + "bad_version", + [ + "banana", + "", + "3", # bare major, no release component + "3.x", + "v3.0", + "3.0.0.0", + ], +) +def test_parse_adcp_major_version_rejects_garbage(bad_version: str) -> None: + with pytest.raises(ValueError): + parse_adcp_major_version(bad_version) + + +# --------------------------------------------------------------------------- +# resolve_adcp_version +# --------------------------------------------------------------------------- + + +def test_resolve_default_returns_packaged_version() -> None: + assert resolve_adcp_version(None) == get_adcp_spec_version() + + +@pytest.mark.parametrize("version", ["3.0", "3.1", "3.0.0", "3.0.1", "3.1-beta"]) +def test_resolve_same_major_accepted(version: str) -> None: + assert resolve_adcp_version(version) == version + + +@pytest.mark.parametrize("version", ["4.0", "2.0", "5.1", "1.0.0"]) +def test_resolve_cross_major_rejected(version: str) -> None: + with pytest.raises(ConfigurationError) as exc: + resolve_adcp_version(version) + assert "cross-major" in str(exc.value).lower() or "major" in str(exc.value).lower() + + +@pytest.mark.parametrize("bad", ["banana", "", "3", "v3.0"]) +def test_resolve_unparseable_rejected(bad: str) -> None: + with pytest.raises(ConfigurationError): + resolve_adcp_version(bad) + + +def test_compatible_versions_constant_matches_major() -> None: + """Every entry in COMPATIBLE_ADCP_VERSIONS must agree on major.""" + for v in COMPATIBLE_ADCP_VERSIONS: + assert parse_adcp_major_version(v) == ADCP_MAJOR_VERSION + + +# --------------------------------------------------------------------------- +# ADCPClient +# --------------------------------------------------------------------------- + + +def _agent_config() -> AgentConfig: + return AgentConfig( + id="test", + agent_uri="https://test.example.com", + protocol=Protocol.A2A, + ) + + +def test_adcp_client_default_uses_packaged_version() -> None: + client = ADCPClient(_agent_config()) + assert client.get_adcp_version() == get_adcp_spec_version() + + +@pytest.mark.parametrize("version", ["3.0", "3.1", "3.1-beta"]) +def test_adcp_client_explicit_pin_accepted(version: str) -> None: + client = ADCPClient(_agent_config(), adcp_version=version) + assert client.get_adcp_version() == version + + +def test_adcp_client_cross_major_rejected() -> None: + with pytest.raises(ConfigurationError): + ADCPClient(_agent_config(), adcp_version="4.0") + + +def test_adcp_client_unparseable_rejected() -> None: + with pytest.raises(ConfigurationError): + ADCPClient(_agent_config(), adcp_version="banana") + + +# --------------------------------------------------------------------------- +# ADCPMultiAgentClient +# --------------------------------------------------------------------------- + + +def test_multi_agent_default_uses_packaged_version() -> None: + multi = ADCPMultiAgentClient(agents=[_agent_config()]) + assert multi.get_adcp_version() == get_adcp_spec_version() + + +def test_multi_agent_pin_forwards_to_per_agent() -> None: + multi = ADCPMultiAgentClient(agents=[_agent_config()], adcp_version="3.1") + assert multi.get_adcp_version() == "3.1" + assert multi.agent("test").get_adcp_version() == "3.1" + + +def test_multi_agent_cross_major_rejected() -> None: + with pytest.raises(ConfigurationError): + ADCPMultiAgentClient(agents=[_agent_config()], adcp_version="4.0") + + +# --------------------------------------------------------------------------- +# ADCPServerBuilder + adcp_server() factory +# --------------------------------------------------------------------------- + + +def test_server_builder_default_uses_packaged_version() -> None: + builder = ADCPServerBuilder("my-seller") + assert builder.get_adcp_version() == get_adcp_spec_version() + + +@pytest.mark.parametrize("version", ["3.0", "3.1"]) +def test_server_builder_explicit_pin_accepted(version: str) -> None: + builder = ADCPServerBuilder("my-seller", adcp_version=version) + assert builder.get_adcp_version() == version + + +def test_server_builder_cross_major_rejected() -> None: + with pytest.raises(ConfigurationError): + ADCPServerBuilder("my-seller", adcp_version="4.0") + + +def test_adcp_server_factory_passes_adcp_version_through() -> None: + builder = adcp_server("my-seller", adcp_version="3.1") + assert builder.get_adcp_version() == "3.1" + + +def test_adcp_server_factory_cross_major_rejected() -> None: + with pytest.raises(ConfigurationError): + adcp_server("my-seller", adcp_version="4.0") From 9f50defdd96e491c25389aa9c5b8891e581a7fe0 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Apr 2026 06:59:48 -0400 Subject: [PATCH 2/7] feat(sdk): wire emission for adcp_version pin (Stage 3a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/adcp/client.py | 11 +++ src/adcp/protocols/a2a.py | 2 + src/adcp/protocols/base.py | 17 ++++ src/adcp/protocols/mcp.py | 4 + src/adcp/server/builder.py | 7 +- src/adcp/server/responses.py | 29 ++++++- tests/test_adcp_version_wire.py | 143 ++++++++++++++++++++++++++++++++ 7 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 tests/test_adcp_version_wire.py diff --git a/src/adcp/client.py b/src/adcp/client.py index 2d2b18e0..a1f4e55c 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -480,6 +480,17 @@ def __init__( # Apply schema validation modes (default: requests=warn, responses=strict # in dev/test, warn in production — see ``ValidationHookConfig`` docs). self.adapter.configure_validation(validation) + # Auto-inject the per-instance ``adcp_version`` pin into every + # outbound request envelope. Caller-supplied values on the + # request object win — the enricher is the default, not an + # override — so per-call overrides remain available once the + # generated request types declare the field. + _pinned_version = self._adcp_version + + def _inject_adcp_version(params: dict[str, Any]) -> dict[str, Any]: + return {"adcp_version": _pinned_version, **params} + + self.adapter.envelope_enricher = _inject_adcp_version if context_id: # Empty string is treated as "not provided" — callers using diff --git a/src/adcp/protocols/a2a.py b/src/adcp/protocols/a2a.py index 6a78f1cf..1d9a2a99 100644 --- a/src/adcp/protocols/a2a.py +++ b/src/adcp/protocols/a2a.py @@ -401,6 +401,8 @@ async def _call_a2a_tool( params, idempotency_key = _idempotency.inject_key( tool_name, params, client_token=self.idempotency_client_token ) + # Apply per-instance envelope enrichment (e.g. adcp_version pin). + params = self._enrich_outgoing_params(params) # Pre-send schema validation. Matches the MCP adapter: strict mode # surfaces as TaskStatus.FAILED so the SDK's unified failure model diff --git a/src/adcp/protocols/base.py b/src/adcp/protocols/base.py index a624b233..4e3657e9 100644 --- a/src/adcp/protocols/base.py +++ b/src/adcp/protocols/base.py @@ -54,6 +54,23 @@ def __init__(self, agent_config: AgentConfig): # ``ADCPClient(validation=...)`` or an env override. self.request_validation_mode: ValidationMode = "warn" self.response_validation_mode: ValidationMode = "strict" + # Optional hook applied to every outbound request params dict + # before validation/send. The owning ADCPClient installs one to + # auto-inject ``adcp_version`` from the per-instance pin. Returns + # a new dict (the original is not mutated). Caller-supplied + # values on the original dict win — the enricher is the default, + # not an override. + self.envelope_enricher: Callable[[dict[str, Any]], dict[str, Any]] | None = None + + def _enrich_outgoing_params(self, params: Any) -> Any: + """Apply ``envelope_enricher`` to an outbound params dict. + + No-op for non-dict params (rare — most tool methods pass dicts + from ``model_dump()``) and when no enricher is installed. + """ + if self.envelope_enricher is None or not isinstance(params, dict): + return params + return self.envelope_enricher(params) def configure_validation(self, config: ValidationHookConfig | None) -> None: """Apply a client's :class:`ValidationHookConfig` to this adapter.""" diff --git a/src/adcp/protocols/mcp.py b/src/adcp/protocols/mcp.py index 46f21e16..a88167de 100644 --- a/src/adcp/protocols/mcp.py +++ b/src/adcp/protocols/mcp.py @@ -508,6 +508,10 @@ async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskRe params, idempotency_key = _idempotency.inject_key( tool_name, params, client_token=self.idempotency_client_token ) + # Apply per-instance envelope enrichment (e.g. adcp_version pin). + # Runs after idempotency injection so the enriched dict is the + # one that's validated and sent. + params = self._enrich_outgoing_params(params) try: # Pre-send schema validation — throws in strict, logs in warn, diff --git a/src/adcp/server/builder.py b/src/adcp/server/builder.py index ab4202c3..c439a512 100644 --- a/src/adcp/server/builder.py +++ b/src/adcp/server/builder.py @@ -176,8 +176,13 @@ def build_handler(self) -> ADCPHandler[Any]: if domains: from adcp.server.responses import capabilities_response + pinned_version = self._adcp_version + async def auto_capabilities(params: Any, context: Any = None) -> dict[str, Any]: - return capabilities_response(domains) + return capabilities_response( + domains, + adcp_version=pinned_version, + ) handlers["get_adcp_capabilities"] = auto_capabilities diff --git a/src/adcp/server/responses.py b/src/adcp/server/responses.py index dc826ece..c94a4262 100644 --- a/src/adcp/server/responses.py +++ b/src/adcp/server/responses.py @@ -45,6 +45,9 @@ def capabilities_response( supported_protocols: list[str], *, major_versions: list[int] | None = None, + adcp_version: str | None = None, + supported_versions: list[str] | None = None, + build_version: str | None = None, sandbox: bool = True, features: dict[str, Any] | None = None, idempotency: dict[str, Any] | None = None, @@ -57,7 +60,23 @@ def capabilities_response( Valid values: media_buy, signals, governance, creative, brand, sponsored_intelligence. ``compliance_testing`` is NOT a protocol — pass it via the ``compliance_testing`` kwarg. - major_versions: AdCP major versions. Defaults to [3]. + major_versions: AdCP major versions. Defaults to [3]. Deprecated in + favor of ``supported_versions`` (release-precision); both are + emitted through 3.x for backwards compatibility. + adcp_version: Server's pinned release this response was built + for (release-precision string, e.g. ``"3.1"``). When set, + included on the response envelope so buyers can read what + release the server actually served. Typically passed by + ``ADCPServerBuilder``'s auto-capabilities handler from its + per-instance pin. + supported_versions: Release-precision versions this server speaks + (e.g. ``["3.0", "3.1"]``). Authoritative for buyer-side + release pinning per the version-negotiation RFC. When omitted + and ``adcp_version`` is set, defaults to ``[adcp_version]``. + build_version: Optional advisory metadata — full + VERSION.RELEASE.PATCH of the server's build (e.g. + ``"3.1.2"``). Useful for incident triage; not part of the + wire negotiation contract. sandbox: Whether this is a sandbox agent. Defaults to True. features: Additional feature flags. idempotency: Optional idempotency declaration, nested under @@ -80,6 +99,12 @@ def capabilities_response( ) """ adcp_info: dict[str, Any] = {"major_versions": major_versions or [3]} + if supported_versions is None and adcp_version is not None: + supported_versions = [adcp_version] + if supported_versions: + adcp_info["supported_versions"] = supported_versions + if build_version: + adcp_info["build_version"] = build_version if idempotency: adcp_info["idempotency"] = idempotency resp: dict[str, Any] = { @@ -87,6 +112,8 @@ def capabilities_response( "supported_protocols": supported_protocols, "sandbox": sandbox, } + if adcp_version is not None: + resp["adcp_version"] = adcp_version if features: resp["features"] = features if compliance_testing is not None: diff --git a/tests/test_adcp_version_wire.py b/tests/test_adcp_version_wire.py new file mode 100644 index 00000000..9e69d9ef --- /dev/null +++ b/tests/test_adcp_version_wire.py @@ -0,0 +1,143 @@ +"""Stage 3a wire emission tests for the per-instance ``adcp_version`` pin. + +Validates: + +- Outbound request params get ``adcp_version`` injected from the + client's per-instance pin (via ``ProtocolAdapter.envelope_enricher``). +- Caller-supplied values on the params dict win over the enricher. +- ``capabilities_response()`` emits ``supported_versions`` / + ``build_version`` / top-level ``adcp_version`` when the server + builder's pin is threaded through. +- ``ADCPServerBuilder``'s auto-generated capabilities handler passes + the pin into ``capabilities_response()``. +""" + +from __future__ import annotations + +import asyncio + +from adcp import ADCPClient +from adcp.server.builder import ADCPServerBuilder, adcp_server +from adcp.server.responses import capabilities_response +from adcp.types import AgentConfig, Protocol + + +def _agent_config() -> AgentConfig: + return AgentConfig( + id="test", + agent_uri="https://test.example.com", + protocol=Protocol.A2A, + ) + + +# --------------------------------------------------------------------------- +# envelope_enricher / outbound injection +# --------------------------------------------------------------------------- + + +def test_envelope_enricher_injects_pin() -> None: + client = ADCPClient(_agent_config(), adcp_version="3.1") + enriched = client.adapter._enrich_outgoing_params({"brief": "hi"}) + assert enriched == {"adcp_version": "3.1", "brief": "hi"} + + +def test_envelope_enricher_does_not_overwrite_caller_value() -> None: + """Caller-supplied adcp_version on the params dict wins over the pin.""" + client = ADCPClient(_agent_config(), adcp_version="3.1") + enriched = client.adapter._enrich_outgoing_params({"adcp_version": "3.0", "brief": "hi"}) + assert enriched == {"adcp_version": "3.0", "brief": "hi"} + + +def test_envelope_enricher_passes_through_non_dict() -> None: + """Rare but possible: non-dict params (e.g. None or a list) pass through.""" + client = ADCPClient(_agent_config(), adcp_version="3.1") + assert client.adapter._enrich_outgoing_params(None) is None + assert client.adapter._enrich_outgoing_params([1, 2, 3]) == [1, 2, 3] + + +def test_envelope_enricher_uses_default_when_pin_omitted() -> None: + """Default pin = packaged ADCP_VERSION.""" + from adcp import get_adcp_spec_version + + client = ADCPClient(_agent_config()) + enriched = client.adapter._enrich_outgoing_params({}) + assert enriched["adcp_version"] == get_adcp_spec_version() + + +# --------------------------------------------------------------------------- +# capabilities_response() +# --------------------------------------------------------------------------- + + +def test_capabilities_response_omits_new_fields_when_no_pin() -> None: + """Backwards compatible: existing callers see no new fields.""" + resp = capabilities_response(["media_buy"]) + assert "adcp_version" not in resp + assert "supported_versions" not in resp["adcp"] + assert "build_version" not in resp["adcp"] + assert resp["adcp"]["major_versions"] == [3] + + +def test_capabilities_response_emits_supported_versions_from_pin() -> None: + """Single-pin server: supported_versions defaults to [pin].""" + resp = capabilities_response(["media_buy"], adcp_version="3.1") + assert resp["adcp_version"] == "3.1" + assert resp["adcp"]["supported_versions"] == ["3.1"] + # Legacy field still emitted for back-compat. + assert resp["adcp"]["major_versions"] == [3] + + +def test_capabilities_response_explicit_supported_versions_override() -> None: + """Multi-release server: caller passes explicit list.""" + resp = capabilities_response( + ["media_buy"], + adcp_version="3.1", + supported_versions=["3.0", "3.1"], + ) + assert resp["adcp_version"] == "3.1" + assert resp["adcp"]["supported_versions"] == ["3.0", "3.1"] + + +def test_capabilities_response_emits_build_version() -> None: + resp = capabilities_response( + ["media_buy"], + adcp_version="3.1", + build_version="3.1.2", + ) + assert resp["adcp"]["build_version"] == "3.1.2" + + +# --------------------------------------------------------------------------- +# ADCPServerBuilder auto-capabilities passes pin through +# --------------------------------------------------------------------------- + + +def test_server_builder_auto_capabilities_emits_pin() -> None: + """The auto-generated get_adcp_capabilities handler threads the pin + into capabilities_response().""" + builder = adcp_server("my-seller", adcp_version="3.1") + + @builder.get_products + async def _get_products(params, context=None): + return {"products": []} + + handler = builder.build_handler() + + response = asyncio.run(handler.get_adcp_capabilities({}, None)) + assert response["adcp_version"] == "3.1" + assert response["adcp"]["supported_versions"] == ["3.1"] + + +def test_server_builder_auto_capabilities_uses_default_pin() -> None: + """No explicit pin → packaged ADCP_VERSION drives the response.""" + from adcp import get_adcp_spec_version + + builder = ADCPServerBuilder("my-seller") + + @builder.get_products + async def _get_products(params, context=None): + return {"products": []} + + handler = builder.build_handler() + response = asyncio.run(handler.get_adcp_capabilities({}, None)) + assert response["adcp_version"] == get_adcp_spec_version() From 3db4b008170a8b48e84df5fada0d253f162cb49e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Apr 2026 19:50:17 -0400 Subject: [PATCH 3/7] fix(sdk): normalize adcp_version to release-precision before wire emission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/adcp/_version.py | 58 +++++++++++++++++------ tests/test_adcp_version_option.py | 76 ++++++++++++++++++++++++++----- tests/test_adcp_version_wire.py | 10 ++-- 3 files changed, 114 insertions(+), 30 deletions(-) diff --git a/src/adcp/_version.py b/src/adcp/_version.py index c7a1d198..ca9031a1 100644 --- a/src/adcp/_version.py +++ b/src/adcp/_version.py @@ -44,6 +44,37 @@ _VERSION_RE: re.Pattern[str] = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?(?:-[a-zA-Z0-9.-]+)?$") +def normalize_to_release_precision(version: str) -> str: + """Strip the patch component from a semver string for wire emission. + + Per the AdCP version-negotiation spec + (`core/version-envelope.json`), wire values for ``adcp_version`` + are release-precision only. SDKs that read full-semver values + from bundle metadata (``ADCP_VERSION`` file, ``published_version``, + etc.) MUST normalize before emitting on the wire — meta-field + values are not valid wire values. + + Examples: + + - ``"3.0"`` → ``"3.0"`` (already release-precision) + - ``"3.0.0"`` → ``"3.0"`` + - ``"3.0.1"`` → ``"3.0"`` + - ``"3.1-beta"`` → ``"3.1-beta"`` + - ``"3.1.0-beta"`` → ``"3.1-beta"`` + - ``"3.1.0-rc.1"`` → ``"3.1-rc.1"`` + + Raises :class:`ValueError` on unparseable strings. + """ + match = _VERSION_RE.match(version) + if match is None: + raise ValueError(f"adcp_version {version!r} is not a valid semver-shaped string.") + major, release = match.group(1), match.group(2) + # Pre-release tag is whatever comes after the optional patch. + suffix_start = match.end(3) if match.group(3) is not None else match.end(2) + pre_release = version[suffix_start:] # includes leading "-" or "" + return f"{major}.{release}{pre_release}" + + def parse_adcp_major_version(version: str) -> int: """Extract the major component from a release- or patch-precision version string. @@ -77,35 +108,34 @@ def _read_packaged_version() -> str: def resolve_adcp_version(pin: str | None) -> str: """Validate and resolve a constructor-supplied ``adcp_version`` pin. - - ``None`` → returns the packaged ``ADCP_VERSION`` (SDK default). - - Same-major pin → returned as-is. Release- and patch-precision - both accepted; the SDK does not normalize the string at this - layer (callers see what they passed). + - ``None`` → reads the packaged ``ADCP_VERSION`` file (SDK default). + - Same-major pin → accepted. - Cross-major pin → raises :class:`ConfigurationError`. - Unparseable string → raises :class:`ConfigurationError`. - The cross-major fence is the only construction-time fail. Within - the same major, release-level pins are accepted optimistically — - Stage 3 (per-instance schema/validator selection) is what - actually validates that the pinned release exists in the SDK's - schema cache. Until Stage 3 lands, the pin is plumbing only. + All resolved pins are normalized to release-precision before being + returned, per the spec's wire-value rule + (``core/version-envelope.json``). Patch-precision inputs like + ``"3.0.1"`` are accepted (the ``ADCP_VERSION`` file ships in this + shape today) but stored and emitted as ``"3.0"``. ``get_adcp_version()`` + therefore returns release-precision regardless of what the caller + passed; this is intentional — wire values are the canonical form. """ # Imported here to avoid a circular import at module load time. from adcp.exceptions import ConfigurationError - if pin is None: - return _read_packaged_version() + raw = pin if pin is not None else _read_packaged_version() try: - major = parse_adcp_major_version(pin) + major = parse_adcp_major_version(raw) except ValueError as exc: raise ConfigurationError(str(exc)) from exc if major != ADCP_MAJOR_VERSION: raise ConfigurationError( - f"adcp_version={pin!r} targets major {major}, but this SDK speaks " + f"adcp_version={raw!r} targets major {major}, but this SDK speaks " f"AdCP {ADCP_MAJOR_VERSION}.x. Install the SDK major that targets " f"AdCP {major}.x — cross-major pinning is not supported." ) - return pin + return normalize_to_release_precision(raw) diff --git a/tests/test_adcp_version_option.py b/tests/test_adcp_version_option.py index 8ab89ccb..6ea1f9fd 100644 --- a/tests/test_adcp_version_option.py +++ b/tests/test_adcp_version_option.py @@ -20,6 +20,7 @@ from adcp._version import ( ADCP_MAJOR_VERSION, COMPATIBLE_ADCP_VERSIONS, + normalize_to_release_precision, parse_adcp_major_version, resolve_adcp_version, ) @@ -70,13 +71,55 @@ def test_parse_adcp_major_version_rejects_garbage(bad_version: str) -> None: # --------------------------------------------------------------------------- -def test_resolve_default_returns_packaged_version() -> None: - assert resolve_adcp_version(None) == get_adcp_spec_version() +def test_resolve_default_returns_normalized_packaged_version() -> None: + """Default pin is the packaged ADCP_VERSION, normalized to release-precision.""" + assert resolve_adcp_version(None) == normalize_to_release_precision(get_adcp_spec_version()) -@pytest.mark.parametrize("version", ["3.0", "3.1", "3.0.0", "3.0.1", "3.1-beta"]) -def test_resolve_same_major_accepted(version: str) -> None: - assert resolve_adcp_version(version) == version +@pytest.mark.parametrize( + "version,expected", + [ + ("3.0", "3.0"), + ("3.1", "3.1"), + ("3.0.0", "3.0"), # normalized — patch stripped + ("3.0.1", "3.0"), # normalized — patch stripped + ("3.1-beta", "3.1-beta"), + ("3.1.0-rc.1", "3.1-rc.1"), # normalized — patch stripped, pre-release kept + ], +) +def test_resolve_same_major_normalized(version: str, expected: str) -> None: + """All same-major pins resolve to release-precision per the spec wire rule.""" + assert resolve_adcp_version(version) == expected + + +# --------------------------------------------------------------------------- +# normalize_to_release_precision +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "input,expected", + [ + ("3.0", "3.0"), + ("3.1", "3.1"), + ("3.0.0", "3.0"), + ("3.0.1", "3.0"), + ("3.1-beta", "3.1-beta"), + ("3.1.0-beta", "3.1-beta"), + ("3.1.0-rc.1", "3.1-rc.1"), + ("3.1.2-beta.5", "3.1-beta.5"), + ("10.20.30", "10.20"), + ], +) +def test_normalize_strips_patch_keeps_prerelease(input: str, expected: str) -> None: + assert normalize_to_release_precision(input) == expected + + +def test_normalize_rejects_garbage() -> None: + import pytest as _pytest + + with _pytest.raises(ValueError): + normalize_to_release_precision("banana") @pytest.mark.parametrize("version", ["4.0", "2.0", "5.1", "1.0.0"]) @@ -111,15 +154,24 @@ def _agent_config() -> AgentConfig: ) -def test_adcp_client_default_uses_packaged_version() -> None: +def test_adcp_client_default_uses_normalized_packaged_version() -> None: client = ADCPClient(_agent_config()) - assert client.get_adcp_version() == get_adcp_spec_version() + assert client.get_adcp_version() == normalize_to_release_precision(get_adcp_spec_version()) -@pytest.mark.parametrize("version", ["3.0", "3.1", "3.1-beta"]) -def test_adcp_client_explicit_pin_accepted(version: str) -> None: +@pytest.mark.parametrize( + "version,expected", + [ + ("3.0", "3.0"), + ("3.1", "3.1"), + ("3.1-beta", "3.1-beta"), + ("3.0.0", "3.0"), # patch input → release stored + ("3.0.1", "3.0"), + ], +) +def test_adcp_client_pin_normalized(version: str, expected: str) -> None: client = ADCPClient(_agent_config(), adcp_version=version) - assert client.get_adcp_version() == version + assert client.get_adcp_version() == expected def test_adcp_client_cross_major_rejected() -> None: @@ -139,7 +191,7 @@ def test_adcp_client_unparseable_rejected() -> None: def test_multi_agent_default_uses_packaged_version() -> None: multi = ADCPMultiAgentClient(agents=[_agent_config()]) - assert multi.get_adcp_version() == get_adcp_spec_version() + assert multi.get_adcp_version() == normalize_to_release_precision(get_adcp_spec_version()) def test_multi_agent_pin_forwards_to_per_agent() -> None: @@ -160,7 +212,7 @@ def test_multi_agent_cross_major_rejected() -> None: def test_server_builder_default_uses_packaged_version() -> None: builder = ADCPServerBuilder("my-seller") - assert builder.get_adcp_version() == get_adcp_spec_version() + assert builder.get_adcp_version() == normalize_to_release_precision(get_adcp_spec_version()) @pytest.mark.parametrize("version", ["3.0", "3.1"]) diff --git a/tests/test_adcp_version_wire.py b/tests/test_adcp_version_wire.py index 9e69d9ef..41a89b9a 100644 --- a/tests/test_adcp_version_wire.py +++ b/tests/test_adcp_version_wire.py @@ -56,12 +56,13 @@ def test_envelope_enricher_passes_through_non_dict() -> None: def test_envelope_enricher_uses_default_when_pin_omitted() -> None: - """Default pin = packaged ADCP_VERSION.""" + """Default pin = packaged ADCP_VERSION, normalized to release-precision.""" from adcp import get_adcp_spec_version + from adcp._version import normalize_to_release_precision client = ADCPClient(_agent_config()) enriched = client.adapter._enrich_outgoing_params({}) - assert enriched["adcp_version"] == get_adcp_spec_version() + assert enriched["adcp_version"] == normalize_to_release_precision(get_adcp_spec_version()) # --------------------------------------------------------------------------- @@ -129,8 +130,9 @@ async def _get_products(params, context=None): def test_server_builder_auto_capabilities_uses_default_pin() -> None: - """No explicit pin → packaged ADCP_VERSION drives the response.""" + """No explicit pin → packaged ADCP_VERSION (normalized) drives the response.""" from adcp import get_adcp_spec_version + from adcp._version import normalize_to_release_precision builder = ADCPServerBuilder("my-seller") @@ -140,4 +142,4 @@ async def _get_products(params, context=None): handler = builder.build_handler() response = asyncio.run(handler.get_adcp_capabilities({}, None)) - assert response["adcp_version"] == get_adcp_spec_version() + assert response["adcp_version"] == normalize_to_release_precision(get_adcp_spec_version()) From ebed151058406edf14ee0d57cd1098563d83795f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Apr 2026 20:01:00 -0400 Subject: [PATCH 4/7] 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. --- src/adcp/__init__.py | 2 ++ src/adcp/_version.py | 44 ++++++++++++++++++------------- src/adcp/client.py | 15 ++++++++--- tests/test_adcp_version_option.py | 4 +++ 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/adcp/__init__.py b/src/adcp/__init__.py index 144fc7fd..73768009 100644 --- a/src/adcp/__init__.py +++ b/src/adcp/__init__.py @@ -43,6 +43,7 @@ ADCPToolNotFoundError, ADCPWebhookError, ADCPWebhookSignatureError, + ConfigurationError, IdempotencyConflictError, IdempotencyExpiredError, IdempotencyUnsupportedError, @@ -838,6 +839,7 @@ def get_adcp_version() -> str: "AdagentsValidationError", "AdagentsNotFoundError", "AdagentsTimeoutError", + "ConfigurationError", "RegistryError", # Validation utilities "SchemaValidationError", diff --git a/src/adcp/_version.py b/src/adcp/_version.py index ca9031a1..3fbb13c3 100644 --- a/src/adcp/_version.py +++ b/src/adcp/_version.py @@ -23,10 +23,6 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - pass # Release-precision versions this SDK can speak. Patch-level pinning is # intentionally absent — patches don't change the wire contract by @@ -39,29 +35,38 @@ ADCP_MAJOR_VERSION: int = 3 # Matches release-precision (3.0, 3.1) and patch-precision (3.0.0, -# 3.0.1) semver, with optional pre-release tag (3.1-beta, 3.1.0-rc.1). +# 3.0.1) semver, with optional pre-release tag (3.1-beta, 3.1.0-rc.1) +# and optional build metadata (3.0.1+canary, 3.1.0-beta+exp.sha.5114f85). # Captures the major as group 1. -_VERSION_RE: re.Pattern[str] = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?(?:-[a-zA-Z0-9.-]+)?$") +_VERSION_RE: re.Pattern[str] = re.compile( + r"^(\d+)\.(\d+)(?:\.(\d+))?(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$" +) def normalize_to_release_precision(version: str) -> str: - """Strip the patch component from a semver string for wire emission. + """Strip patch component (and build metadata) for wire emission. Per the AdCP version-negotiation spec - (`core/version-envelope.json`), wire values for ``adcp_version`` + (``core/version-envelope.json``), wire values for ``adcp_version`` are release-precision only. SDKs that read full-semver values from bundle metadata (``ADCP_VERSION`` file, ``published_version``, etc.) MUST normalize before emitting on the wire — meta-field values are not valid wire values. + Pre-release tags are preserved (they describe the release line); + build metadata is dropped (it's purely a build identifier, never + part of a contract). + Examples: - - ``"3.0"`` → ``"3.0"`` (already release-precision) - - ``"3.0.0"`` → ``"3.0"`` - - ``"3.0.1"`` → ``"3.0"`` - - ``"3.1-beta"`` → ``"3.1-beta"`` - - ``"3.1.0-beta"`` → ``"3.1-beta"`` - - ``"3.1.0-rc.1"`` → ``"3.1-rc.1"`` + - ``"3.0"`` → ``"3.0"`` (already release-precision) + - ``"3.0.0"`` → ``"3.0"`` + - ``"3.0.1"`` → ``"3.0"`` + - ``"3.1-beta"`` → ``"3.1-beta"`` + - ``"3.1.0-beta"`` → ``"3.1-beta"`` + - ``"3.1.0-rc.1"`` → ``"3.1-rc.1"`` + - ``"3.0.1+canary"`` → ``"3.0"`` + - ``"3.1.0-beta+sha.5"`` → ``"3.1-beta"`` Raises :class:`ValueError` on unparseable strings. """ @@ -69,10 +74,13 @@ def normalize_to_release_precision(version: str) -> str: if match is None: raise ValueError(f"adcp_version {version!r} is not a valid semver-shaped string.") major, release = match.group(1), match.group(2) - # Pre-release tag is whatever comes after the optional patch. - suffix_start = match.end(3) if match.group(3) is not None else match.end(2) - pre_release = version[suffix_start:] # includes leading "-" or "" - return f"{major}.{release}{pre_release}" + # Skip past patch component (group 3) if present, then take whatever's + # left (pre-release tag and/or build metadata) and drop the +build half. + rest_start = match.end(3) if match.group(3) is not None else match.end(2) + rest = version[rest_start:] + if "+" in rest: + rest = rest.split("+", 1)[0] + return f"{major}.{release}{rest}" def parse_adcp_major_version(version: str) -> int: diff --git a/src/adcp/client.py b/src/adcp/client.py index a1f4e55c..2b0ab46f 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -431,9 +431,18 @@ def __init__( packaged with the wheel). Cross-major pins raise :class:`ConfigurationError` at construction; install the SDK major that targets your wire version instead. - Patch-precision strings (``"3.0.1"``) are accepted but - patches are not part of the negotiation contract per - spec — use release-precision in production. + Patch-precision strings (``"3.0.1"``) and build + metadata (``"3.0.1+canary"``) are accepted at construction + but normalized to release-precision before wire emission + per the spec — patches and build metadata are not part + of the negotiation contract. ``get_adcp_version()`` + returns the normalized form. + + Caller-supplied ``adcp_version`` on a per-call params + dict wins over the constructor pin: the enricher is + the default, not an override. Once Stage 3 threads + schema selection through, this becomes a supported + per-call override; today it's plumbing-level only. """ self._adcp_version: str = resolve_adcp_version(adcp_version) self.agent_config = agent_config diff --git a/tests/test_adcp_version_option.py b/tests/test_adcp_version_option.py index 6ea1f9fd..1166190c 100644 --- a/tests/test_adcp_version_option.py +++ b/tests/test_adcp_version_option.py @@ -109,6 +109,10 @@ def test_resolve_same_major_normalized(version: str, expected: str) -> None: ("3.1.0-rc.1", "3.1-rc.1"), ("3.1.2-beta.5", "3.1-beta.5"), ("10.20.30", "10.20"), + # Build metadata stripped (not part of contract). + ("3.0.1+canary", "3.0"), + ("3.0+exp.sha.5114f85", "3.0"), + ("3.1.0-beta+sha.5", "3.1-beta"), ], ) def test_normalize_strips_patch_keeps_prerelease(input: str, expected: str) -> None: From 32305ab445ca74ecb33135aa9073d6ec11d6a6a3 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Apr 2026 20:05:22 -0400 Subject: [PATCH 5/7] docs(sdk): dx-expert polish on adcp_version pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/adcp/client.py | 22 ++++++++++++++++------ src/adcp/exceptions.py | 20 ++++++++------------ 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/adcp/client.py b/src/adcp/client.py index 2b0ab46f..4ff394fa 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -405,12 +405,13 @@ def __init__( both ``context_id`` AND ``active_task_id``. Raises ``TypeError`` if passed with a non-A2A protocol. - force_a2a_version: A2A-only. Pin the wire version by - filtering the peer's advertised - ``supported_interfaces`` to entries whose - ``protocol_version`` matches. Intended for tests or - for forcing a 0.3-speaking path against a - dual-advertising peer. Raises + force_a2a_version: A2A-only. Pin the **A2A transport + version** (e.g. ``"0.3"``, ``"1.0"``) by filtering the + peer's advertised ``supported_interfaces`` to entries + whose ``protocol_version`` matches. Not for AdCP + protocol pinning — see ``adcp_version`` for that. + Intended for tests or for forcing a 0.3-speaking path + against a dual-advertising peer. Raises :class:`ADCPConnectionError` on the first call if no advertised interface matches. ``None`` (default) lets the SDK's ``ClientFactory`` pick the most capable @@ -443,6 +444,15 @@ def __init__( the default, not an override. Once Stage 3 threads schema selection through, this becomes a supported per-call override; today it's plumbing-level only. + + Migration from ``adcp_major_version`` (legacy integer + wire field): generated request types still expose + ``adcp_major_version: int | None`` from the pre-#3493 + schema. Both fields will coexist on the wire through + 3.x; servers prefer the new ``adcp_version`` when both + are present. Stop populating ``adcp_major_version`` on + request models once your seller advertises 3.1 in + ``supported_versions``. """ self._adcp_version: str = resolve_adcp_version(adcp_version) self.agent_config = agent_config diff --git a/src/adcp/exceptions.py b/src/adcp/exceptions.py index f157c1bf..c5007a08 100644 --- a/src/adcp/exceptions.py +++ b/src/adcp/exceptions.py @@ -415,18 +415,14 @@ def __init__( class ConfigurationError(ADCPError): """Invalid SDK configuration detected at construction time. - Raised when a value passed to a client/server constructor cannot be - reconciled with the SDK's compile-time pin. Currently used for - cross-major ``adcp_version`` pins — e.g. constructing an - ``ADCPClient`` with ``adcp_version="4.0"`` against an SDK built for - AdCP 3.x. Pre-release tags and unparseable strings also raise this. - - Construction-time validation is the right layer: a cross-major pin - has no recoverable runtime path in this SDK major. Stage 3 (per- - instance schema/validator selection across releases within the same - major) is what lifts the cross-major fence — until then, callers - pinning across majors should install the SDK major that speaks - their target wire version. + Raised when a value passed to a client/server constructor cannot + be reconciled with the SDK's compile-time pin — most commonly a + cross-major ``adcp_version`` (e.g. ``adcp_version="4.0"`` against + an SDK built for AdCP 3.x), or an unparseable version string. + + Recovery: install the SDK major that targets the wire version you + want to speak. Cross-major pinning is not supported within a + single SDK major. """ From 0a9b724650db0e60f70b7849cf8875e09229001b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Apr 2026 20:10:43 -0400 Subject: [PATCH 6/7] feat(sdk): per-agent adcp_version map on ADCPMultiAgentClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/adcp/client.py | 93 ++++++++++++++++++++++--------- src/adcp/protocols/base.py | 8 +++ src/adcp/server/responses.py | 2 +- tests/test_adcp_version_option.py | 58 +++++++++++++++++++ 4 files changed, 134 insertions(+), 27 deletions(-) diff --git a/src/adcp/client.py b/src/adcp/client.py index 4ff394fa..dec5f982 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -4280,7 +4280,7 @@ def __init__( on_activity: Callable[[Activity], None] | None = None, handlers: dict[str, Callable[..., Any]] | None = None, signing: SigningConfig | None = None, - adcp_version: str | None = None, + adcp_version: str | dict[str, str] | None = None, ): """ Initialize multi-agent client. @@ -4294,34 +4294,75 @@ def __init__( signing: Optional RFC 9421 signing config forwarded to every per-agent ADCPClient. The same identity signs traffic to all agents. See ADCPClient.__init__ for details. - adcp_version: AdCP protocol release pin forwarded to every - per-agent ADCPClient. All agents under this multi-client - speak the same release. See ADCPClient.__init__ for - semantics. Cross-major pins raise ConfigurationError at - construction. - """ - self._adcp_version: str = resolve_adcp_version(adcp_version) - self.agents = { - agent.id: ADCPClient( - agent, - webhook_url_template=webhook_url_template, - webhook_secret=webhook_secret, - on_activity=on_activity, - signing=signing, - adcp_version=self._adcp_version, - ) - for agent in agents - } + adcp_version: AdCP protocol release pin. Three forms: + + - ``None`` (default): every per-agent ADCPClient resolves + the SDK's compile-time pin. + - ``str`` (e.g. ``"3.1"``): every agent uses this pin. + - ``dict[str, str]`` (e.g. + ``{"seller_a": "3.0", "seller_b": "3.1"}``): per-agent + override map keyed by ``agent.id``. Agents missing + from the map fall back to the SDK default — useful + for holdco/multi-tenant operators where one seller is + ahead of the others on the upgrade cadence. + + See ADCPClient.__init__ for per-instance semantics. + Cross-major pins raise ConfigurationError at construction. + """ + # Per-agent map → resolve each pin individually for the dict form; + # otherwise use the uniform pin for all agents. + if isinstance(adcp_version, dict): + self._adcp_version: str | None = None # mixed pins + self._per_agent_versions: dict[str, str] = { + agent_id: resolve_adcp_version(pin) for agent_id, pin in adcp_version.items() + } + default_pin = resolve_adcp_version(None) + self.agents = { + agent.id: ADCPClient( + agent, + webhook_url_template=webhook_url_template, + webhook_secret=webhook_secret, + on_activity=on_activity, + signing=signing, + adcp_version=self._per_agent_versions.get(agent.id, default_pin), + ) + for agent in agents + } + else: + self._adcp_version = resolve_adcp_version(adcp_version) + self._per_agent_versions = {} + self.agents = { + agent.id: ADCPClient( + agent, + webhook_url_template=webhook_url_template, + webhook_secret=webhook_secret, + on_activity=on_activity, + signing=signing, + adcp_version=self._adcp_version, + ) + for agent in agents + } self.handlers = handlers or {} def get_adcp_version(self) -> str: - """Return the AdCP protocol release this multi-client is pinned to. - - Resolved at construction. All per-agent clients share the same - pin — see ADCPClient.get_adcp_version for the per-instance - semantics. - """ - return self._adcp_version + """Return the AdCP protocol release pin for this multi-client. + + Returns the uniform pin when all agents share one. Raises + :class:`ValueError` when agents have heterogeneous pins (the + ``dict[str, str]`` constructor form) — in that case, query + the per-agent pin via ``multi.agent(agent_id).get_adcp_version()``. + """ + if self._adcp_version is not None: + return self._adcp_version + # Heterogeneous: surface uniformly if all agents agree at runtime. + versions = {client.get_adcp_version() for client in self.agents.values()} + if len(versions) == 1: + return next(iter(versions)) + raise ValueError( + "Multi-agent client has heterogeneous adcp_version pins; " + "use multi.agent(agent_id).get_adcp_version() to read per-agent. " + f"Pins by agent: { {a: c.get_adcp_version() for a, c in self.agents.items()} }" + ) def agent(self, agent_id: str) -> ADCPClient: """Get client for specific agent.""" diff --git a/src/adcp/protocols/base.py b/src/adcp/protocols/base.py index 4e3657e9..f19fb53f 100644 --- a/src/adcp/protocols/base.py +++ b/src/adcp/protocols/base.py @@ -60,6 +60,14 @@ def __init__(self, agent_config: AgentConfig): # a new dict (the original is not mutated). Caller-supplied # values on the original dict win — the enricher is the default, # not an override. + # + # Contract: the validator runs on the enriched dict, so any field + # the enricher injects must be either (a) declared in the request + # schema, or (b) tolerated by the schema's ``additionalProperties`` + # policy. Top-level Request models in this SDK declare + # ``extra="allow"`` (see ``AdCPBaseModel`` overrides in generated + # types) — flipping any of them to ``extra="forbid"`` would break + # this assumption silently. self.envelope_enricher: Callable[[dict[str, Any]], dict[str, Any]] | None = None def _enrich_outgoing_params(self, params: Any) -> Any: diff --git a/src/adcp/server/responses.py b/src/adcp/server/responses.py index c94a4262..1ce525e4 100644 --- a/src/adcp/server/responses.py +++ b/src/adcp/server/responses.py @@ -103,7 +103,7 @@ def capabilities_response( supported_versions = [adcp_version] if supported_versions: adcp_info["supported_versions"] = supported_versions - if build_version: + if build_version is not None: adcp_info["build_version"] = build_version if idempotency: adcp_info["idempotency"] = idempotency diff --git a/tests/test_adcp_version_option.py b/tests/test_adcp_version_option.py index 1166190c..da1ee465 100644 --- a/tests/test_adcp_version_option.py +++ b/tests/test_adcp_version_option.py @@ -209,6 +209,64 @@ def test_multi_agent_cross_major_rejected() -> None: ADCPMultiAgentClient(agents=[_agent_config()], adcp_version="4.0") +def _two_agents() -> list[AgentConfig]: + return [ + AgentConfig(id="seller_a", agent_uri="https://a.example.com", protocol=Protocol.A2A), + AgentConfig(id="seller_b", agent_uri="https://b.example.com", protocol=Protocol.A2A), + ] + + +def test_multi_agent_per_agent_map_pins_each_agent_independently() -> None: + """Per-agent dict form lets a holdco pin each seller separately.""" + multi = ADCPMultiAgentClient( + agents=_two_agents(), + adcp_version={"seller_a": "3.0", "seller_b": "3.1"}, + ) + assert multi.agent("seller_a").get_adcp_version() == "3.0" + assert multi.agent("seller_b").get_adcp_version() == "3.1" + + +def test_multi_agent_per_agent_map_falls_back_to_default_for_missing_keys() -> None: + multi = ADCPMultiAgentClient( + agents=_two_agents(), + adcp_version={"seller_a": "3.1"}, + ) + assert multi.agent("seller_a").get_adcp_version() == "3.1" + # seller_b missing from map → SDK default. + assert multi.agent("seller_b").get_adcp_version() == normalize_to_release_precision( + get_adcp_spec_version() + ) + + +def test_multi_agent_get_version_raises_on_heterogeneous_pins() -> None: + multi = ADCPMultiAgentClient( + agents=_two_agents(), + adcp_version={"seller_a": "3.0", "seller_b": "3.1"}, + ) + with pytest.raises(ValueError) as exc: + multi.get_adcp_version() + msg = str(exc.value) + assert "heterogeneous" in msg + assert "seller_a" in msg and "seller_b" in msg + + +def test_multi_agent_get_version_returns_uniform_when_map_agrees() -> None: + """Dict form with all agents at the same pin still resolves uniformly.""" + multi = ADCPMultiAgentClient( + agents=_two_agents(), + adcp_version={"seller_a": "3.1", "seller_b": "3.1"}, + ) + assert multi.get_adcp_version() == "3.1" + + +def test_multi_agent_per_agent_map_cross_major_rejected() -> None: + with pytest.raises(ConfigurationError): + ADCPMultiAgentClient( + agents=_two_agents(), + adcp_version={"seller_a": "3.0", "seller_b": "4.0"}, + ) + + # --------------------------------------------------------------------------- # ADCPServerBuilder + adcp_server() factory # --------------------------------------------------------------------------- From 7b8990737979f9c91729be4832f1340531ea0a8d Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Apr 2026 20:32:03 -0400 Subject: [PATCH 7/7] chore(types): regen public API snapshot for ConfigurationError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot test caught the new public symbol added in commit ebed1510 (re-export ConfigurationError from adcp.__init__). Regenerated via scripts/regenerate_public_api_snapshot.py — addition is intentional. --- tests/fixtures/public_api_snapshot.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index fc5cffa2..2957ed9c 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -75,6 +75,7 @@ "Checkpoint", "ComplyTestControllerRequest", "ComplyTestControllerResponse", + "ConfigurationError", "ConsentBasis", "ContentIdType", "ContextMatchRequest",