Skip to content

Commit 433c999

Browse files
feat(compat): runtime SDK↔backend version guard at ACP startup (#408)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f312fef commit 433c999

8 files changed

Lines changed: 452 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Runtime SDK ↔ backend contract-version guard.
2+
3+
Complements the *build-time* cross-version compatibility tests (``tests/compat``):
4+
5+
- **Build-time** (CI): is this *client* compatible with the window of supported server
6+
contracts (``min-supported``..``current``)?
7+
- **Runtime** (this module): is the *server* the SDK is pointed at within that window?
8+
9+
It runs once at ACP/worker startup, reads the backend's contract version (the version
10+
the server already reports via ``/openapi.json`` ``info.version``), and **fails fast with
11+
an actionable error** if the backend is older than this SDK supports — instead of the
12+
mismatch surfacing later as opaque 500s / missing-field errors deep in a request.
13+
14+
``MIN_BACKEND_CONTRACT`` is the same source of truth as the ``min-supported`` server
15+
contract in ``tests/compat/server_specs/manifest.json``: the oldest agentex backend this
16+
SDK version supports. Bump both together when a breaking change raises the floor.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import os
22+
import re
23+
24+
import httpx
25+
26+
from agentex.lib.utils.logging import make_logger
27+
28+
logger = make_logger(__name__)
29+
30+
# Oldest agentex backend contract this SDK is compatible with.
31+
# Keep in sync with the `min-supported` spec in tests/compat (#407); the version axis
32+
# itself comes from scale-agentex release tags (#321). Bump on a breaking SDK change.
33+
MIN_BACKEND_CONTRACT = "0.1.0"
34+
35+
SKIP_ENV = "AGENTEX_SKIP_VERSION_CHECK"
36+
37+
# Full-string SemVer. Accepts: `1.2.3`, leading `v`, surrounding whitespace, `-prerelease`
38+
# (captured), `+build` (ignored). Anchored at both ends so a malformed tail (`0.1.0rc1`,
39+
# `0.1.0.1`) is rejected → None → "unknown, proceed", not silently coerced to stable `0.1.0`.
40+
_VERSION_RE = re.compile(
41+
r"^\s*v?(\d+)\.(\d+)\.(\d+)" # major.minor.patch
42+
r"(?:-([0-9A-Za-z.-]+))?" # optional -prerelease (captured)
43+
r"(?:\+[0-9A-Za-z.-]+)?" # optional +build metadata (ignored)
44+
r"\s*$"
45+
)
46+
47+
48+
class IncompatibleBackendError(RuntimeError):
49+
"""Raised when the agentex backend is older than this SDK's minimum supported contract."""
50+
51+
52+
def _parse(version: str | None) -> tuple[int, int, int, str | None] | None:
53+
"""Parse ``major.minor.patch[-prerelease]`` → ``(major, minor, patch, prerelease)``.
54+
55+
``prerelease`` is the raw dot-separated identifier string (e.g. ``"rc.1"``), or None for
56+
a stable release. Build metadata (after ``+``) is ignored. Returns None if unparseable.
57+
"""
58+
m = _VERSION_RE.match(version or "")
59+
if not m:
60+
return None
61+
return (int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4) or None)
62+
63+
64+
# Comparable SemVer precedence key. The 4th element keeps a uniform shape across stable and
65+
# prerelease so the whole tuple is orderable: (rank, identifiers), where stable rank 1 > prerelease
66+
# rank 0 (and the identifier list is only ever compared when both sides are prereleases, rank 0).
67+
_PreKey = tuple[int, int, int, tuple[int, list[tuple[int, int, str]]]]
68+
69+
70+
def _precedence_key(parsed: tuple[int, int, int, str | None]) -> _PreKey:
71+
"""SemVer §11 precedence key (directly comparable with ``<``).
72+
73+
A stable release outranks any prerelease of the same triplet (``0.1.0-rc.1 < 0.1.0``);
74+
among prereleases, numeric identifiers rank below alphanumeric and compare field-by-field,
75+
with a longer identifier list outranking a shorter prefix-equal one.
76+
"""
77+
major, minor, patch, prerelease = parsed
78+
if prerelease is None:
79+
return (major, minor, patch, (1, [])) # stable sorts above every prerelease
80+
identifiers: list[tuple[int, int, str]] = []
81+
for ident in prerelease.split("."):
82+
if ident.isdigit():
83+
identifiers.append((0, int(ident), "")) # numeric: lowest class, numeric order
84+
else:
85+
identifiers.append((1, 0, ident)) # alphanumeric: higher class, lexical order
86+
return (major, minor, patch, (0, identifiers))
87+
88+
89+
def _truthy(name: str) -> bool:
90+
return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")
91+
92+
93+
async def fetch_backend_version(base_url: str, *, timeout: float = 5.0) -> str | None:
94+
"""Return the backend's reported contract version (``/openapi.json`` ``info.version``), or None."""
95+
url = base_url.rstrip("/") + "/openapi.json"
96+
try:
97+
async with httpx.AsyncClient(timeout=timeout) as client:
98+
resp = await client.get(url)
99+
resp.raise_for_status()
100+
return (resp.json().get("info") or {}).get("version")
101+
except Exception as exc: # noqa: BLE001 - any failure → unknown, handled by caller
102+
logger.warning("backend version guard: could not fetch %s (%s)", url, exc)
103+
return None
104+
105+
106+
async def assert_backend_compatible(
107+
base_url: str | None,
108+
*,
109+
min_version: str = MIN_BACKEND_CONTRACT,
110+
sdk_version: str | None = None,
111+
) -> None:
112+
"""Fail fast at startup if the backend is older than ``min_version``.
113+
114+
No-op (warns, does not raise) when:
115+
- ``AGENTEX_SKIP_VERSION_CHECK`` is set (explicit bypass),
116+
- ``base_url`` is unset,
117+
- the backend version can't be determined (unreachable / unparseable) — a transient
118+
blip or a contract-less server shouldn't crash startup.
119+
120+
Raises ``IncompatibleBackendError`` only when the backend version is *known* and older
121+
than ``min_version``.
122+
"""
123+
if _truthy(SKIP_ENV):
124+
logger.warning("%s set — skipping backend version guard", SKIP_ENV)
125+
return
126+
if not base_url:
127+
return
128+
129+
if sdk_version is None:
130+
from agentex._version import __version__ as sdk_version # local import to avoid cycles
131+
132+
backend_version = await fetch_backend_version(base_url)
133+
if backend_version is None:
134+
logger.warning(
135+
"backend version guard: could not determine backend version at %s; proceeding "
136+
"(set %s=1 to silence).",
137+
base_url,
138+
SKIP_ENV,
139+
)
140+
return
141+
142+
backend, minimum = _parse(backend_version), _parse(min_version)
143+
if backend is None or minimum is None:
144+
logger.warning(
145+
"backend version guard: unparseable version(s) backend=%r min=%r; proceeding.",
146+
backend_version,
147+
min_version,
148+
)
149+
return
150+
151+
if _precedence_key(backend) < _precedence_key(minimum):
152+
raise IncompatibleBackendError(
153+
f"agentex-sdk {sdk_version} requires agentex backend >= {min_version}, "
154+
f"but {base_url} reports {backend_version}. "
155+
f"Upgrade the backend, or pin agentex-sdk to a version compatible with backend "
156+
f"{backend_version}. (Set {SKIP_ENV}=1 to bypass at your own risk.)"
157+
)
158+
159+
logger.info(
160+
"backend version guard OK: sdk=%s backend=%s (min=%s)",
161+
sdk_version,
162+
backend_version,
163+
min_version,
164+
)

