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
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion bindings/python/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "amplifier-core-py"
version = "1.4.0"
version = "1.4.1"
edition = "2021"
description = "PyO3 bridge for amplifier-core Rust kernel"
license = "MIT"
Expand Down
1 change: 1 addition & 0 deletions context/release-mandate.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,4 @@ there is no fast local rollback — users must wait for a fix.
| v1.2.3 | 2026-03-16 | `session_state` crash — missing dict field on RustCoordinator | CLI startup crashed | Yanked |
| v1.2.4 | 2026-03-16 | `_tool_dispatch_context` crash — RustCoordinator lacked `__dict__` | All tool dispatch crashed | Yanked |
| v1.2.4 | 2026-03-16 | Version files not bumped before tagging | PyPI publish rejected (400) | Re-tagged |
| v1.4.0 | 2026-04-25 | Validator imports leaked pytest as a runtime dep — `validation/structural/__init__.py` eagerly loaded test base classes whose top-level `import pytest` is undeclared in `pyproject.toml`. The 5 type validators imported `check_on_session_ready` from `.structural` (not `.base`), so a clean `pip install amplifier-core` failed `amplifier` startup with `ModuleNotFoundError: No module named 'pytest'`. The smoke test missed it because the pre-existing CLI install (Step 4) pulled pytest as a transitive dep, masking the missing runtime declaration. | All clean-env users hit ImportError at session init | Yanked. Hot-fix in v1.4.1: moved `check_on_session_ready` to `validation/base.py`, updated 5 validator imports, added pristine-import regression test (subprocess + `sys.modules` poisoning) and pristine-import preflight (Step 1b) in `e2e-smoke-test.sh` that imports the wheel into a bare `python:3.12-slim` before any deps pollute the env. |
2 changes: 1 addition & 1 deletion crates/amplifier-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "amplifier-core"
version = "1.4.0"
version = "1.4.1"
edition = "2021"
description = "Pure Rust kernel for the Amplifier modular AI agent system"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "amplifier-core"
version = "1.4.0"
version = "1.4.1"
description = "Rust kernel with Python bindings for the Amplifier modular AI agent framework"
license = "MIT"
readme = "README.md"
Expand Down
77 changes: 76 additions & 1 deletion python/amplifier_core/validation/base.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
"""
Base types for module validation.

Provides ValidationCheck and ValidationResult dataclasses used by all validators.
Provides ValidationCheck and ValidationResult dataclasses used by all validators,
plus structural-check helper functions that operate on imported module objects
without depending on the test-class hierarchy in ``validation.structural``.

NOTE: ``check_on_session_ready`` lives here (not in ``validation.structural``)
so the per-type validators can import it without pulling in the pytest-dependent
test base classes at module-load time. See incident #5 in
``context/release-mandate.md`` for the v1.4.0 regression that motivated this.
"""

import inspect
from dataclasses import dataclass
from dataclasses import field
from typing import Any
from typing import Literal


Expand Down Expand Up @@ -51,3 +60,69 @@ def summary(self) -> str:
passed_count = sum(1 for c in self.checks if c.passed)
status = "PASSED" if self.passed else "FAILED"
return f"{status}: {passed_count}/{len(self.checks)} checks passed ({len(self.errors)} errors, {len(self.warnings)} warnings)"


def check_on_session_ready(module: Any) -> ValidationCheck | None:
"""Check whether a module's on_session_ready() function, if present, is valid.

Validates:
1. Presence: returns None when on_session_ready is absent (no check needed).
2. Async: returns a failing ValidationCheck when on_session_ready exists but
is not async (must be ``async def``).
3. Arity (B5): returns a failing ValidationCheck when on_session_ready exists,
is async, but accepts no positional arguments — the coordinator argument
is required.

Args:
module: The imported module object to inspect.

Returns:
None if no issue found, or a ValidationCheck with passed=False describing
the first problem encountered.

Note:
This function lives in ``validation.base`` (not ``validation.structural``)
so that the per-type validators can import it without triggering the
pytest-dependent test base classes in ``validation.structural``. See
incident #5 in ``context/release-mandate.md`` for the v1.4.0 regression
that motivated this placement.
"""
fn = getattr(module, "on_session_ready", None)
if fn is None:
return None
if not inspect.iscoroutinefunction(fn):
return ValidationCheck(
name="on_session_ready_async",
passed=False,
message=(
"on_session_ready() must be async: found sync function. "
"Use 'async def on_session_ready(coordinator) -> None:'"
),
severity="error",
)
# B5 fix: validate arity — must accept at least one positional arg (coordinator)
try:
sig = inspect.signature(fn)
positional_params = [
p
for p in sig.parameters.values()
if p.kind
in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.POSITIONAL_ONLY,
)
and p.default is inspect.Parameter.empty
]
if len(positional_params) < 1:
return ValidationCheck(
name="on_session_ready_async",
passed=False,
message=(
"on_session_ready() must accept a coordinator argument: "
"async def on_session_ready(coordinator) -> None"
),
severity="error",
)
except (ValueError, TypeError):
pass # Can't inspect — let it pass; runtime will catch it
return None
2 changes: 1 addition & 1 deletion python/amplifier_core/validation/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from .base import ValidationCheck
from .base import ValidationResult
from .structural import check_on_session_ready
from .base import check_on_session_ready


def _implements_context_manager_interface(obj: Any) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion python/amplifier_core/validation/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from .base import ValidationCheck
from .base import ValidationResult
from .structural import check_on_session_ready
from .base import check_on_session_ready


