ADAPTER_MODULE resolution required the imported module to export a class literally named `Adapter`. The claude-code, langgraph, and openclaw adapter-template repos (3 of 4 currently in production) don't ship that alias — they export ClaudeCodeAdapter / LangGraphAdapter / OpenClawAdapter directly. Only hermes has the `Adapter = HermesAdapter` shim at the bottom of adapter.py. Consequence in prod: every fresh claude-code / langgraph / openclaw workspace crashed at runtime startup with "module 'adapter' has no attribute 'Adapter'", even with a2a-sdk correctly pinned <1.0. Provisioning looked successful from CP's side (EC2 ran) but the agent never registered because the process never reached A2A bootstrap. Fix: if `Adapter` is absent from the imported module, scan the module for any attribute that is a proper BaseAdapter subclass (excluding BaseAdapter itself — regression guard in tests). The explicit alias remains the preferred contract; this is purely additive tolerance. Bump to 0.1.4 and publish to PyPI via the existing v* tag trigger. 6 new tests cover: explicit alias, subclass-fallback, non-adapter-noise ignored, empty module → error, missing module → error, re-exported BaseAdapter → not selected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
107 lines
4.1 KiB
Python
107 lines
4.1 KiB
Python
"""Adapter registry — discovers and loads agent infrastructure adapters."""
|
|
|
|
import importlib
|
|
import logging
|
|
import os
|
|
from .base import BaseAdapter, AdapterConfig, SetupResult
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_ADAPTER_CACHE: dict[str, type[BaseAdapter]] = {}
|
|
|
|
|
|
def discover_adapters() -> dict[str, type[BaseAdapter]]:
|
|
"""Scan subdirectories for adapter modules. Each must export an Adapter class.
|
|
|
|
This is used for local development inside the monorepo where adapters
|
|
live as subdirectories. In standalone adapter repos, use ADAPTER_MODULE
|
|
env var instead.
|
|
"""
|
|
if _ADAPTER_CACHE:
|
|
return _ADAPTER_CACHE
|
|
|
|
from pathlib import Path
|
|
adapters_dir = Path(__file__).parent
|
|
for entry in sorted(adapters_dir.iterdir()):
|
|
if not entry.is_dir() or entry.name.startswith("_"):
|
|
continue
|
|
try:
|
|
mod = importlib.import_module(f"molecule_runtime.adapters.{entry.name}")
|
|
adapter_cls = getattr(mod, "Adapter", None)
|
|
if adapter_cls and issubclass(adapter_cls, BaseAdapter):
|
|
_ADAPTER_CACHE[adapter_cls.name()] = adapter_cls
|
|
logger.debug(f"Loaded adapter: {adapter_cls.name()} ({adapter_cls.display_name()})")
|
|
except Exception as e:
|
|
# Log but don't crash — adapter may have uninstalled deps
|
|
logger.debug(f"Skipped adapter {entry.name}: {e}")
|
|
|
|
return _ADAPTER_CACHE
|
|
|
|
|
|
def get_adapter(runtime: str) -> type[BaseAdapter]:
|
|
"""Get adapter class by runtime name.
|
|
|
|
Resolution order:
|
|
1. ADAPTER_MODULE env var — used by standalone adapter repos to register
|
|
their adapter without modifying the runtime package.
|
|
2. Built-in discovery — scans subdirectories (for local monorepo dev).
|
|
|
|
Raises KeyError if the adapter cannot be found.
|
|
"""
|
|
# First check env override (standalone adapter repos set this)
|
|
adapter_module = os.environ.get("ADAPTER_MODULE")
|
|
if adapter_module:
|
|
try:
|
|
mod = importlib.import_module(adapter_module)
|
|
except Exception as e:
|
|
raise KeyError(
|
|
f"ADAPTER_MODULE={adapter_module!r} could not be imported: {e}"
|
|
) from e
|
|
|
|
# Resolution order inside the imported module:
|
|
# 1. An explicit `Adapter = XxxAdapter` alias (most ergonomic).
|
|
# 2. Any attribute that is a BaseAdapter subclass — unblocks
|
|
# standalone adapter repos whose author forgot to add the alias
|
|
# (e.g. claude-code, langgraph, openclaw templates pre-2026-04-20).
|
|
# Without this fallback, a perfectly valid ClaudeCodeAdapter
|
|
# class in the module can't be loaded and provisioning fails at
|
|
# runtime with "module 'adapter' has no attribute 'Adapter'".
|
|
cls = getattr(mod, "Adapter", None)
|
|
if cls is None:
|
|
for name in dir(mod):
|
|
if name.startswith("_"):
|
|
continue
|
|
obj = getattr(mod, name, None)
|
|
if isinstance(obj, type) and obj is not BaseAdapter and issubclass(obj, BaseAdapter):
|
|
cls = obj
|
|
break
|
|
if cls is not None and issubclass(cls, BaseAdapter):
|
|
return cls
|
|
raise KeyError(
|
|
f"ADAPTER_MODULE={adapter_module!r} imported but no BaseAdapter subclass found"
|
|
)
|
|
|
|
# Fall back to built-in discovery (for local dev / monorepo)
|
|
adapters = discover_adapters()
|
|
if runtime not in adapters:
|
|
available = ", ".join(sorted(adapters.keys()))
|
|
raise KeyError(f"Unknown runtime '{runtime}'. Available: {available}")
|
|
return adapters[runtime]
|
|
|
|
|
|
def list_adapters() -> list[dict]:
|
|
"""Return metadata for all discovered adapters (for API/UI)."""
|
|
adapters = discover_adapters()
|
|
return [
|
|
{
|
|
"name": cls.name(),
|
|
"display_name": cls.display_name(),
|
|
"description": cls.description(),
|
|
"config_schema": cls.get_config_schema(),
|
|
}
|
|
for cls in adapters.values()
|
|
]
|
|
|
|
|
|
__all__ = ["BaseAdapter", "AdapterConfig", "SetupResult", "get_adapter", "list_adapters", "discover_adapters"]
|