src/agentex/lib/core/temporal/workers/worker.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from agentex.lib.utils.logging import make_logger
3131
from agentex.lib.utils.registration import register_agent
3232
from agentex.lib.environment_variables import EnvironmentVariables
33+
from agentex.lib.core.compat.version_guard import assert_backend_compatible
3334

3435
logger = make_logger(__name__)
3536

@@ -278,6 +279,10 @@ async def start_health_check_server(self):
278279
async def _register_agent(self):
279280
env_vars = EnvironmentVariables.refresh()
280281
if env_vars and env_vars.AGENTEX_BASE_URL:
282+
# Fail fast if this worker is pointed at a backend older than the SDK supports —
283+
# the worker process never goes through the ACP server lifespan, so it needs its
284+
# own guard (mirrors base_acp_server.lifespan_context).
285+
await assert_backend_compatible(env_vars.AGENTEX_BASE_URL)
281286
await register_agent(env_vars)
282287
else:
283288
logger.warning("AGENTEX_BASE_URL not set, skipping worker registration")

src/agentex/lib/sdk/fastacp/base/base_acp_server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from agentex.types.task_message_update import TaskMessageUpdate, StreamTaskMessageFull
3434
from agentex.types.task_message_content import TaskMessageContent
3535
from agentex.lib.core.tracing.span_queue import shutdown_default_span_queue
36+
from agentex.lib.core.compat.version_guard import assert_backend_compatible
3637
from agentex.lib.sdk.fastacp.base.constants import (
3738
FASTACP_HEADER_SKIP_EXACT,
3839
FASTACP_HEADER_SKIP_PREFIXES,
@@ -104,6 +105,9 @@ def get_lifespan_function(self):
104105
async def lifespan_context(app: FastAPI): # noqa: ARG001
105106
env_vars = EnvironmentVariables.refresh()
106107
if env_vars.AGENTEX_BASE_URL:
108+
# Runtime SDK<->backend contract guard: fail fast if the backend is older
109+
# than this SDK supports, instead of opaque 500s later. See compat.version_guard.
110+
await assert_backend_compatible(env_vars.AGENTEX_BASE_URL)
107111
await register_agent(env_vars, agent_card=self._agent_card)
108112
self.agent_id = env_vars.AGENT_ID
109113
else:

tests/lib/core/temporal/__init__.py

Whitespace-only changes.

tests/lib/core/temporal/workers/__init__.py

Whitespace-only changes.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""AgentexWorker wires the backend version guard into worker startup.
2+
3+
A Temporal worker runs as its own process and never goes through the ACP server
4+
lifespan, so the guard must run inside `_register_agent` — before `register_agent`,
5+
and only when `AGENTEX_BASE_URL` is set.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from unittest.mock import Mock, AsyncMock
11+
12+
import pytest
13+
14+
from agentex.lib.core.temporal.workers import worker as worker_mod
15+
from agentex.lib.core.compat.version_guard import IncompatibleBackendError
16+
17+
18+
def _worker():
19+
# explicit health_check_port so __init__ doesn't read EnvironmentVariables
20+
return worker_mod.AgentexWorker(task_queue="test-queue", health_check_port=8080)
21+
22+
23+
def _patch_env(monkeypatch, base_url):
24+
env = Mock()
25+
env.AGENTEX_BASE_URL = base_url
26+
fake_cls = Mock()
27+
fake_cls.refresh.return_value = env
28+
monkeypatch.setattr(worker_mod, "EnvironmentVariables", fake_cls)
29+
return env
30+
31+
32+
async def test_guard_runs_before_register_agent(monkeypatch):
33+
env = _patch_env(monkeypatch, "http://backend")
34+
order: list[str] = []
35+
guard = AsyncMock(side_effect=lambda *a, **k: order.append("guard"))
36+
register = AsyncMock(side_effect=lambda *a, **k: order.append("register"))
37+
monkeypatch.setattr(worker_mod, "assert_backend_compatible", guard)
38+
monkeypatch.setattr(worker_mod, "register_agent", register)
39+
40+
await _worker()._register_agent()
41+
42+
guard.assert_awaited_once_with("http://backend")
43+
register.assert_awaited_once_with(env)
44+
assert order == ["guard", "register"] # guard must precede registration
45+
46+
47+
async def test_incompatible_backend_blocks_registration(monkeypatch):
48+
_patch_env(monkeypatch, "http://backend")
49+
guard = AsyncMock(side_effect=IncompatibleBackendError("backend too old"))
50+
register = AsyncMock()
51+
monkeypatch.setattr(worker_mod, "assert_backend_compatible", guard)
52+
monkeypatch.setattr(worker_mod, "register_agent", register)
53+
54+
with pytest.raises(IncompatibleBackendError):
55+
await _worker()._register_agent()
56+
57+
register.assert_not_awaited() # fail fast — never register against an unsupported backend
58+
59+
60+
async def test_no_base_url_skips_guard_and_registration(monkeypatch):
61+
_patch_env(monkeypatch, None)
62+
guard = AsyncMock()
63+
register = AsyncMock()
64+
monkeypatch.setattr(worker_mod, "assert_backend_compatible", guard)
65+
monkeypatch.setattr(worker_mod, "register_agent", register)
66+
67+
await _worker()._register_agent()
68+
69+
guard.assert_not_awaited()
70+
register.assert_not_awaited()

0 commit comments

Comments
 (0)