Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/adcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
ADCPToolNotFoundError,
ADCPWebhookError,
ADCPWebhookSignatureError,
ConfigurationError,
IdempotencyConflictError,
IdempotencyExpiredError,
IdempotencyUnsupportedError,
Expand Down Expand Up @@ -838,6 +839,7 @@ def get_adcp_version() -> str:
"AdagentsValidationError",
"AdagentsNotFoundError",
"AdagentsTimeoutError",
"ConfigurationError",
"RegistryError",
# Validation utilities
"SchemaValidationError",
Expand Down
149 changes: 149 additions & 0 deletions src/adcp/_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""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

# 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)
# 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.-]+)?(?:\+[a-zA-Z0-9.-]+)?$"
)


def normalize_to_release_precision(version: str) -> str:
"""Strip patch component (and build metadata) 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.

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.1+canary"`` → ``"3.0"``
- ``"3.1.0-beta+sha.5"`` → ``"3.1-beta"``

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)
# 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:
"""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`` → reads the packaged ``ADCP_VERSION`` file (SDK default).
- Same-major pin → accepted.
- Cross-major pin → raises :class:`ConfigurationError`.
- Unparseable string → raises :class:`ConfigurationError`.

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

raw = pin if pin is not None else _read_packaged_version()

try:
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={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 normalize_to_release_precision(raw)
154 changes: 138 additions & 16 deletions src/adcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -403,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
Expand All @@ -417,7 +420,41 @@ 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"``) 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.

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
self.webhook_url_template = webhook_url_template
self.webhook_secret = webhook_secret
Expand Down Expand Up @@ -462,6 +499,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
Expand All @@ -479,6 +527,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.
Expand Down Expand Up @@ -4216,6 +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 | dict[str, str] | None = None,
):
"""
Initialize multi-agent client.
Expand All @@ -4229,19 +4294,76 @@ 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. 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.
"""
self.agents = {
agent.id: ADCPClient(
agent,
webhook_url_template=webhook_url_template,
webhook_secret=webhook_secret,
on_activity=on_activity,
signing=signing,
)
for agent in agents
}
# 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 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."""
if agent_id not in self.agents:
Expand Down
Loading
Loading