diff --git a/agent_assembly/__init__.py b/agent_assembly/__init__.py index b038b1a..eaf5edd 100644 --- a/agent_assembly/__init__.py +++ b/agent_assembly/__init__.py @@ -1,7 +1,9 @@ """Agent Assembly Python SDK.""" +from agent_assembly.adapters import FrameworkAdapter, GovernanceInterceptor from agent_assembly.core import init_assembly from agent_assembly.exceptions import ( + AdapterValidationError, AgentError, AssemblyError, ConfigurationError, @@ -14,9 +16,12 @@ __all__ = [ "__version__", "init_assembly", + "GovernanceInterceptor", + "FrameworkAdapter", "AssemblyError", "AgentError", "PolicyError", "GatewayError", "ConfigurationError", -] \ No newline at end of file + "AdapterValidationError", +] diff --git a/agent_assembly/adapters/__init__.py b/agent_assembly/adapters/__init__.py new file mode 100644 index 0000000..c16f448 --- /dev/null +++ b/agent_assembly/adapters/__init__.py @@ -0,0 +1,3 @@ +from agent_assembly.adapters.base import FrameworkAdapter, GovernanceInterceptor + +__all__ = ["GovernanceInterceptor", "FrameworkAdapter"] diff --git a/agent_assembly/adapters/base.py b/agent_assembly/adapters/base.py new file mode 100644 index 0000000..ca06325 --- /dev/null +++ b/agent_assembly/adapters/base.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +import importlib +from typing import Protocol + +from agent_assembly.exceptions import AdapterValidationError + + +class GovernanceInterceptor(Protocol): + """Protocol implemented by governance interceptors used by adapters.""" + + pass + + +class FrameworkAdapter(ABC): + """Abstract contract implemented by every framework adapter. + + Adapters should be registered through `register()` so contract validation + errors are raised before framework hooks are attached. + """ + + @abstractmethod + def get_framework_name(self) -> str: + """Return the canonical importable framework package name. + + Error conditions: + - Empty or whitespace-only names trigger `AdapterValidationError` + during `register()`. + """ + + ... + + @abstractmethod + def get_supported_versions(self) -> list[str]: + """Return supported semantic version ranges for the framework. + + Error conditions: + - Empty lists or empty range strings trigger `AdapterValidationError` + during `register()`. + """ + + ... + + @abstractmethod + def register_hooks(self, interceptor: GovernanceInterceptor) -> None: + """Attach framework hooks to a governance interceptor instance. + + Error conditions: + - Framework-specific hook wiring failures should raise adapter-specific + exceptions for the caller to handle. + """ + + ... + + @abstractmethod + def unregister_hooks(self) -> None: + """Detach all framework hooks in an idempotent way. + + Error conditions: + - This method should avoid raising when no hooks are currently active. + """ + + ... + + def validate_registration(self) -> None: + """Validate adapter contract values before hook registration. + + Raises: + AdapterValidationError: If the framework name or version ranges + violate the base adapter contract. + """ + framework_name = self.get_framework_name() + if not framework_name.strip(): + raise AdapterValidationError( + "Adapter contract invalid: framework name must be non-empty." + ) + + supported_versions = self.get_supported_versions() + if not supported_versions: + raise AdapterValidationError( + "Adapter contract invalid: supported versions must not be empty." + ) + + for version_range in supported_versions: + if not version_range.strip(): + raise AdapterValidationError( + "Adapter contract invalid: version ranges must be non-empty strings." + ) + + def register(self, interceptor: GovernanceInterceptor) -> None: + """Validate contract values and then attach framework hooks. + + Raises: + AdapterValidationError: If `validate_registration()` fails. + """ + self.validate_registration() + self.register_hooks(interceptor) + + def is_available(self) -> bool: + """Return True when the framework package can be imported. + + Error conditions: + - Import failures are handled internally and return `False`. + """ + + try: + importlib.import_module(self.get_framework_name()) + except ImportError: + return False + + return True + + def get_active_version(self) -> str | None: + """Return framework `__version__` when present, otherwise `None`. + + Error conditions: + - Import failures and missing/non-string `__version__` values return + `None`. + """ + + try: + module = importlib.import_module(self.get_framework_name()) + except ImportError: + return None + + version = getattr(module, "__version__", None) + if isinstance(version, str): + return version + + return None diff --git a/agent_assembly/exceptions/__init__.py b/agent_assembly/exceptions/__init__.py index 14886a3..b973d5d 100644 --- a/agent_assembly/exceptions/__init__.py +++ b/agent_assembly/exceptions/__init__.py @@ -2,6 +2,15 @@ from __future__ import annotations +__all__ = [ + "AssemblyError", + "AgentError", + "PolicyError", + "GatewayError", + "ConfigurationError", + "AdapterValidationError", +] + class AssemblyError(Exception): """Base exception for Agent Assembly SDK errors.""" @@ -26,3 +35,8 @@ class GatewayError(AssemblyError): class ConfigurationError(AssemblyError): """Exception raised for configuration errors.""" pass + + +class AdapterValidationError(AssemblyError): + """Exception raised when an adapter contract is invalid.""" + pass diff --git a/mypy.ini b/mypy.ini index 9d88229..70c6cf5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -20,3 +20,6 @@ warn_unused_ignores = True ;no_implicit_reexport = True strict_equality = True strict_concatenate = True + +[mypy-agent_assembly.adapters.base] +strict = True diff --git a/test/unit/adapters/__init__.py b/test/unit/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/adapters/test_base.py b/test/unit/adapters/test_base.py new file mode 100644 index 0000000..d32ce31 --- /dev/null +++ b/test/unit/adapters/test_base.py @@ -0,0 +1,172 @@ +import pytest +from types import SimpleNamespace + +from agent_assembly.adapters import FrameworkAdapter, GovernanceInterceptor +from agent_assembly.exceptions import AdapterValidationError + + +class IncompleteAdapter(FrameworkAdapter): + def get_framework_name(self) -> str: + return "math" + + +def test_framework_adapter_requires_all_abstract_methods() -> None: + with pytest.raises(TypeError): + IncompleteAdapter() + + +class AvailableFrameworkAdapter(FrameworkAdapter): + def get_framework_name(self) -> str: + return "math" + + def get_supported_versions(self) -> list[str]: + return [] + + def register_hooks(self, interceptor: GovernanceInterceptor) -> None: + return None + + def unregister_hooks(self) -> None: + return None + + +def test_is_available_returns_true_when_framework_exists() -> None: + assert AvailableFrameworkAdapter().is_available() is True + + +class MissingFrameworkAdapter(FrameworkAdapter): + def get_framework_name(self) -> str: + return "_agent_assembly_missing_framework_" + + def get_supported_versions(self) -> list[str]: + return [] + + def register_hooks(self, interceptor: GovernanceInterceptor) -> None: + return None + + def unregister_hooks(self) -> None: + return None + + +def test_is_available_returns_false_when_framework_is_missing() -> None: + assert MissingFrameworkAdapter().is_available() is False + + +class VersionedFrameworkAdapter(FrameworkAdapter): + def get_framework_name(self) -> str: + return "pytest" + + def get_supported_versions(self) -> list[str]: + return [] + + def register_hooks(self, interceptor: GovernanceInterceptor) -> None: + return None + + def unregister_hooks(self) -> None: + return None + + +def test_get_active_version_returns_module_version() -> None: + assert VersionedFrameworkAdapter().get_active_version() is not None + + +class NonVersionedFrameworkAdapter(FrameworkAdapter): + def get_framework_name(self) -> str: + return "math" + + def get_supported_versions(self) -> list[str]: + return [] + + def register_hooks(self, interceptor: GovernanceInterceptor) -> None: + return None + + def unregister_hooks(self) -> None: + return None + + +def test_get_active_version_returns_none_without_version() -> None: + assert NonVersionedFrameworkAdapter().get_active_version() is None + + +class InvalidRegistrationAdapter(FrameworkAdapter): + def get_framework_name(self) -> str: + return " " + + def get_supported_versions(self) -> list[str]: + return [] + + def register_hooks(self, interceptor: GovernanceInterceptor) -> None: + return None + + def unregister_hooks(self) -> None: + return None + + +def test_register_raises_validation_error_for_invalid_contract() -> None: + with pytest.raises(AdapterValidationError): + InvalidRegistrationAdapter().register(object()) + + +class ValidRegistrationAdapter(FrameworkAdapter): + def __init__(self) -> None: + self.hooks_registered = False + + def get_framework_name(self) -> str: + return "pytest" + + def get_supported_versions(self) -> list[str]: + return [">=8.0.0,<9.0.0"] + + def register_hooks(self, interceptor: GovernanceInterceptor) -> None: + self.hooks_registered = True + + def unregister_hooks(self) -> None: + return None + + +def test_register_calls_register_hooks_when_contract_is_valid() -> None: + adapter = ValidRegistrationAdapter() + adapter.register(object()) + assert adapter.hooks_registered is True + + +class LangChainFrameworkAdapter(FrameworkAdapter): + def get_framework_name(self) -> str: + return "langchain" + + def get_supported_versions(self) -> list[str]: + return [">=0.1.0,<0.4.0"] + + def register_hooks(self, interceptor: GovernanceInterceptor) -> None: + return None + + def unregister_hooks(self) -> None: + return None + + +def test_is_available_returns_true_for_langchain(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + + def fake_import_module(module_name: str) -> object: + calls.append(module_name) + if module_name == "langchain": + return SimpleNamespace(__version__="0.3.0") + raise ImportError + + monkeypatch.setattr("agent_assembly.adapters.base.importlib.import_module", fake_import_module) + + adapter = LangChainFrameworkAdapter() + assert adapter.is_available() is True + assert calls == ["langchain"] + + +def test_get_active_version_returns_langchain_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fake_import_module(module_name: str) -> object: + if module_name == "langchain": + return SimpleNamespace(__version__="0.3.0") + raise ImportError + + monkeypatch.setattr("agent_assembly.adapters.base.importlib.import_module", fake_import_module) + + assert LangChainFrameworkAdapter().get_active_version() == "0.3.0"