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
31 changes: 30 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,35 @@ jobs:
print(f'Bumped version from {current_version} to {new_version}')
"

- name: Refresh engine pin (ENGINE_VERSION)
id: engine
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Every SDK release re-pins agentx.version.ENGINE_VERSION to AgentX-trace-eval's
# newest release, so the tested-pair contract (agentx/cli.py installs and converges to
# this tag) never goes stale - both repos release per merge, a manual pin would lag
# within days. Fail-open on a transient API error: publishing with the previous pin
# beats not publishing.
set -euo pipefail
tag="$(gh release view --repo AgentX-ai/AgentX-trace-eval --json tagName -q .tagName || true)"
if ! echo "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Could not resolve a v<x>.<y>.<z> engine release (got: '$tag'); keeping the existing pin."
grep ENGINE_VERSION agentx/version.py
exit 0
fi
python - "$tag" <<'EOF'
import re, sys
tag = sys.argv[1]
with open('agentx/version.py') as f:
content = f.read()
new_content = re.sub(r'ENGINE_VERSION = "[^"]+"', f'ENGINE_VERSION = "{tag}"', content)
with open('agentx/version.py', 'w') as f:
f.write(new_content)
print(f'Pinned ENGINE_VERSION = {tag}')
EOF

- name: Get new version
id: version
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
Expand All @@ -69,7 +98,7 @@ jobs:
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add agentx/version.py
git commit -m "Bump version to ${{ steps.version.outputs.new_version }} [skip ci]"
git commit -m "Bump version to ${{ steps.version.outputs.new_version }} (engine pin refreshed) [skip ci]"
git push

- name: Wait for version bump commit and fetch latest
Expand Down
50 changes: 34 additions & 16 deletions agentx/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,21 @@
wrapping a Bun-compiled TypeScript engine, not Python). That compiled engine binary is tens of
megabytes; most `pip install agentx-python` installs are just this SDK talking to the hosted
AgentX SaaS and would never touch it, so it isn't bundled in this package. Instead, this command
downloads the matching release into ~/.agentx/bin the first time it's needed (mirroring
downloads the release into ~/.agentx/bin the first time it's needed (mirroring
AgentX-trace-eval's own install.sh) and then hands off to the real `agentx-server` binary.

The installed release tag is stamped in ~/.agentx/bin/.version. The launcher never silently
re-downloads, but it re-installs when you ask: `--update` fetches the newest release (engine +
dashboard), and setting AGENTX_TRACE_EVAL_VERSION to a tag other than the stamped one switches
to that release. When running "latest", a quick fail-open check against GitHub tells you if a
newer release exists.
Versioning: each SDK release pins the engine release it was tested against
(agentx.version.ENGINE_VERSION); the launcher installs that pin and converges to it - if the
stamped install (~/.agentx/bin/.version) differs from the pin, it re-installs, so upgrading the
SDK upgrades the engine to the matching pair. `--update` force-reinstalls the resolved version
(recovering a broken or pre-stamp install). Set AGENTX_TRACE_EVAL_VERSION to a tag or `latest`
to override the pin; in `latest` mode the launcher trusts whatever is installed, prints a
fail-open notice when GitHub has something newer, and `--update` fetches it.

Usage:
agentx-trace-eval --dev
agentx-trace-eval --update --dev
AGENTX_TRACE_EVAL_VERSION=latest agentx-trace-eval --update --dev
agentx-trace-eval --port 5000 --db-url postgres://...
"""

Expand All @@ -32,6 +35,8 @@

import requests

from agentx.version import ENGINE_VERSION

REPO = "AgentX-ai/AgentX-trace-eval"
INSTALL_DIR = Path(os.environ.get("AGENTX_INSTALL_DIR", str(Path.home() / ".agentx" / "bin")))
_BIN_NAMES = ("agentx", "agentx-server", "agentx-engine")
Expand Down Expand Up @@ -114,11 +119,17 @@ def _latest_release_tag() -> Optional[str]:
return None


def resolve_version() -> str:
"""The engine version this launcher should be running: the AGENTX_TRACE_EVAL_VERSION
override (a tag, or `latest`) when set, else the SDK's tested pin."""
return os.environ.get("AGENTX_TRACE_EVAL_VERSION") or ENGINE_VERSION


def _install(version: str = "latest") -> None:
os_name, arch = _platform_tag()
INSTALL_DIR.mkdir(parents=True, exist_ok=True)

