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
130 changes: 98 additions & 32 deletions agent_assembly/__init__.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,42 @@
"""Agent Assembly Python SDK."""

from agent_assembly.adapters import FrameworkAdapter, GovernanceInterceptor
from agent_assembly.core import AssemblyContext, init_assembly
from agent_assembly.exceptions import (
AdapterValidationError,
AgentError,
AssemblyError,
ConfigurationError,
GatewayError,
MCPToolBlockedError,
PolicyError,
ToolExecutionBlockedError,
)
from agent_assembly.types import AuditEvent, CallStackNode, CallStackNodeKind

try:
from agent_assembly._core import (
GovernanceEvent,
PolicyResult,
PolicyTimeoutError,
RuntimeClient,
)
except ImportError:
pass
from __future__ import annotations

import contextlib
import importlib
import importlib.util
import sys
from typing import TYPE_CHECKING, Any

__version__ = "0.0.0"

__all__ = [
# AAASM-1696: top-level exports are resolved lazily so that lightweight
# submodules (e.g. `agent_assembly.runtime`, which is stdlib-only) can be
# imported without dragging in the SDK's third-party dependency surface
# (`httpx`, `pydantic`, …). See PEP 562.
_LAZY_EXPORTS: dict[str, str] = {
"init_assembly": "agent_assembly.core",
"AssemblyContext": "agent_assembly.core",
"GovernanceInterceptor": "agent_assembly.adapters",
"FrameworkAdapter": "agent_assembly.adapters",
"AssemblyError": "agent_assembly.exceptions",

Check failure on line 22 in agent_assembly/__init__.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "agent_assembly.exceptions" 8 times.

See more on https://sonarcloud.io/project/issues?id=AI-agent-assembly_python-sdk&issues=AZ5JmVDThGXS72Ac8qEV&open=AZ5JmVDThGXS72Ac8qEV&pullRequest=51
"AgentError": "agent_assembly.exceptions",
"PolicyError": "agent_assembly.exceptions",
"GatewayError": "agent_assembly.exceptions",
"ConfigurationError": "agent_assembly.exceptions",
"AdapterValidationError": "agent_assembly.exceptions",
"ToolExecutionBlockedError": "agent_assembly.exceptions",
"MCPToolBlockedError": "agent_assembly.exceptions",
"AuditEvent": "agent_assembly.types",

Check failure on line 30 in agent_assembly/__init__.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "agent_assembly.types" 3 times.

See more on https://sonarcloud.io/project/issues?id=AI-agent-assembly_python-sdk&issues=AZ5JmVDThGXS72Ac8qET&open=AZ5JmVDThGXS72Ac8qET&pullRequest=51
"CallStackNode": "agent_assembly.types",
"CallStackNodeKind": "agent_assembly.types",
"GovernanceEvent": "agent_assembly._core",

Check failure on line 33 in agent_assembly/__init__.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "agent_assembly._core" 7 times.

See more on https://sonarcloud.io/project/issues?id=AI-agent-assembly_python-sdk&issues=AZ5JmVDThGXS72Ac8qEU&open=AZ5JmVDThGXS72Ac8qEU&pullRequest=51
"PolicyResult": "agent_assembly._core",
"PolicyTimeoutError": "agent_assembly._core",
"RuntimeClient": "agent_assembly._core",
}

_ALWAYS_EXPORTED: list[str] = [
"__version__",
"init_assembly",
"AssemblyContext",
Expand All @@ -45,12 +55,68 @@
"CallStackNodeKind",
]

if "RuntimeClient" in globals():
__all__.extend(
[
"RuntimeClient",
"GovernanceEvent",
"PolicyResult",
"PolicyTimeoutError",
]
_OPTIONAL_CORE: list[str] = [
"RuntimeClient",
"GovernanceEvent",
"PolicyResult",
"PolicyTimeoutError",
]


def _core_available() -> bool:
if "agent_assembly._core" in sys.modules:
return True
try:
return importlib.util.find_spec("agent_assembly._core") is not None
except (ModuleNotFoundError, ValueError):
return False


__all__: list[str] = list(_ALWAYS_EXPORTED)
if _core_available():
__all__.extend(_OPTIONAL_CORE)


def __getattr__(name: str) -> Any:
module_name = _LAZY_EXPORTS.get(name)
if module_name is None:
raise AttributeError(f"module 'agent_assembly' has no attribute {name!r}")
try:
module = importlib.import_module(module_name)
except ImportError:
if module_name == "agent_assembly._core":
raise AttributeError(
f"module 'agent_assembly' has no attribute {name!r}: the native '_core' extension is not built"
) from None
raise
value = getattr(module, name)
globals()[name] = value
return value


def __dir__() -> list[str]:
return sorted(set(__all__) | set(globals()))


if TYPE_CHECKING:
from agent_assembly.adapters import FrameworkAdapter, GovernanceInterceptor
from agent_assembly.core import AssemblyContext, init_assembly
from agent_assembly.exceptions import (
AdapterValidationError,
AgentError,
AssemblyError,
ConfigurationError,
GatewayError,
MCPToolBlockedError,
PolicyError,
ToolExecutionBlockedError,
)
from agent_assembly.types import AuditEvent, CallStackNode, CallStackNodeKind

with contextlib.suppress(ImportError):
from agent_assembly._core import (
GovernanceEvent,
PolicyResult,
PolicyTimeoutError,
RuntimeClient,
)
100 changes: 100 additions & 0 deletions test/unit/test_runtime_import_isolation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""AAASM-1696: agent_assembly.runtime must be importable without httpx/pydantic.

Regression test for the eager-import bug in `agent_assembly/__init__.py` that
broke aa-integration-tests::e2e_sdk_runtime_lifecycle::python_binary_in_path_returns_resolved_path
(agent-assembly run 26211782822, both ubuntu-latest and macos-latest jobs).
"""

from __future__ import annotations

import subprocess
import sys
import textwrap
from pathlib import Path

PROJECT_ROOT = Path(__file__).resolve().parents[2]


def _run_python_with_blocked_imports(blocked: list[str], code: str) -> subprocess.CompletedProcess[str]:
"""Run ``code`` in a child interpreter where ``blocked`` modules raise on import."""
block_literal = repr(blocked)
wrapper = (
textwrap.dedent(
f"""
import sys
_BLOCKED = {block_literal}

class _BlockingFinder:
def find_spec(self, name, path=None, target=None):
root = name.split(".", 1)[0]
if root in _BLOCKED:
raise ModuleNotFoundError(f"No module named {{name!r}} (blocked by test)")
return None

sys.meta_path.insert(0, _BlockingFinder())
"""
).strip()
+ "\n"
+ code
)
return subprocess.run(
[sys.executable, "-c", wrapper],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
check=False,
)


def test_runtime_import_does_not_pull_in_httpx() -> None:
result = _run_python_with_blocked_imports(
["httpx", "pydantic"],
"from agent_assembly.runtime import find_aasm_binary, init_assembly, is_running\nprint('ok')\n",
)
assert result.returncode == 0, (
f"agent_assembly.runtime should not require httpx/pydantic.\nstdout: {result.stdout}\nstderr: {result.stderr}"
)
assert result.stdout.strip() == "ok"


def test_top_level_package_import_does_not_pull_in_httpx() -> None:
result = _run_python_with_blocked_imports(
["httpx", "pydantic"],
"import agent_assembly\nprint(agent_assembly.__version__)\n",
)
assert result.returncode == 0, (
f"`import agent_assembly` must not eagerly import httpx/pydantic.\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
assert result.stdout.strip() == "0.0.0"


def test_eager_attribute_access_still_resolves_through_lazy_loader() -> None:
result = subprocess.run(
[
sys.executable,
"-c",
"import agent_assembly\n"
"_ = agent_assembly.init_assembly\n"
"_ = agent_assembly.AssemblyError\n"
"_ = agent_assembly.AuditEvent\n"
"print('ok')\n",
],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"
assert result.stdout.strip() == "ok"


def test_unknown_attribute_raises_attribute_error() -> None:
import agent_assembly

try:
agent_assembly.does_not_exist # noqa: B018
except AttributeError as exc:
assert "does_not_exist" in str(exc)
else:
raise AssertionError("expected AttributeError for unknown attribute")