diff --git a/.github/workflows/framework-resolution-check.yml b/.github/workflows/framework-resolution-check.yml new file mode 100644 index 00000000..a74e5be8 --- /dev/null +++ b/.github/workflows/framework-resolution-check.yml @@ -0,0 +1,38 @@ +name: Framework resolution check + +# AAASM-4518 pre-publish guard: agent-assembly's runtime dependency floors must +# stay co-installable with the supported agent frameworks (AutoGen / CrewAI / +# Semantic Kernel). A floor raised above what a framework permits is a metadata-only +# break that only surfaces AFTER the immutable wheel is published to PyPI — this job +# turns that into a PR-time failure. See scripts/ci/check_framework_resolution.py. +on: + pull_request: + paths: + - "pyproject.toml" + - "uv.lock" + - "scripts/ci/check_framework_resolution.py" + - ".github/workflows/framework-resolution-check.yml" + push: + branches: [master] + paths: + - "pyproject.toml" + - "uv.lock" + - "scripts/ci/check_framework_resolution.py" + - ".github/workflows/framework-resolution-check.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + framework-floors-resolve: + name: Dependency floors co-resolve with supported frameworks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v7 + - name: Resolve agent-assembly floors against the framework matrix + run: python scripts/ci/check_framework_resolution.py --python-version 3.12 diff --git a/pyproject.toml b/pyproject.toml index 1a9b7012..cc7b0de0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,28 +24,40 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "pydantic>=2.13.4,<3.0.0", + # AAASM-4518: floor held at the true minimum 2.0 — the SDK only uses + # BaseModel/Field/model_dump, all stable since pydantic 2.0. AAASM-4434 raised + # this to >=2.13.4 by pinning the floor to the then-resolved version, which made + # agent-assembly un-co-installable with CrewAI (caps pydantic<2.13) and Semantic + # Kernel (caps <2.12) — a framework-compat regression vs rc.3. Keep the floor at + # what the code needs, not what the lockfile happened to resolve to. + "pydantic>=2.0.0,<3.0.0", "httpx>=0.27.0,<1.0.0", "typing-extensions>=4.16.0", # AAASM-1654 (PR-E): OpControlSubscriber consumes PolicyService.OpControlStream - # via gRPC. Held to grpcio's modern stable line. - "grpcio>=1.82.1,<2", - # AAASM-4434 (dependency completeness re-audit): resolves to 6.33.6, not raw - # PyPI latest (7.35.1), even though >=5,<8 permitted 7.x. Confirmed this is a - # REAL transitive ceiling, not a stale "permissive floor" resolution: the - # `dev`/test group's pydantic-ai>=2.9.0 pulls in - # `pydantic-ai-slim[...,logfire,...]`, and logfire (<=4.37.0) pins - # `opentelemetry-exporter-otlp-proto-http<1.43.0`; every otlp-proto-http - # release in that range depends on an `opentelemetry-proto` that caps - # `protobuf<7.0`. Verified by temporarily setting `protobuf>=7.35.1,<8` and - # re-running `uv lock`, which fails with a resolver conflict rooted at - # pydantic-ai's logfire extra; a bare `uv lock --upgrade` (no floor change) - # also leaves protobuf pinned at 6.33.6. Re-audit once logfire raises its - # opentelemetry-exporter-otlp-proto-http ceiling above 1.43.0. - # AAASM-4434 (manifest transparency follow-up): floor tightened from the - # wide-open "5" to the exact resolved 6.33.6 so the manifest states what is - # actually locked, not just the outer bound of what's permitted. - "protobuf>=6.33.6,<8", + # via gRPC. AAASM-4518: floor restored to rc.3's >=1.66. AAASM-4434 raised it to + # >=1.82.1 to mirror the then-resolved version — an over-tighten, not a real + # minimum required by the SDK. + "grpcio>=1.66,<2", + # AAASM-4518: floor restored to rc.3's >=5 so agent-assembly can co-install with + # AutoGen (autogen-core>=0.7.5 caps protobuf<5.30). AAASM-4434 raised this to + # >=6.33.6 by pinning the floor to the then-resolved version, which made rc.4 + # unresolvable with AutoGen — a framework-compat regression vs rc.3. + # + # CAVEAT (pre-existing, NOT introduced by this change): the checked-in gRPC stubs + # under agent_assembly/proto/ are protobuf gencode 6.31.1 and require a protobuf + # 6.x RUNTIME (>=6.31.1) to import — protobuf 5.x raises a VersionError. Those + # stubs are imported only by the optional OpControl gRPC transport + # (agent_assembly/op_control.py), never at package-import time, so mainstream + # framework quick-starts (which don't use that transport) run fine under protobuf + # 5.x — exactly as they did on rc.3, which also shipped 6.31.1 gencode under a + # >=5 floor since 2026-05-21. A user who needs the OpControl gRPC path must have + # protobuf>=6.31.1. Regenerating the stubs at 5.x gencode to make protobuf 5.x + # fully runtime-safe is a separate concern, not this compat fix. + # + # dev/test note: a bare `uv lock` still resolves protobuf to 6.33.6 because the + # dev-only pydantic-ai logfire extra caps protobuf<7.0 (via opentelemetry-proto); + # that transitive dev-time ceiling is independent of this runtime floor. + "protobuf>=5,<8", ] [project.optional-dependencies] diff --git a/scripts/ci/check_framework_resolution.py b/scripts/ci/check_framework_resolution.py new file mode 100644 index 00000000..272794af --- /dev/null +++ b/scripts/ci/check_framework_resolution.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Pre-publish guard: agent-assembly's dependency floors must co-resolve with the +supported agent frameworks. + +WHY this exists (AAASM-4518): a well-meaning "pin the floor to the resolved +version" bump (AAASM-4434) raised ``pydantic``/``protobuf`` floors above what +CrewAI, Semantic Kernel, and AutoGen permit. Because the floors are metadata, the +break only surfaced *after* the wheel was published to PyPI (rc.4 is immutable) — +downstream users could no longer ``pip install agent-assembly`` alongside their +framework. This guard turns that post-publish surprise into a PR-time failure. + +It resolves the SDK's *declared runtime floors* (read live from +``[project.dependencies]``) together with each framework at its current release +floor, using ``uv pip compile`` as an offline-metadata SAT solve. It never builds +or imports agent-assembly, so it is fast and runs on any platform. A floor that +excludes a framework's supported range makes the union unsatisfiable and fails the +guard, naming the conflicting constraint. + +Usage:: + + python scripts/ci/check_framework_resolution.py # check current tree + python scripts/ci/check_framework_resolution.py --python-version 3.12 + python scripts/ci/check_framework_resolution.py --pyproject path/to/pyproject.toml + +Exit code 0 = every framework co-resolves; 1 = at least one conflict (details +printed); 2 = harness error (uv missing, pyproject unreadable). +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import tempfile +import tomllib +from pathlib import Path + +# The frameworks agent-assembly ships quick-start examples for, each pinned at the +# CURRENT release floor. The floor matters: an unconstrained `framework` lets the +# resolver pick an ancient version that trivially resolves, hiding the very conflict +# we guard against. Bump these floors when a framework's supported release moves. +# (Mirrors the frameworks called out in AAASM-4518 / examples#268.) +FRAMEWORK_MATRIX: list[str] = [ + "autogen-core>=0.7.5", # caps protobuf<5.30 + "crewai>=1.15.2", # caps pydantic<2.13 + "semantic-kernel>=1.30", # caps pydantic<2.12 +] + +DEFAULT_PYTHON_VERSION = "3.12" # the SDK's minimum supported interpreter + + +def read_runtime_floors(pyproject: Path) -> list[str]: + """Return the ``[project.dependencies]`` specifiers verbatim from pyproject.toml.""" + with pyproject.open("rb") as fh: + data = tomllib.load(fh) + deps = data.get("project", {}).get("dependencies") + if not deps: + raise SystemExit(f"error: no [project.dependencies] found in {pyproject}") + # Drop self-referential extras (e.g. `agent-assembly[runtime]`) — they are not + # third-party floors and would force a build of the local project. + return [d for d in deps if not d.replace(" ", "").startswith("agent-assembly")] + + +def resolve(reqs: list[str], python_version: str) -> tuple[bool, str]: + """Try to resolve ``reqs`` together. Returns (ok, combined resolver output).""" + with tempfile.TemporaryDirectory() as tmp: + req_in = Path(tmp) / "req.in" + req_out = Path(tmp) / "req.txt" + req_in.write_text("\n".join(reqs) + "\n") + proc = subprocess.run( + [ + "uv", + "pip", + "compile", + str(req_in), + "--python-version", + python_version, + "--output-file", + str(req_out), + "--quiet", + "--no-header", + ], + capture_output=True, + text=True, + ) + return proc.returncode == 0, (proc.stderr + proc.stdout).strip() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--pyproject", + default=str(Path(__file__).resolve().parents[2] / "pyproject.toml"), + help="path to pyproject.toml (default: repo root)", + ) + parser.add_argument( + "--python-version", + default=DEFAULT_PYTHON_VERSION, + help=f"target interpreter for resolution (default: {DEFAULT_PYTHON_VERSION})", + ) + args = parser.parse_args() + + try: + floors = read_runtime_floors(Path(args.pyproject)) + except (OSError, tomllib.TOMLDecodeError) as exc: + print(f"error: cannot read {args.pyproject}: {exc}", file=sys.stderr) + return 2 + + print("agent-assembly runtime floors under test:") + for spec in floors: + print(f" {spec}") + print(f"target python: {args.python_version}\n") + + failures: list[tuple[str, str]] = [] + for framework in FRAMEWORK_MATRIX: + ok, output = resolve(floors + [framework], args.python_version) + status = "OK " if ok else "FAIL" + print(f"[{status}] agent-assembly floors + {framework}") + if not ok: + failures.append((framework, output)) + + if failures: + print("\nFramework resolution conflicts (a floor excludes a supported framework):") + for framework, output in failures: + print(f"\n--- {framework} ---\n{output}") + print( + "\nRelax the offending floor to the widest range the SDK actually " + "supports before publishing. See AAASM-4518." + ) + return 1 + + print("\nAll frameworks co-resolve with the current dependency floors.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index c29432db..5626737d 100644 --- a/uv.lock +++ b/uv.lock @@ -92,10 +92,10 @@ test = [ [package.metadata] requires-dist = [ { name = "agent-assembly", extras = ["runtime"], marker = "extra == 'all'" }, - { name = "grpcio", specifier = ">=1.82.1,<2" }, + { name = "grpcio", specifier = ">=1.66,<2" }, { name = "httpx", specifier = ">=0.27.0,<1.0.0" }, - { name = "protobuf", specifier = ">=6.33.6,<8" }, - { name = "pydantic", specifier = ">=2.13.4,<3.0.0" }, + { name = "protobuf", specifier = ">=5,<8" }, + { name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, { name = "typing-extensions", specifier = ">=4.16.0" }, ] provides-extras = ["runtime", "all"]