Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions src/agentex/lib/core/compat/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

124 changes: 124 additions & 0 deletions src/agentex/lib/core/compat/version_guard.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated


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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing tests this — every test mocks fetch_backend_version out. Worth a respx test for the parse paths (missing info, missing version, non-2xx, non-JSON → all should degrade to None).

🧑‍💻🤖 — posted via Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4d9e2c2. fetch_backend_version is now exercised for real via httpx.MockTransport (the function actually runs — request build, status check, JSON parse — just no network):

  • success + asserts URL is …/openapi.json and method GET
  • missing info.version, info absent, info: nullNone
  • HTTP 404 / 503 → None
  • non-JSON body → None
  • httpx.ConnectErrorNone

Plus end-to-end assert_backend_compatible through the real fetch (old → raises, new → passes, unreachable → proceeds). Writing these caught a real bug in the first test helper (it recursed infinitely), which the mock-everything tests would never have surfaced.

"""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,
)
4 changes: 4 additions & 0 deletions src/agentex/lib/sdk/fastacp/base/base_acp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
65 changes: 65 additions & 0 deletions tests/test_version_guard.py
Original file line number Diff line number Diff line change
@@ -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(""))
Loading