def _implements_hook_handler_interface(obj: Any) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion python/amplifier_core/validation/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from .base import ValidationCheck
from .base import ValidationResult
from .structural import check_on_session_ready
from .base import check_on_session_ready


def _implements_orchestrator_interface(obj: Any) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion python/amplifier_core/validation/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from ..models import ProviderInfo
from .base import ValidationCheck
from .base import ValidationResult
from .structural import check_on_session_ready
from .base import check_on_session_ready


def _implements_provider_interface(obj: Any) -> bool:
Expand Down
69 changes: 6 additions & 63 deletions python/amplifier_core/validation/structural/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ class TestMyToolStructural(ToolStructuralTests):
- No duplication: Modules just inherit, no copy-paste
"""

import inspect
from typing import Any

from ..base import ValidationCheck
# Re-export ``check_on_session_ready`` from ``validation.base`` for backward
# compatibility. The function lives in ``base`` (not here) so that the per-type
# validators can import it without triggering the pytest-dependent test base
# classes below. See incident #5 in ``context/release-mandate.md`` for the
# v1.4.0 regression that motivated this placement.
from ..base import check_on_session_ready
from .test_context import ContextStructuralTests
from .test_hook import HookStructuralTests
from .test_orchestrator import OrchestratorStructuralTests
Expand All @@ -48,62 +50,3 @@ class TestMyToolStructural(ToolStructuralTests):
"ContextStructuralTests",
"check_on_session_ready",
]


def check_on_session_ready(module: Any) -> ValidationCheck | None:
"""Check whether a module's on_session_ready() function, if present, is valid.

Validates:
1. Presence: returns None when on_session_ready is absent (no check needed).
2. Async: returns a failing ValidationCheck when on_session_ready exists but
is not async (must be ``async def``).
3. Arity (B5): returns a failing ValidationCheck when on_session_ready exists,
is async, but accepts no positional arguments — the coordinator argument
is required.

Args:
module: The imported module object to inspect.

Returns:
None if no issue found, or a ValidationCheck with passed=False describing
the first problem encountered.
"""
fn = getattr(module, "on_session_ready", None)
if fn is None:
return None
if not inspect.iscoroutinefunction(fn):
return ValidationCheck(
name="on_session_ready_async",
passed=False,
message=(
"on_session_ready() must be async: found sync function. "
"Use 'async def on_session_ready(coordinator) -> None:'"
),
severity="error",
)
# B5 fix: validate arity — must accept at least one positional arg (coordinator)
try:
sig = inspect.signature(fn)
positional_params = [
p
for p in sig.parameters.values()
if p.kind
in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.POSITIONAL_ONLY,
)
and p.default is inspect.Parameter.empty
]
if len(positional_params) < 1:
return ValidationCheck(
name="on_session_ready_async",
passed=False,
message=(
"on_session_ready() must accept a coordinator argument: "
"async def on_session_ready(coordinator) -> None"
),
severity="error",
)
except (ValueError, TypeError):
pass # Can't inspect — let it pass; runtime will catch it
return None
2 changes: 1 addition & 1 deletion python/amplifier_core/validation/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from .base import ValidationCheck
from .base import ValidationResult
from .structural import check_on_session_ready
from .base import check_on_session_ready


def _implements_tool_interface(obj: Any) -> bool:
Expand Down
43 changes: 43 additions & 0 deletions scripts/e2e-smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,49 @@ WHEEL=$(ls "$WHEEL_DIR"/amplifier_core-*.whl 2>/dev/null | head -1)
[[ -z "$WHEEL" ]] && fail "No wheel found in $WHEEL_DIR/ — run without --skip-build first"
log "Using wheel: $(basename "$WHEEL")"

# ---------------------------------------------------------------------------
# Step 1b: Pristine-import preflight
# ---------------------------------------------------------------------------
# Catch the v1.4.0 class of bug: wheel requires a runtime dep not declared in
# pyproject.toml, but masked by transitive deps in the polluted CLI install
# environment from Steps 4–5. We install ONLY the wheel into a fresh
# python:3.12-slim image and verify the production import paths succeed.
#
# Required because Step 4's `uv tool install git+microsoft/amplifier@main`
# pulls a full dep closure (including pytest as a transitive) which can hide
# missing runtime deps. A clean end-user `pip install amplifier-core` doesn't
# get that pollution and would fail.
#
# See context/release-mandate.md Incident History #5 (v1.4.0 yank).

log "Pristine-import preflight: wheel must import on bare python:3.12-slim..."
WHEEL_BASENAME="$(basename "$WHEEL")"
docker run --rm \
-v "$WHEEL":"/tmp/${WHEEL_BASENAME}":ro \
-e WHEEL_BASENAME="$WHEEL_BASENAME" \
python:3.12-slim \
bash -c '
set -e
pip install -q "/tmp/${WHEEL_BASENAME}"
python -c "
import sys
# Defensive: poison pytest so any leak is detected even on images that happen to ship it
sys.modules[\"pytest\"] = None
from amplifier_core.validation import (
HookValidator, ToolValidator, OrchestratorValidator,
ProviderValidator, ContextValidator,
)
from amplifier_core.validation.base import check_on_session_ready
import amplifier_core._session_init # noqa: F401
import amplifier_core.loader # noqa: F401
import amplifier_core.coordinator # noqa: F401
import amplifier_core.hooks # noqa: F401
print(\"pristine import OK\")
"
' || fail "Pristine-import preflight failed — wheel has runtime dep not declared in pyproject.toml"

log "Pristine-import preflight passed."

# ---------------------------------------------------------------------------
# Step 2: Create container
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading