From 433c999bbdb4817d2048c5454cb65b54812950af Mon Sep 17 00:00:00 2001 From: Nitesh Dhanpal Date: Wed, 17 Jun 2026 21:10:11 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(compat):=20runtime=20SDK=E2=86=94backe?= =?UTF-8?q?nd=20version=20guard=20at=20ACP=20startup=20(#408)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.8 --- src/agentex/lib/core/compat/__init__.py | 1 + src/agentex/lib/core/compat/version_guard.py | 164 ++++++++++++++ .../lib/core/temporal/workers/worker.py | 5 + .../lib/sdk/fastacp/base/base_acp_server.py | 4 + 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 | 208 ++++++++++++++++++ 8 files changed, 452 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/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 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..56933de0b --- /dev/null +++ b/src/agentex/lib/core/compat/version_guard.py @@ -0,0 +1,164 @@ +"""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" + +# 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): + """Raised when the agentex backend is older than this SDK's minimum supported contract.""" + + +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 "") + if not m: + return None + return (int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4) or 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``); + 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: list[tuple[int, int, str]] = [] + 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: + 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 _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}. " + 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/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") 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..b3dac487e 100644 --- a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py +++ b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py @@ -33,6 +33,7 @@ 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, @@ -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/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 new file mode 100644 index 000000000..dba4a50e2 --- /dev/null +++ b/tests/test_version_guard.py @@ -0,0 +1,208 @@ +"""Unit tests for the runtime backend version guard (agentex.lib.core.compat.version_guard).""" + +from __future__ import annotations + +import asyncio + +import httpx +import pytest + +from agentex.lib.core.compat import version_guard as vg + + +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) + 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" + + 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_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") + + 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("")) + + +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")) From be400b9575e0ea63db168ae66148fca68e13813a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 04:10:30 +0000 Subject: [PATCH 2/2] chore: release main --- .release-please-manifest.json | 2 +- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- src/agentex/_version.py | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index edf7acaa0..3780e633d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - ".": "0.13.1", + ".": "0.14.0", "adk": "0.13.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index abc68a6ba..1568fbc54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ * **tracing:** emit OTel metrics for async span queue depth, batch drain, and SGP export success/failure (HTTP status labels). Disable SDK-side recording with ``AGENTEX_TRACING_METRICS=0``. +## 0.14.0 (2026-06-18) + +Full Changelog: [agentex-client-v0.13.1...agentex-client-v0.14.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.13.1...agentex-client-v0.14.0) + +### Features + +* **compat:** runtime SDK↔backend version guard at ACP startup ([#408](https://github.com/scaleapi/scale-agentex-python/issues/408)) ([433c999](https://github.com/scaleapi/scale-agentex-python/commit/433c999bbdb4817d2048c5454cb65b54812950af)) + ## 0.13.1 (2026-06-17) Full Changelog: [agentex-client-v0.13.0...agentex-client-v0.13.1](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.13.0...agentex-client-v0.13.1) diff --git a/pyproject.toml b/pyproject.toml index 8fdbe0410..98134d993 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ # overlay (formerly `src/agentex/lib/*`) now lives in `adk/` and ships # as the sibling `agentex-sdk` package — see `adk/pyproject.toml`. name = "agentex-client" -version = "0.13.1" +version = "0.14.0" description = "The official Python REST client for the Agentex API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/agentex/_version.py b/src/agentex/_version.py index bc927dc30..551c0dbac 100644 --- a/src/agentex/_version.py +++ b/src/agentex/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "agentex" -__version__ = "0.13.1" # x-release-please-version +__version__ = "0.14.0" # x-release-please-version