print(f"agentx-trace-eval: downloading agentx ({os_name}/{arch})...", file=sys.stderr)
print(f"agentx-trace-eval: downloading agentx {version} ({os_name}/{arch})...", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)
archive = tmp_dir / "agentx.tar.gz"
Expand Down Expand Up @@ -178,23 +189,30 @@ def _install(version: str = "latest") -> None:
_extract_tar(web_archive, web_dir)


def ensure_installed(version: str = "latest", force: bool = False) -> Path:
"""Downloads agentx-server (+ its engine and dashboard) into ~/.agentx/bin when it's missing
def ensure_installed(version: Optional[str] = None, force: bool = False) -> Path:
"""Installs agentx-server (+ its engine and dashboard) into ~/.agentx/bin when it's missing
there, when `force` is set, or when `version` is a specific tag that differs from the
installed one. Returns the path to the agentx-server executable. Set AGENTX_INSTALL_DIR to
change where this looks/installs; set AGENTX_TRACE_EVAL_VERSION to pin a release tag."""
stamped install - the convergence that makes an SDK upgrade carry its tested engine along.
`version` defaults to resolve_version() (the AGENTX_TRACE_EVAL_VERSION override, else the
SDK's pin); pass "latest" to trust whatever is installed and only fetch when missing.
Returns the path to the agentx-server executable. Set AGENTX_INSTALL_DIR to change where
this looks/installs."""
if version is None:
version = resolve_version()
server_path = INSTALL_DIR / "agentx-server"
installed = _stamped_version()
pin_changed = version != "latest" and installed is not None and installed != version
if force or pin_changed or not server_path.exists():
stamped = _stamped_version()
pin_mismatch = version != "latest" and stamped != version
if force or pin_mismatch or not server_path.exists():
_install(version=version)
return server_path


