From 3ad505bcdc38b602fefb6b1ec3e7adc96cb86756 Mon Sep 17 00:00:00 2001 From: Nitesh Dhanpal Date: Wed, 17 Jun 2026 10:44:45 -0700 Subject: [PATCH 1/6] feat(compat): runtime backend version guard at ACP startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complements the build-time cross-version compat tests (#407): on ACP/worker startup, read the backend's reported contract version (/openapi.json info.version) and fail fast with an actionable error if the backend is older than MIN_BACKEND_CONTRACT — instead of the mismatch surfacing later as opaque 500s / missing-field errors (the agentex-sdk 0.13 friction). - agentex/lib/core/compat/version_guard.py: assert_backend_compatible() + MIN_BACKEND_CONTRACT (kept in sync with tests/compat min-supported) + AGENTEX_SKIP_VERSION_CHECK escape hatch; warns (no crash) on unreachable/unknown. - wired into BaseACPServer lifespan (runs before register_agent when AGENTEX_BASE_URL set). - unit tests. Co-Authored-By: Claude Opus 4.8 --- src/agentex/lib/core/compat/__init__.py | 1 + src/agentex/lib/core/compat/version_guard.py | 124 ++++++++++++++++++ .../lib/sdk/fastacp/base/base_acp_server.py | 4 + tests/test_version_guard.py | 65 +++++++++ 4 files changed, 194 insertions(+) create mode 100644 src/agentex/lib/core/compat/__init__.py create mode 100644 src/agentex/lib/core/compat/version_guard.py create mode 100644 tests/test_version_guard.py diff --git a/src/agentex/lib/core/compat/__init__.py b/src/agentex/lib/core/compat/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/agentex/lib/core/compat/__init__.py @@ -0,0 +1 @@ + diff --git a/src/agentex/lib/core/compat/version_guard.py b/src/agentex/lib/core/compat/version_guard.py new file mode 100644 index 000000000..9c0b47151 --- /dev/null +++ b/src/agentex/lib/core/compat/version_guard.py @@ -0,0 +1,124 @@ +"""Runtime SDK ↔ backend contract-version guard. + +Complements the *build-time* cross-version compatibility tests (``tests/compat``): + +- **Build-time** (CI): is this *client* compatible with the window of supported server + contracts (``min-supported``..``current``)? +- **Runtime** (this module): is the *server* the SDK is pointed at within that window? + +It runs once at ACP/worker startup, reads the backend's contract version (the version +the server already reports via ``/openapi.json`` ``info.version``), and **fails fast with +an actionable error** if the backend is older than this SDK supports — instead of the +mismatch surfacing later as opaque 500s / missing-field errors deep in a request. + +``MIN_BACKEND_CONTRACT`` is the same source of truth as the ``min-supported`` server +contract in ``tests/compat/server_specs/manifest.json``: the oldest agentex backend this +SDK version supports. Bump both together when a breaking change raises the floor. +""" + +from __future__ import annotations + +import os +import re + +import httpx + +from agentex.lib.utils.logging import make_logger + +logger = make_logger(__name__) + +# Oldest agentex backend contract this SDK is compatible with. +# Keep in sync with the `min-supported` spec in tests/compat (#407); the version axis +# itself comes from scale-agentex release tags (#321). Bump on a breaking SDK change. +MIN_BACKEND_CONTRACT = "0.1.0" + +SKIP_ENV = "AGENTEX_SKIP_VERSION_CHECK" + +_VERSION_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)") + + +class IncompatibleBackendError(RuntimeError): + """Raised when the agentex backend is older than this SDK's minimum supported contract.""" + + +def _parse(version: str | None) -> tuple[int, int, int] | None: + m = _VERSION_RE.match(version or "") + return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else None + + +def _truthy(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on") + + +async def fetch_backend_version(base_url: str, *, timeout: float = 5.0) -> str | None: + """Return the backend's reported contract version (``/openapi.json`` ``info.version``), or None.""" + url = base_url.rstrip("/") + "/openapi.json" + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.get(url) + resp.raise_for_status() + return (resp.json().get("info") or {}).get("version") + except Exception as exc: # noqa: BLE001 - any failure → unknown, handled by caller + logger.warning("backend version guard: could not fetch %s (%s)", url, exc) + return None + + +async def assert_backend_compatible( + base_url: str | None, + *, + min_version: str = MIN_BACKEND_CONTRACT, + sdk_version: str | None = None, +) -> None: + """Fail fast at startup if the backend is older than ``min_version``. + + No-op (warns, does not raise) when: + - ``AGENTEX_SKIP_VERSION_CHECK`` is set (explicit bypass), + - ``base_url`` is unset, + - the backend version can't be determined (unreachable / unparseable) — a transient + blip or a contract-less server shouldn't crash startup. + + Raises ``IncompatibleBackendError`` only when the backend version is *known* and older + than ``min_version``. + """ + if _truthy(SKIP_ENV): + logger.warning("%s set — skipping backend version guard", SKIP_ENV) + return + if not base_url: + return + + if sdk_version is None: + from agentex._version import __version__ as sdk_version # local import to avoid cycles + + backend_version = await fetch_backend_version(base_url) + if backend_version is None: + logger.warning( + "backend version guard: could not determine backend version at %s; proceeding " + "(set %s=1 to silence).", + base_url, + SKIP_ENV, + ) + return + + backend, minimum = _parse(backend_version), _parse(min_version) + if backend is None or minimum is None: + logger.warning( + "backend version guard: unparseable version(s) backend=%r min=%r; proceeding.", + backend_version, + min_version, + ) + return + + if backend < minimum: + raise IncompatibleBackendError( + f"agentex-sdk {sdk_version} requires agentex backend >= {min_version}, " + f"but {base_url} reports {backend_version}. " + f"Upgrade the backend, or pin agentex-sdk to a version compatible with backend " + f"{backend_version}. (Set {SKIP_ENV}=1 to bypass at your own risk.)" + ) + + logger.info( + "backend version guard OK: sdk=%s backend=%s (min=%s)", + sdk_version, + backend_version, + min_version, + ) diff --git a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py index b0b1c3685..eb9968f49 100644 --- a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py +++ b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py @@ -27,6 +27,7 @@ from agentex.protocol.json_rpc import JSONRPCError, JSONRPCRequest, JSONRPCResponse from agentex.lib.utils.model_utils import BaseModel from agentex.lib.utils.registration import register_agent +from agentex.lib.core.compat.version_guard import assert_backend_compatible # from agentex.lib.sdk.fastacp.types import BaseACPConfig from agentex.lib.environment_variables import EnvironmentVariables, refreshed_environment_variables @@ -104,6 +105,9 @@ def get_lifespan_function(self): async def lifespan_context(app: FastAPI): # noqa: ARG001 env_vars = EnvironmentVariables.refresh() if env_vars.AGENTEX_BASE_URL: + # Runtime SDK<->backend contract guard: fail fast if the backend is older + # than this SDK supports, instead of opaque 500s later. See compat.version_guard. + await assert_backend_compatible(env_vars.AGENTEX_BASE_URL) await register_agent(env_vars, agent_card=self._agent_card) self.agent_id = env_vars.AGENT_ID else: diff --git a/tests/test_version_guard.py b/tests/test_version_guard.py new file mode 100644 index 000000000..fa3e081e6 --- /dev/null +++ b/tests/test_version_guard.py @@ -0,0 +1,65 @@ +"""Unit tests for the runtime backend version guard (agentex.lib.core.compat.version_guard).""" + +from __future__ import annotations + +import asyncio + +import pytest + +from agentex.lib.core.compat import version_guard as vg + + +def _run(coro): + return asyncio.run(coro) + + +def test_parse_versions(): + assert vg._parse("0.2.1") == (0, 2, 1) + assert vg._parse("v1.4.0") == (1, 4, 0) + assert vg._parse("0.2.1-rc.1+build5") == (0, 2, 1) + assert vg._parse("garbage") is None + assert vg._parse(None) is None + + +def test_compatible_backend_passes(monkeypatch): + async def fake(url, **kw): + return "0.2.0" + + monkeypatch.setattr(vg, "fetch_backend_version", fake) + # backend (0.2.0) >= min (0.1.0) → no raise + _run(vg.assert_backend_compatible("http://backend", min_version="0.1.0")) + + +def test_incompatible_backend_raises(monkeypatch): + async def fake(url, **kw): + return "0.0.9" + + monkeypatch.setattr(vg, "fetch_backend_version", fake) + with pytest.raises(vg.IncompatibleBackendError) as exc: + _run(vg.assert_backend_compatible("http://backend", min_version="0.1.0", sdk_version="0.13.0")) + msg = str(exc.value) + assert "0.13.0" in msg and "0.1.0" in msg and "0.0.9" in msg # actionable message + + +def test_skip_env_bypasses(monkeypatch): + async def fake(url, **kw): + raise AssertionError("must not fetch when skip env is set") + + monkeypatch.setattr(vg, "fetch_backend_version", fake) + monkeypatch.setenv(vg.SKIP_ENV, "1") + # even an impossible min must not raise when explicitly skipped + _run(vg.assert_backend_compatible("http://backend", min_version="9.9.9")) + + +def test_unknown_backend_version_does_not_crash(monkeypatch): + async def fake(url, **kw): + return None # unreachable / no version → unknown + + monkeypatch.setattr(vg, "fetch_backend_version", fake) + # unknown version warns but must not raise (transient/contract-less server) + _run(vg.assert_backend_compatible("http://backend", min_version="9.9.9")) + + +def test_no_base_url_is_noop(): + _run(vg.assert_backend_compatible(None)) + _run(vg.assert_backend_compatible("")) From adbaf4643c8db3f7ba53e002988e248397c52ef6 Mon Sep 17 00:00:00 2001 From: Nitesh Dhanpal Date: Wed, 17 Jun 2026 12:18:22 -0700 Subject: [PATCH 2/6] style(compat): sort import block in base_acp_server (ruff I001) Co-Authored-By: Claude Opus 4.8 --- src/agentex/lib/sdk/fastacp/base/base_acp_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py index eb9968f49..b3dac487e 100644 --- a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py +++ b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py @@ -27,13 +27,13 @@ from agentex.protocol.json_rpc import JSONRPCError, JSONRPCRequest, JSONRPCResponse from agentex.lib.utils.model_utils import BaseModel from agentex.lib.utils.registration import register_agent -from agentex.lib.core.compat.version_guard import assert_backend_compatible # from agentex.lib.sdk.fastacp.types import BaseACPConfig from agentex.lib.environment_variables import EnvironmentVariables, refreshed_environment_variables from agentex.types.task_message_update import TaskMessageUpdate, StreamTaskMessageFull from agentex.types.task_message_content import TaskMessageContent from agentex.lib.core.tracing.span_queue import shutdown_default_span_queue +from agentex.lib.core.compat.version_guard import assert_backend_compatible from agentex.lib.sdk.fastacp.base.constants import ( FASTACP_HEADER_SKIP_EXACT, FASTACP_HEADER_SKIP_PREFIXES, From e3e0a4486ae87f2bbdf3ff92153c87e036a1a0e2 Mon Sep 17 00:00:00 2001 From: Nitesh Dhanpal Date: Wed, 17 Jun 2026 12:35:11 -0700 Subject: [PATCH 3/6] fix(compat): order prereleases below stable in version guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SemVer §11: a prerelease precedes its stable release (0.1.0-rc.1 < 0.1.0). The old _parse dropped the suffix, so a release-candidate backend compared equal to a stable floor and slipped past the guard even though it may lack the final contract. Parse the prerelease and compare via a SemVer precedence key; a prerelease of a higher version (0.2.0-rc.1) still clears a 0.1.0 floor. Co-Authored-By: Claude Opus 4.8 --- src/agentex/lib/core/compat/version_guard.py | 35 +++++++++++++++++--- tests/test_version_guard.py | 24 ++++++++++++-- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/agentex/lib/core/compat/version_guard.py b/src/agentex/lib/core/compat/version_guard.py index 9c0b47151..0839d57a8 100644 --- a/src/agentex/lib/core/compat/version_guard.py +++ b/src/agentex/lib/core/compat/version_guard.py @@ -34,16 +34,43 @@ SKIP_ENV = "AGENTEX_SKIP_VERSION_CHECK" -_VERSION_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)") +# major.minor.patch, optional `-prerelease`; build metadata (after `+`) is ignored. +_VERSION_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?") class IncompatibleBackendError(RuntimeError): """Raised when the agentex backend is older than this SDK's minimum supported contract.""" -def _parse(version: str | None) -> tuple[int, int, int] | None: +def _parse(version: str | None) -> tuple[int, int, int, str | None] | None: + """Parse ``major.minor.patch[-prerelease]`` → ``(major, minor, patch, prerelease)``. + + ``prerelease`` is the raw dot-separated identifier string (e.g. ``"rc.1"``), or None for + a stable release. Build metadata (after ``+``) is ignored. Returns None if unparseable. + """ m = _VERSION_RE.match(version or "") - return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else None + if not m: + return None + return (int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4) or None) + + +def _precedence_key(parsed: tuple[int, int, int, str | None]): + """SemVer §11 precedence key (directly comparable with ``<``). + + A stable release outranks any prerelease of the same triplet (``0.1.0-rc.1 < 0.1.0``); + among prereleases, numeric identifiers rank below alphanumeric and compare field-by-field, + with a longer identifier list outranking a shorter prefix-equal one. + """ + major, minor, patch, prerelease = parsed + if prerelease is None: + return (major, minor, patch, (1,)) # stable sorts above every prerelease + identifiers = [] + for ident in prerelease.split("."): + if ident.isdigit(): + identifiers.append((0, int(ident), "")) # numeric: lowest class, numeric order + else: + identifiers.append((1, 0, ident)) # alphanumeric: higher class, lexical order + return (major, minor, patch, (0, identifiers)) def _truthy(name: str) -> bool: @@ -108,7 +135,7 @@ async def assert_backend_compatible( ) return - if backend < minimum: + if _precedence_key(backend) < _precedence_key(minimum): raise IncompatibleBackendError( f"agentex-sdk {sdk_version} requires agentex backend >= {min_version}, " f"but {base_url} reports {backend_version}. " diff --git a/tests/test_version_guard.py b/tests/test_version_guard.py index fa3e081e6..8bb3248ad 100644 --- a/tests/test_version_guard.py +++ b/tests/test_version_guard.py @@ -14,13 +14,22 @@ def _run(coro): def test_parse_versions(): - assert vg._parse("0.2.1") == (0, 2, 1) - assert vg._parse("v1.4.0") == (1, 4, 0) - assert vg._parse("0.2.1-rc.1+build5") == (0, 2, 1) + assert vg._parse("0.2.1") == (0, 2, 1, None) + assert vg._parse("v1.4.0") == (1, 4, 0, None) + assert vg._parse("0.2.1-rc.1+build5") == (0, 2, 1, "rc.1") # build metadata ignored assert vg._parse("garbage") is None assert vg._parse(None) is None +def test_prerelease_precedence(): + k = lambda v: vg._precedence_key(vg._parse(v)) # noqa: E731 + assert k("0.1.0-rc.1") < k("0.1.0") # prerelease precedes its stable release (SemVer §11) + assert k("0.1.0-rc.1") < k("0.1.0-rc.2") # numeric prerelease identifiers compare numerically + assert k("0.1.0-alpha") < k("0.1.0-rc") # numeric/alpha ordering by identifier + assert k("0.1.0") < k("0.1.1-rc.1") # patch bump outranks prior stable + assert k("0.2.0-rc.1") > k("0.1.0") # prerelease of a higher version still clears the floor + + def test_compatible_backend_passes(monkeypatch): async def fake(url, **kw): return "0.2.0" @@ -41,6 +50,15 @@ async def fake(url, **kw): assert "0.13.0" in msg and "0.1.0" in msg and "0.0.9" in msg # actionable message +def test_prerelease_backend_below_stable_floor_raises(monkeypatch): + async def fake(url, **kw): + return "0.1.0-rc.1" # release candidate: precedes the stable 0.1.0 contract + + monkeypatch.setattr(vg, "fetch_backend_version", fake) + with pytest.raises(vg.IncompatibleBackendError): + _run(vg.assert_backend_compatible("http://backend", min_version="0.1.0", sdk_version="0.13.0")) + + def test_skip_env_bypasses(monkeypatch): async def fake(url, **kw): raise AssertionError("must not fetch when skip env is set") From 9f2cabc61b2478f29de6a02951c27bd9d55b1a7e Mon Sep 17 00:00:00 2001 From: Nitesh Dhanpal Date: Wed, 17 Jun 2026 12:39:48 -0700 Subject: [PATCH 4/6] fix(compat): give SemVer precedence key a uniform type (pyright) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stable vs prerelease key branches returned different tuple shapes ((maj,min,patch,(1,)) vs (...,(0,list))), so pyright couldn't prove < was defined on the union. Make the 4th element a uniform (rank, identifiers) pair — stable rank 1 > prerelease rank 0 — keeping the ordering identical. Co-Authored-By: Claude Opus 4.8 --- src/agentex/lib/core/compat/version_guard.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/agentex/lib/core/compat/version_guard.py b/src/agentex/lib/core/compat/version_guard.py index 0839d57a8..40420f031 100644 --- a/src/agentex/lib/core/compat/version_guard.py +++ b/src/agentex/lib/core/compat/version_guard.py @@ -54,7 +54,13 @@ def _parse(version: str | None) -> tuple[int, int, int, str | None] | None: return (int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4) or None) -def _precedence_key(parsed: tuple[int, int, int, str | None]): +# Comparable SemVer precedence key. The 4th element keeps a uniform shape across stable and +# prerelease so the whole tuple is orderable: (rank, identifiers), where stable rank 1 > prerelease +# rank 0 (and the identifier list is only ever compared when both sides are prereleases, rank 0). +_PreKey = tuple[int, int, int, tuple[int, list[tuple[int, int, str]]]] + + +def _precedence_key(parsed: tuple[int, int, int, str | None]) -> _PreKey: """SemVer §11 precedence key (directly comparable with ``<``). A stable release outranks any prerelease of the same triplet (``0.1.0-rc.1 < 0.1.0``); @@ -63,8 +69,8 @@ def _precedence_key(parsed: tuple[int, int, int, str | None]): """ major, minor, patch, prerelease = parsed if prerelease is None: - return (major, minor, patch, (1,)) # stable sorts above every prerelease - identifiers = [] + return (major, minor, patch, (1, [])) # stable sorts above every prerelease + identifiers: list[tuple[int, int, str]] = [] for ident in prerelease.split("."): if ident.isdigit(): identifiers.append((0, int(ident), "")) # numeric: lowest class, numeric order From 5bbb9356b2b9b53793bc09b8863ca83f6eb1615e Mon Sep 17 00:00:00 2001 From: Nitesh Dhanpal Date: Wed, 17 Jun 2026 12:46:37 -0700 Subject: [PATCH 5/6] feat(compat): run backend version guard at Temporal worker startup Async/Temporal agents run a separate worker process that never goes through the ACP server lifespan, so the guard there wouldn't cover them. Wire it into AgentexWorker._register_agent (same AGENTEX_BASE_URL gate, before register_agent). Co-Authored-By: Claude Opus 4.8 --- src/agentex/lib/core/temporal/workers/worker.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/agentex/lib/core/temporal/workers/worker.py b/src/agentex/lib/core/temporal/workers/worker.py index 253b6759f..2b4958b1f 100644 --- a/src/agentex/lib/core/temporal/workers/worker.py +++ b/src/agentex/lib/core/temporal/workers/worker.py @@ -30,6 +30,7 @@ from agentex.lib.utils.logging import make_logger from agentex.lib.utils.registration import register_agent from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.compat.version_guard import assert_backend_compatible logger = make_logger(__name__) @@ -278,6 +279,10 @@ async def start_health_check_server(self): async def _register_agent(self): env_vars = EnvironmentVariables.refresh() if env_vars and env_vars.AGENTEX_BASE_URL: + # Fail fast if this worker is pointed at a backend older than the SDK supports — + # the worker process never goes through the ACP server lifespan, so it needs its + # own guard (mirrors base_acp_server.lifespan_context). + await assert_backend_compatible(env_vars.AGENTEX_BASE_URL) await register_agent(env_vars) else: logger.warning("AGENTEX_BASE_URL not set, skipping worker registration") From 4d9e2c251d0489e7bce73c286e7acb3836f60c1f Mon Sep 17 00:00:00 2001 From: Nitesh Dhanpal Date: Wed, 17 Jun 2026 13:33:47 -0700 Subject: [PATCH 6/6] fix(compat): anchor version regex + add real-fetch and worker-wiring tests - Anchor _VERSION_RE at both ends so a malformed tail (0.1.0rc1, 0.1.0foo, 0.1.0.1) is rejected to None ('unknown, proceed') instead of silently parsing as stable 0.1.0 and satisfying MIN_BACKEND_CONTRACT. - Test fetch_backend_version for real via httpx.MockTransport (success/URL, missing version, missing/null info, 404/503, non-JSON, connection error) plus end-to-end assert_backend_compatible through the real fetch. - Test the regex anchors explicitly (leading/trailing junk rejected; whitespace + leading v permitted). - Test AgentexWorker._register_agent wiring: guard runs before register_agent, incompatible backend blocks registration, no AGENTEX_BASE_URL skips both. Co-Authored-By: Claude Opus 4.8 --- src/agentex/lib/core/compat/version_guard.py | 11 +- tests/lib/core/temporal/__init__.py | 0 tests/lib/core/temporal/workers/__init__.py | 0 .../workers/test_worker_version_guard.py | 70 ++++++++++ tests/test_version_guard.py | 125 ++++++++++++++++++ 5 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 tests/lib/core/temporal/__init__.py create mode 100644 tests/lib/core/temporal/workers/__init__.py create mode 100644 tests/lib/core/temporal/workers/test_worker_version_guard.py diff --git a/src/agentex/lib/core/compat/version_guard.py b/src/agentex/lib/core/compat/version_guard.py index 40420f031..56933de0b 100644 --- a/src/agentex/lib/core/compat/version_guard.py +++ b/src/agentex/lib/core/compat/version_guard.py @@ -34,8 +34,15 @@ SKIP_ENV = "AGENTEX_SKIP_VERSION_CHECK" -# major.minor.patch, optional `-prerelease`; build metadata (after `+`) is ignored. -_VERSION_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?") +# Full-string SemVer. Accepts: `1.2.3`, leading `v`, surrounding whitespace, `-prerelease` +# (captured), `+build` (ignored). Anchored at both ends so a malformed tail (`0.1.0rc1`, +# `0.1.0.1`) is rejected → None → "unknown, proceed", not silently coerced to stable `0.1.0`. +_VERSION_RE = re.compile( + r"^\s*v?(\d+)\.(\d+)\.(\d+)" # major.minor.patch + r"(?:-([0-9A-Za-z.-]+))?" # optional -prerelease (captured) + r"(?:\+[0-9A-Za-z.-]+)?" # optional +build metadata (ignored) + r"\s*$" +) class IncompatibleBackendError(RuntimeError): diff --git a/tests/lib/core/temporal/__init__.py b/tests/lib/core/temporal/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/core/temporal/workers/__init__.py b/tests/lib/core/temporal/workers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/lib/core/temporal/workers/test_worker_version_guard.py b/tests/lib/core/temporal/workers/test_worker_version_guard.py new file mode 100644 index 000000000..4ab5fc435 --- /dev/null +++ b/tests/lib/core/temporal/workers/test_worker_version_guard.py @@ -0,0 +1,70 @@ +"""AgentexWorker wires the backend version guard into worker startup. + +A Temporal worker runs as its own process and never goes through the ACP server +lifespan, so the guard must run inside `_register_agent` — before `register_agent`, +and only when `AGENTEX_BASE_URL` is set. +""" + +from __future__ import annotations + +from unittest.mock import Mock, AsyncMock + +import pytest + +from agentex.lib.core.temporal.workers import worker as worker_mod +from agentex.lib.core.compat.version_guard import IncompatibleBackendError + + +def _worker(): + # explicit health_check_port so __init__ doesn't read EnvironmentVariables + return worker_mod.AgentexWorker(task_queue="test-queue", health_check_port=8080) + + +def _patch_env(monkeypatch, base_url): + env = Mock() + env.AGENTEX_BASE_URL = base_url + fake_cls = Mock() + fake_cls.refresh.return_value = env + monkeypatch.setattr(worker_mod, "EnvironmentVariables", fake_cls) + return env + + +async def test_guard_runs_before_register_agent(monkeypatch): + env = _patch_env(monkeypatch, "http://backend") + order: list[str] = [] + guard = AsyncMock(side_effect=lambda *a, **k: order.append("guard")) + register = AsyncMock(side_effect=lambda *a, **k: order.append("register")) + monkeypatch.setattr(worker_mod, "assert_backend_compatible", guard) + monkeypatch.setattr(worker_mod, "register_agent", register) + + await _worker()._register_agent() + + guard.assert_awaited_once_with("http://backend") + register.assert_awaited_once_with(env) + assert order == ["guard", "register"] # guard must precede registration + + +async def test_incompatible_backend_blocks_registration(monkeypatch): + _patch_env(monkeypatch, "http://backend") + guard = AsyncMock(side_effect=IncompatibleBackendError("backend too old")) + register = AsyncMock() + monkeypatch.setattr(worker_mod, "assert_backend_compatible", guard) + monkeypatch.setattr(worker_mod, "register_agent", register) + + with pytest.raises(IncompatibleBackendError): + await _worker()._register_agent() + + register.assert_not_awaited() # fail fast — never register against an unsupported backend + + +async def test_no_base_url_skips_guard_and_registration(monkeypatch): + _patch_env(monkeypatch, None) + guard = AsyncMock() + register = AsyncMock() + monkeypatch.setattr(worker_mod, "assert_backend_compatible", guard) + monkeypatch.setattr(worker_mod, "register_agent", register) + + await _worker()._register_agent() + + guard.assert_not_awaited() + register.assert_not_awaited() diff --git a/tests/test_version_guard.py b/tests/test_version_guard.py index 8bb3248ad..dba4a50e2 100644 --- a/tests/test_version_guard.py +++ b/tests/test_version_guard.py @@ -4,6 +4,7 @@ import asyncio +import httpx import pytest from agentex.lib.core.compat import version_guard as vg @@ -13,14 +14,49 @@ def _run(coro): return asyncio.run(coro) +def _patch_transport(monkeypatch, handler): + """Make version_guard's httpx.AsyncClient route through an in-memory MockTransport, + so fetch_backend_version runs for real (request build, status check, JSON parse) + without touching the network. `handler(request) -> httpx.Response` (or raises).""" + + real_client = httpx.AsyncClient # capture before patching to avoid recursing into the factory + + def factory(**kwargs): + kwargs.pop("transport", None) + return real_client(transport=httpx.MockTransport(handler), **kwargs) + + monkeypatch.setattr(vg.httpx, "AsyncClient", factory) + + def test_parse_versions(): assert vg._parse("0.2.1") == (0, 2, 1, None) assert vg._parse("v1.4.0") == (1, 4, 0, None) assert vg._parse("0.2.1-rc.1+build5") == (0, 2, 1, "rc.1") # build metadata ignored + assert vg._parse("0.1.0+build5") == (0, 1, 0, None) # build metadata only, still stable assert vg._parse("garbage") is None assert vg._parse(None) is None +def test_parse_rejects_malformed_tails(): + # Anchored regex: a junk tail after the triplet must NOT silently parse as stable 0.1.0; + # it has to fall through to None (→ unknown / unparseable path), not satisfy the floor. + for bad in ("0.1.0rc1", "0.1.0foo", "0.1.0.1", "0.1.0-", "1.2", "0.1.0-rc 1"): + assert vg._parse(bad) is None, bad + + +def test_parse_anchored_both_ends(): + # Leading anchor (^): anything before the triplet (other than whitespace / a `v`) is rejected. + for bad in ("foo0.1.0", ">=0.1.0", "x0.1.0", "=0.1.0", "0 0.1.0"): + assert vg._parse(bad) is None, bad + # Trailing anchor ($): anything after the version (other than whitespace) is rejected. + for bad in ("0.1.0 extra", "0.1.0;", "0.1.0/", "0.1.0+", "0.1.0 0.1.0"): + assert vg._parse(bad) is None, bad + # What the anchors DO permit: surrounding whitespace and an optional leading `v`. + assert vg._parse(" 0.1.0 ") == (0, 1, 0, None) + assert vg._parse("\tv1.2.3\n") == (1, 2, 3, None) + assert vg._parse(" 0.2.0-rc.1 ") == (0, 2, 0, "rc.1") + + def test_prerelease_precedence(): k = lambda v: vg._precedence_key(vg._parse(v)) # noqa: E731 assert k("0.1.0-rc.1") < k("0.1.0") # prerelease precedes its stable release (SemVer §11) @@ -81,3 +117,92 @@ async def fake(url, **kw): def test_no_base_url_is_noop(): _run(vg.assert_backend_compatible(None)) _run(vg.assert_backend_compatible("")) + + +def test_truthy(monkeypatch): + for val in ("1", "true", "True", "YES", "on"): + monkeypatch.setenv("X_GUARD_FLAG", val) + assert vg._truthy("X_GUARD_FLAG") + for val in ("0", "false", "no", "off", ""): + monkeypatch.setenv("X_GUARD_FLAG", val) + assert not vg._truthy("X_GUARD_FLAG") + monkeypatch.delenv("X_GUARD_FLAG", raising=False) + assert not vg._truthy("X_GUARD_FLAG") # unset → falsy + + +# --- fetch_backend_version: exercised for real through MockTransport (not mocked out) --- + + +def test_fetch_success_and_url_construction(monkeypatch): + seen = {} + + def handler(request): + seen["url"] = str(request.url) + seen["method"] = request.method + return httpx.Response(200, json={"openapi": "3.1.0", "info": {"version": "0.2.0"}}) + + _patch_transport(monkeypatch, handler) + assert _run(vg.fetch_backend_version("http://backend/")) == "0.2.0" + assert seen["url"] == "http://backend/openapi.json" # trailing slash trimmed, path appended + assert seen["method"] == "GET" + + +def test_fetch_missing_version_field(monkeypatch): + _patch_transport(monkeypatch, lambda r: httpx.Response(200, json={"info": {}})) + assert _run(vg.fetch_backend_version("http://backend")) is None + + +def test_fetch_missing_info_object(monkeypatch): + # `info` absent entirely, and `info: null` — both must coalesce to None, not crash. + _patch_transport(monkeypatch, lambda r: httpx.Response(200, json={})) + assert _run(vg.fetch_backend_version("http://backend")) is None + _patch_transport(monkeypatch, lambda r: httpx.Response(200, json={"info": None})) + assert _run(vg.fetch_backend_version("http://backend")) is None + + +def test_fetch_http_error_status(monkeypatch): + # raise_for_status() → caught → None (e.g. server has no /openapi.json) + _patch_transport(monkeypatch, lambda r: httpx.Response(404, text="not found")) + assert _run(vg.fetch_backend_version("http://backend")) is None + _patch_transport(monkeypatch, lambda r: httpx.Response(503, text="unavailable")) + assert _run(vg.fetch_backend_version("http://backend")) is None + + +def test_fetch_non_json_body(monkeypatch): + _patch_transport(monkeypatch, lambda r: httpx.Response(200, text="nope")) + assert _run(vg.fetch_backend_version("http://backend")) is None + + +def test_fetch_connection_error(monkeypatch): + def handler(request): + raise httpx.ConnectError("connection refused", request=request) + + _patch_transport(monkeypatch, handler) + assert _run(vg.fetch_backend_version("http://backend")) is None + + +# --- assert_backend_compatible end-to-end: real fetch through MockTransport, not mocked out --- + + +def test_assert_end_to_end_old_backend_raises(monkeypatch): + monkeypatch.delenv(vg.SKIP_ENV, raising=False) + _patch_transport(monkeypatch, lambda r: httpx.Response(200, json={"info": {"version": "0.0.9"}})) + with pytest.raises(vg.IncompatibleBackendError): + _run(vg.assert_backend_compatible("http://backend", min_version="0.1.0", sdk_version="0.13.0")) + + +def test_assert_end_to_end_new_backend_passes(monkeypatch): + monkeypatch.delenv(vg.SKIP_ENV, raising=False) + _patch_transport(monkeypatch, lambda r: httpx.Response(200, json={"info": {"version": "0.2.0"}})) + _run(vg.assert_backend_compatible("http://backend", min_version="0.1.0")) + + +def test_assert_end_to_end_unreachable_backend_does_not_raise(monkeypatch): + # real fetch returns None on connection failure → guard proceeds (no crash on transient blip) + monkeypatch.delenv(vg.SKIP_ENV, raising=False) + + def handler(request): + raise httpx.ConnectError("refused", request=request) + + _patch_transport(monkeypatch, handler) + _run(vg.assert_backend_compatible("http://backend", min_version="9.9.9"))