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.6.1"
version = "1.6.2"
edition = "2021"
description = "PyO3 bridge for amplifier-core Rust kernel"
license = "MIT"
Expand Down
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.6.1"
version = "1.6.2"
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.6.1"
version = "1.6.2"
description = "Rust kernel with Python bindings for the Amplifier modular AI agent framework"
license = "MIT"
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion python/amplifier_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
AmplifierSession`) still give the pure-Python implementations.
"""

__version__ = "1.6.1"
__version__ = "1.6.2"

# --- Rust-backed primary types (THE SWITCHOVER) ---
# These four were previously imported from their Python submodules.
Expand Down
83 changes: 51 additions & 32 deletions python/amplifier_core/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def __init__(
search_paths: Optional list of filesystem paths for direct discovery
"""
self._loaded_modules: dict[str, Any] = {}
self._loaded_module_paths: dict[str, Path] = {}
self._module_info: dict[str, ModuleInfo] = {}
self._search_paths = search_paths
self._coordinator = coordinator
Expand Down Expand Up @@ -204,17 +205,43 @@ async def load(
if module_id in self._loaded_modules:
logger.debug(f"Module '{module_id}' already loaded, creating fresh closure")
raw_fn = self._loaded_modules[module_id]
cached_source_path = self._loaded_module_paths.get(module_id)
source_resolver = None
if self._coordinator:
with contextlib.suppress(ValueError):
source_resolver = self._coordinator.get("module-source-resolver")
if cached_source_path is not None and self._coordinator:
if source_resolver is not None:
if hasattr(source_resolver, "async_resolve"):
source = await source_resolver.async_resolve(
module_id,
source_hint=source_hint,
profile_hint=source_hint,
)
else:
source = source_resolver.resolve(
module_id,
source_hint=source_hint,
profile_hint=source_hint,
)
requested_source_path = source.resolve().resolve()
if requested_source_path != cached_source_path:
raise ImportError(
f"Refusing to load '{module_id}' from {requested_source_path}: "
f"it is already loaded from {cached_source_path}"
)

async def mount_with_config_cached(
coordinator: ModuleCoordinator, fn=raw_fn
):
return await fn(coordinator, config or {})
if source_resolver is None or cached_source_path is not None:
async def mount_with_config_cached(
coordinator: ModuleCoordinator, fn=raw_fn
):
return await fn(coordinator, config or {})

# B1: propagate __on_session_ready__ to fresh closure
if on_sr := getattr(raw_fn, "__on_session_ready__", None):
setattr(mount_with_config_cached, "__on_session_ready__", on_sr)
# B1: propagate __on_session_ready__ to fresh closure
if on_sr := getattr(raw_fn, "__on_session_ready__", None):
setattr(mount_with_config_cached, "__on_session_ready__", on_sr)

return mount_with_config_cached
return mount_with_config_cached

try:
# Resolve module source
Expand Down Expand Up @@ -304,7 +331,9 @@ async def mount_with_config_cached(
)

# Validate module before loading (Python modules only at this point)
await self._validate_module(module_id, module_path, config=config)
package_path = await self._validate_module(
module_id, module_path, config=config
)

except Exception as resolve_error:
# Import here to avoid circular dependency
Expand All @@ -320,26 +349,13 @@ async def mount_with_config_cached(
return mount_fn
raise resolve_error

# Try to load via entry point first
raw_fn = self._load_entry_point(module_id)
if raw_fn:
self._loaded_modules[module_id] = raw_fn

async def mount_with_config_ep(
coordinator: ModuleCoordinator, fn=raw_fn
):
return await fn(coordinator, config or {})

# B1: propagate __on_session_ready__ to closure
if on_sr := getattr(raw_fn, "__on_session_ready__", None):
setattr(mount_with_config_ep, "__on_session_ready__", on_sr)

return mount_with_config_ep

# Try filesystem loading
raw_fn = self._load_filesystem(module_id)
# Source resolution selected and validated this filesystem package.
# Do not let an installed entry point for the same module id mount a
# different source after validation.
raw_fn = self._load_filesystem(module_id, module_name=package_path.name)
if raw_fn:
self._loaded_modules[module_id] = raw_fn
self._loaded_module_paths[module_id] = module_path.resolve()

async def mount_with_config_fs(
coordinator: ModuleCoordinator, fn=raw_fn
Expand Down Expand Up @@ -457,15 +473,17 @@ def _load_entry_point(self, module_id: str) -> Callable | None:

return None

def _load_filesystem(self, module_id: str) -> Callable | None:
def _load_filesystem(
self, module_id: str, module_name: str | None = None
) -> Callable | None:
"""Resolve module from filesystem and return the raw mount function.

Returns the raw (un-configured) mount function so callers can cache it
and wrap it in a fresh closure with the correct config on each use.
"""
try:
# Try to import the module
module_name = f"amplifier_module_{module_id.replace('-', '_')}"
module_name = module_name or f"amplifier_module_{module_id.replace('-', '_')}"
module = importlib.import_module(module_name)

# Detect on_session_ready lifecycle hook if present.
Expand Down Expand Up @@ -524,7 +542,7 @@ def _get_module_metadata(
package_path = self._find_package_dir(module_id, module_path)
if package_path:
# Import the module temporarily
module_name = f"amplifier_module_{module_id.replace('-', '_')}"
module_name = package_path.name

# Add to sys.path temporarily for import
path_str = str(module_path)
Expand Down Expand Up @@ -603,7 +621,7 @@ def _guess_from_naming(

async def _validate_module(
self, module_id: str, module_path: Path, config: dict[str, Any] | None = None
) -> None:
) -> Path:
"""
Validate a module before loading.

Expand Down Expand Up @@ -643,7 +661,7 @@ async def _validate_module(
logger.warning(
f"Unknown module type '{module_type}' for '{module_id}', skipping validation"
)
return
return module_path

# Find the actual Python package directory within the module root
# Module structure: amplifier-module-xyz/ contains amplifier_module_xyz/
Expand All @@ -664,6 +682,7 @@ async def _validate_module(
)

logger.info(f"[module:validated] {module_id} - {result.summary()}")
return package_path

def _find_package_dir(self, module_id: str, module_path: Path) -> Path | None:
"""
Expand Down
96 changes: 96 additions & 0 deletions python/amplifier_core/validation/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@
``context/release-mandate.md`` for the v1.4.0 regression that motivated this.
"""

import _imp
import importlib
import inspect
import sys
from dataclasses import dataclass
from dataclasses import field
from pathlib import Path
from types import ModuleType
from typing import Any
from typing import Literal

Expand Down Expand Up @@ -62,6 +67,97 @@ def summary(self) -> str:
return f"{status}: {passed_count}/{len(self.checks)} checks passed ({len(self.errors)} errors, {len(self.warnings)} warnings)"


def import_module_from_path(module_path: str | Path) -> ModuleType:
"""Import a Python source path through Python's normal import machinery.

Validation must use the canonical module object that runtime loading will
mount. If another source already owns the same package name, fail closed
rather than replacing entries in ``sys.modules`` while another importer can
observe them.
"""
path = Path(module_path)
source_path = path / "__init__.py" if path.is_dir() else path
module_name = path.name if path.is_dir() else path.stem

import_root = path.parent if path.is_dir() else source_path.parent
parent = str(import_root)
_imp.acquire_lock()
try:
# Audit children even when the matching root package is already loaded:
# a canonical root must not hide a child cached from a different source.
package_dir = source_path.parent.resolve()
for cached_name, cached_module in list(sys.modules.items()):
if not cached_name.startswith(f"{module_name}."):
continue
cached_file = getattr(cached_module, "__file__", None)
if cached_file is None or not Path(cached_file).resolve().is_relative_to(
package_dir
):
raise ImportError(
f"Refusing to import '{module_name}' from {source_path}: "
f"cached submodule '{cached_name}' is from {cached_file}"
)

existing = sys.modules.get(module_name)
if existing is not None:
existing_file = getattr(existing, "__file__", None)
if (
existing_file is not None
and Path(existing_file).resolve() == source_path.resolve()
):
return existing
raise ImportError(
f"Refusing to import '{module_name}' from {source_path}: "
f"it is already loaded from {existing_file}"
)

try:
original_path_index = sys.path.index(parent)
except ValueError:
original_path_index = None
next_path = (
sys.path[original_path_index + 1]
if original_path_index is not None
and original_path_index + 1 < len(sys.path)
else None
)
if original_path_index is None:
sys.path.insert(0, parent)
elif original_path_index != 0:
sys.path.pop(original_path_index)
sys.path.insert(0, parent)
try:
module = importlib.import_module(module_name)
finally:
current_path_index = next(
(index for index, value in enumerate(sys.path) if value == parent),
None,
)
if original_path_index is None:
if current_path_index is not None:
sys.path.pop(current_path_index)
elif original_path_index != 0 and current_path_index is not None:
sys.path.pop(current_path_index)
next_path_index = next(
(index for index, value in enumerate(sys.path) if value == next_path),
None,
)
if next_path_index is None:
sys.path.append(parent)
else:
sys.path.insert(next_path_index, parent)
finally:
_imp.release_lock()

imported_file = getattr(module, "__file__", None)
if imported_file is None or Path(imported_file).resolve() != source_path.resolve():
raise ImportError(
f"Refusing to validate '{module_name}' from {source_path}: "
f"Python imported {imported_file}"
)
return module


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

Expand Down
31 changes: 11 additions & 20 deletions python/amplifier_core/validation/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@

import asyncio
import importlib
import importlib.util
import inspect
from pathlib import Path
from typing import Any

from .base import ValidationCheck
from .base import ValidationResult
from .base import check_on_session_ready
from .base import import_module_from_path


def _implements_context_manager_interface(obj: Any) -> bool:
Expand Down Expand Up @@ -95,11 +95,7 @@ def _check_importable(
# File path - find the Python module
if path.is_dir():
init_file = path / "__init__.py"
if init_file.exists():
spec = importlib.util.spec_from_file_location(
path.name, init_file
)
else:
if not init_file.exists():
result.add(
ValidationCheck(
name="module_importable",
Expand All @@ -109,21 +105,16 @@ def _check_importable(
)
)
return None
else:
spec = importlib.util.spec_from_file_location(path.stem, path)

if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
result.add(
ValidationCheck(
name="module_importable",
passed=True,
message=f"Module loaded from {path}",
severity="info",
)
module = import_module_from_path(path)
result.add(
ValidationCheck(
name="module_importable",
passed=True,
message=f"Module loaded from {path}",
severity="info",
)
return module
)
return module
else:
# Module name - import directly
module = importlib.import_module(str(module_path))
Expand Down
Loading
Loading