def _maybe_print_update_notice(installed: Optional[str]) -> None:
"""Only relevant in `latest` mode, where nothing converges automatically - a quick fail-open
check tells the user when the world has moved on. (Pinned mode needs no notice: a pin
mismatch re-installs instead of nagging.)"""
latest = _latest_release_tag()
if installed is None:
# Pre-stamp install (or a wiped stamp): age unknown, so always point at --update.
print(
"agentx-trace-eval: installed engine version unknown"
+ (f" (newest release is {latest})" if latest else "")
Expand All @@ -215,7 +233,7 @@ def main() -> None:
# --update belongs to this launcher, not to agentx-server - consume it before the handoff.
args = [a for a in args if a != "--update"]

version = os.environ.get("AGENTX_TRACE_EVAL_VERSION", "latest")
version = resolve_version()
server_path = ensure_installed(version=version, force=force_update)
if not server_path.exists():
raise SystemExit(f"agentx-trace-eval: {server_path} still missing after install, giving up")
Expand Down
6 changes: 6 additions & 0 deletions agentx/version.py
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
VERSION = "0.8.8"

# The AgentX-trace-eval release this SDK version is tested against - what `agentx-trace-eval`
# installs and converges to (see agentx/cli.py). Bump together with VERSION when releasing, so
# every published SDK names a known-good engine+dashboard pair. Users can override with
# AGENTX_TRACE_EVAL_VERSION=<tag|latest>.
ENGINE_VERSION = "v0.3.1"
80 changes: 54 additions & 26 deletions tests/test_cli_launcher.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
"""The agentx-trace-eval launcher's staleness fixes: before these, ensure_installed downloaded
once and then ran that binary forever - a July install silently served a July engine months
later, with no version stamp, no way to ask for an update, and the dashboard fetched from
releases/latest independently of the engine (so the two halves of one install could skew)."""

import io
import sys
"""The agentx-trace-eval launcher's version pinning: each SDK release names the engine release
it was tested against (agentx.version.ENGINE_VERSION) and the launcher converges the local
install to it, so `pip install -U agentx-python` upgrades the engine to the matching pair.
Before this, the launcher downloaded "latest" once and then ran that binary forever - a July
install silently served a July engine months later, with no stamp and no way to update."""

import pytest

from agentx import cli
from agentx.version import ENGINE_VERSION


@pytest.fixture
Expand All @@ -24,49 +23,78 @@ def fake(version="latest"):
return fake


def test_engine_pin_is_a_release_tag():
# The pin the SDK ships must look like a real trace-eval release tag (v<series>.<n>), not
# "latest" - "latest" as a pin would silently disable the whole tested-pair contract.
assert cli.ENGINE_VERSION == ENGINE_VERSION
import re

assert re.fullmatch(r"v\d+\.\d+\.\d+", ENGINE_VERSION)


def test_resolve_version_prefers_env_override(monkeypatch):
monkeypatch.delenv("AGENTX_TRACE_EVAL_VERSION", raising=False)
assert cli.resolve_version() == ENGINE_VERSION
monkeypatch.setenv("AGENTX_TRACE_EVAL_VERSION", "latest")
assert cli.resolve_version() == "latest"
monkeypatch.setenv("AGENTX_TRACE_EVAL_VERSION", "v9.9.9")
assert cli.resolve_version() == "v9.9.9"


def test_tag_resolved_from_redirected_release_url():
url = "https://github.com/AgentX-ai/AgentX-trace-eval/releases/download/v0.3.0/agentx_darwin_arm64.tar.gz"
assert cli._tag_from_release_url(url) == "v0.3.0"
url = "https://github.com/AgentX-ai/AgentX-trace-eval/releases/download/v0.3.1/agentx_darwin_arm64.tar.gz"
assert cli._tag_from_release_url(url) == "v0.3.1"
assert cli._tag_from_release_url("https://example.com/not-a-release") is None


def test_existing_install_is_not_redownloaded(install_dir, monkeypatch):
def test_install_matching_pin_is_not_redownloaded(install_dir, monkeypatch):
(install_dir / "agentx-server").write_text("bin")
(install_dir / ".version").write_text(ENGINE_VERSION + "\n")
calls = []
monkeypatch.setattr(cli, "_install", _fake_install(calls))
cli.ensure_installed(version="latest")
cli.ensure_installed(version=ENGINE_VERSION)
assert calls == []


def test_force_reinstalls_over_existing(install_dir, monkeypatch):
def test_stamp_differing_from_pin_converges(install_dir, monkeypatch):
# The SDK-upgrade path: binaries exist but were installed for an older pin (or pre-stamp,
# where .version is missing entirely) - the launcher re-installs the pinned pair.
(install_dir / "agentx-server").write_text("bin")
(install_dir / ".version").write_text("v0.2.0\n")
calls = []
monkeypatch.setattr(cli, "_install", _fake_install(calls))
cli.ensure_installed(version="latest", force=True)
assert calls == ["latest"]
cli.ensure_installed(version=ENGINE_VERSION)
assert calls == [ENGINE_VERSION]

(install_dir / ".version").unlink()
calls.clear()
cli.ensure_installed(version=ENGINE_VERSION)
assert calls == [ENGINE_VERSION]


def test_changed_version_pin_reinstalls(install_dir, monkeypatch):
def test_latest_mode_trusts_existing_install(install_dir, monkeypatch):
(install_dir / "agentx-server").write_text("bin")
(install_dir / ".version").write_text("v0.2.0\n")
calls = []
monkeypatch.setattr(cli, "_install", _fake_install(calls))

# Same pin: no download. Different pin: download. "latest": trusts what's there.
cli.ensure_installed(version="v0.2.0")
assert calls == []
cli.ensure_installed(version="v0.3.0")
assert calls == ["v0.3.0"]
calls.clear()
cli.ensure_installed(version="latest")
assert calls == []


def test_force_reinstalls_over_existing(install_dir, monkeypatch):
(install_dir / "agentx-server").write_text("bin")
(install_dir / ".version").write_text(ENGINE_VERSION + "\n")
calls = []
monkeypatch.setattr(cli, "_install", _fake_install(calls))
cli.ensure_installed(version=ENGINE_VERSION, force=True)
assert calls == [ENGINE_VERSION]


def test_update_notice_names_both_versions(install_dir, monkeypatch, capsys):
monkeypatch.setattr(cli, "_latest_release_tag", lambda: "v0.9.0")
cli._maybe_print_update_notice("v0.2.0")
cli._maybe_print_update_notice("v0.3.1")
err = capsys.readouterr().err
assert "v0.2.0" in err and "v0.9.0" in err and "--update" in err
assert "v0.3.1" in err and "v0.9.0" in err and "--update" in err


def test_update_notice_handles_unstamped_install(install_dir, monkeypatch, capsys):
Expand All @@ -76,8 +104,8 @@ def test_update_notice_handles_unstamped_install(install_dir, monkeypatch, capsy


def test_update_notice_is_silent_when_current(install_dir, monkeypatch, capsys):
monkeypatch.setattr(cli, "_latest_release_tag", lambda: "v0.2.0")
cli._maybe_print_update_notice("v0.2.0")
monkeypatch.setattr(cli, "_latest_release_tag", lambda: "v0.3.1")
cli._maybe_print_update_notice("v0.3.1")
assert capsys.readouterr().err == ""


Expand Down
Loading