From a0bcdb00a2e87ecc2e4e70bc517fbcae425c1389 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:02:22 +0800 Subject: [PATCH 01/23] =?UTF-8?q?=F0=9F=93=A6=20(adapters):=20Create=20emp?= =?UTF-8?q?ty=20registry=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/registry.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 agent_assembly/adapters/registry.py diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py new file mode 100644 index 0000000..e69de29 From a44d5c4d5e85260d0b2a033cef71ddade0cd3bee Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:02:27 +0800 Subject: [PATCH 02/23] =?UTF-8?q?=F0=9F=93=A6=20(tests):=20Create=20empty?= =?UTF-8?q?=20registry=20test=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/adapters/test_registry.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 test/unit/adapters/test_registry.py diff --git a/test/unit/adapters/test_registry.py b/test/unit/adapters/test_registry.py new file mode 100644 index 0000000..e69de29 From 3ca42606b405d4b3866c65ffdcdd2d1400ffbbac Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:02:36 +0800 Subject: [PATCH 03/23] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Add=20AdapterIn?= =?UTF-8?q?fo=20dataclass=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/registry.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index e69de29..825d047 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +@dataclass(frozen=True, slots=True) +class AdapterInfo: + name: str + version: str + status: Literal["active", "error"] + hooks_registered: int From db0737fd3c04469743dde221e35f533121035787 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:02:44 +0800 Subject: [PATCH 04/23] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20(adapters):=20Add?= =?UTF-8?q?=20AdapterRegistry=20class=20shell=20with=20lock=20and=20state?= =?UTF-8?q?=20maps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/registry.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index 825d047..e232fae 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -1,8 +1,11 @@ from __future__ import annotations from dataclasses import dataclass +from threading import Lock from typing import Literal +from agent_assembly.adapters.base import FrameworkAdapter + @dataclass(frozen=True, slots=True) class AdapterInfo: @@ -10,3 +13,10 @@ class AdapterInfo: version: str status: Literal["active", "error"] hooks_registered: int + + +class AdapterRegistry: + def __init__(self) -> None: + self._lock = Lock() + self._registered: dict[str, FrameworkAdapter] = {} + self._active: dict[str, FrameworkAdapter] = {} From 811dc9b45f21d05739952d1ce09da3cf784932e4 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:03:00 +0800 Subject: [PATCH 05/23] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Add=20built-in?= =?UTF-8?q?=20framework=20placeholder=20adapter=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/registry.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index e232fae..18641e6 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -15,6 +15,23 @@ class AdapterInfo: hooks_registered: int +class _BuiltinPlaceholderAdapter(FrameworkAdapter): + def __init__(self, framework_name: str) -> None: + self._framework_name = framework_name + + def get_framework_name(self) -> str: + return self._framework_name + + def get_supported_versions(self) -> list[str]: + return [">=0.0.0"] + + def register_hooks(self, interceptor: object) -> None: + return None + + def unregister_hooks(self) -> None: + return None + + class AdapterRegistry: def __init__(self) -> None: self._lock = Lock() From c8cbe8399e3fdea5128cffebfdb5d83d0caa884b Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:03:11 +0800 Subject: [PATCH 06/23] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Pre-register=20?= =?UTF-8?q?built-in=20adapters=20in=20AdapterRegistry.=5F=5Finit=5F=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/registry.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index 18641e6..81f6e39 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -37,3 +37,5 @@ def __init__(self) -> None: self._lock = Lock() self._registered: dict[str, FrameworkAdapter] = {} self._active: dict[str, FrameworkAdapter] = {} + for framework_name in ("langchain", "langgraph", "crewai", "pydantic_ai"): + self._registered[framework_name] = _BuiltinPlaceholderAdapter(framework_name) From 9dfb6e8581b1351e528174d9d9f0f5c855961382 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:03:20 +0800 Subject: [PATCH 07/23] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Add=20register(?= =?UTF-8?q?)=20with=20replacement-safe=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/registry.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index 81f6e39..e4c4d9c 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -39,3 +39,10 @@ def __init__(self) -> None: self._active: dict[str, FrameworkAdapter] = {} for framework_name in ("langchain", "langgraph", "crewai", "pydantic_ai"): self._registered[framework_name] = _BuiltinPlaceholderAdapter(framework_name) + + def register(self, adapter: FrameworkAdapter) -> None: + adapter_name = adapter.get_framework_name() + with self._lock: + self._registered[adapter_name] = adapter + if adapter_name in self._active and self._active[adapter_name] is not adapter: + self._active.pop(adapter_name, None) From f40f596b82ef511add8554feaad348ab17fef7d3 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:03:33 +0800 Subject: [PATCH 08/23] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Add=20unregiste?= =?UTF-8?q?r()=20with=20safe=20deactivation=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/registry.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index e4c4d9c..0e938bc 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -46,3 +46,11 @@ def register(self, adapter: FrameworkAdapter) -> None: self._registered[adapter_name] = adapter if adapter_name in self._active and self._active[adapter_name] is not adapter: self._active.pop(adapter_name, None) + + def unregister(self, name: str) -> None: + with self._lock: + active_adapter = self._active.pop(name, None) + self._registered.pop(name, None) + + if active_adapter is not None: + active_adapter.unregister_hooks() From 9d546f6e7a59d035ff66c9587cf7102502d826d7 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:03:48 +0800 Subject: [PATCH 09/23] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Add=20list=5Fac?= =?UTF-8?q?tive()=20real-time=20state=20projection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/registry.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index 0e938bc..171ef3b 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -54,3 +54,24 @@ def unregister(self, name: str) -> None: if active_adapter is not None: active_adapter.unregister_hooks() + + def list_active(self) -> list[AdapterInfo]: + with self._lock: + active_items = list(self._active.items()) + + result: list[AdapterInfo] = [] + for name, adapter in active_items: + hooks_registered = getattr(adapter, "_hooks_registered_count", 0) + if not isinstance(hooks_registered, int): + hooks_registered = 0 + + result.append( + AdapterInfo( + name=name, + version=adapter.get_active_version() or "", + status="active", + hooks_registered=hooks_registered, + ) + ) + + return sorted(result, key=lambda info: info.name) From b29310fca72fdc68990c387c8f2cd75ce9eb396e Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:04:02 +0800 Subject: [PATCH 10/23] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Add=20entry-poi?= =?UTF-8?q?nt=20discovery=20for=20group=20agent=5Fassembly.adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/registry.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index 171ef3b..2169b31 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from importlib import metadata from threading import Lock from typing import Literal @@ -75,3 +76,22 @@ def list_active(self) -> list[AdapterInfo]: ) return sorted(result, key=lambda info: info.name) + + def _discover_entry_point_adapters(self) -> list[str]: + discovered: list[str] = [] + entry_points = metadata.entry_points() + adapter_entry_points = entry_points.select(group="agent_assembly.adapters") + + for entry_point in adapter_entry_points: + loaded = entry_point.load() + if not isinstance(loaded, type): + continue + + if not issubclass(loaded, FrameworkAdapter): + continue + + adapter = loaded() + self.register(adapter) + discovered.append(adapter.get_framework_name()) + + return discovered From c197d131eb1352543b5ff6aba6bc14d24ad29b32 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:04:20 +0800 Subject: [PATCH 11/23] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Add=20auto=5Fde?= =?UTF-8?q?tect()=20activation=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/registry.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index 2169b31..d15aab4 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -95,3 +95,21 @@ def _discover_entry_point_adapters(self) -> list[str]: discovered.append(adapter.get_framework_name()) return discovered + + def auto_detect(self) -> list[str]: + self._discover_entry_point_adapters() + + with self._lock: + registered_items = list(self._registered.items()) + + activated: list[str] = [] + for name, adapter in registered_items: + if not adapter.is_available(): + continue + + adapter.register(object()) + with self._lock: + self._active[name] = adapter + activated.append(name) + + return activated From fc093c6d835d6b1df766d517225595e82c4ee8ad Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:04:32 +0800 Subject: [PATCH 12/23] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(adapters):=20Make?= =?UTF-8?q?=20auto=5Fdetect()=20idempotent=20across=20repeated=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/registry.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index d15aab4..ed93471 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -107,6 +107,10 @@ def auto_detect(self) -> list[str]: if not adapter.is_available(): continue + with self._lock: + if self._active.get(name) is adapter: + continue + adapter.register(object()) with self._lock: self._active[name] = adapter From 8c90da2c0809f0b35c5957e8fa421ae421e6e3ce Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:05:08 +0800 Subject: [PATCH 13/23] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(adapters):=20Track?= =?UTF-8?q?=20activation=20and=20discovery=20error=20status=20in=20Adapter?= =?UTF-8?q?Info?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/registry.py | 44 +++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index ed93471..463cc2a 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -38,6 +38,7 @@ def __init__(self) -> None: self._lock = Lock() self._registered: dict[str, FrameworkAdapter] = {} self._active: dict[str, FrameworkAdapter] = {} + self._errors: dict[str, str] = {} for framework_name in ("langchain", "langgraph", "crewai", "pydantic_ai"): self._registered[framework_name] = _BuiltinPlaceholderAdapter(framework_name) @@ -45,6 +46,7 @@ def register(self, adapter: FrameworkAdapter) -> None: adapter_name = adapter.get_framework_name() with self._lock: self._registered[adapter_name] = adapter + self._errors.pop(adapter_name, None) if adapter_name in self._active and self._active[adapter_name] is not adapter: self._active.pop(adapter_name, None) @@ -52,6 +54,7 @@ def unregister(self, name: str) -> None: with self._lock: active_adapter = self._active.pop(name, None) self._registered.pop(name, None) + self._errors.pop(name, None) if active_adapter is not None: active_adapter.unregister_hooks() @@ -59,6 +62,7 @@ def unregister(self, name: str) -> None: def list_active(self) -> list[AdapterInfo]: with self._lock: active_items = list(self._active.items()) + error_names = set(self._errors.keys()) result: list[AdapterInfo] = [] for name, adapter in active_items: @@ -75,6 +79,17 @@ def list_active(self) -> list[AdapterInfo]: ) ) + active_names = {name for name, _ in active_items} + for name in sorted(error_names - active_names): + result.append( + AdapterInfo( + name=name, + version="", + status="error", + hooks_registered=0, + ) + ) + return sorted(result, key=lambda info: info.name) def _discover_entry_point_adapters(self) -> list[str]: @@ -83,14 +98,30 @@ def _discover_entry_point_adapters(self) -> list[str]: adapter_entry_points = entry_points.select(group="agent_assembly.adapters") for entry_point in adapter_entry_points: - loaded = entry_point.load() + try: + loaded = entry_point.load() + except Exception as error: # pragma: no cover - guarded by tests via monkeypatch + with self._lock: + self._errors[entry_point.name] = str(error) + continue + if not isinstance(loaded, type): + with self._lock: + self._errors[entry_point.name] = "Entry point did not load a class." continue if not issubclass(loaded, FrameworkAdapter): + with self._lock: + self._errors[entry_point.name] = "Entry point class is not a FrameworkAdapter." + continue + + try: + adapter = loaded() + except Exception as error: # pragma: no cover - guarded by tests via monkeypatch + with self._lock: + self._errors[entry_point.name] = str(error) continue - adapter = loaded() self.register(adapter) discovered.append(adapter.get_framework_name()) @@ -111,9 +142,16 @@ def auto_detect(self) -> list[str]: if self._active.get(name) is adapter: continue - adapter.register(object()) + try: + adapter.register(object()) + except Exception as error: + with self._lock: + self._errors[name] = str(error) + continue + with self._lock: self._active[name] = adapter + self._errors.pop(name, None) activated.append(name) return activated From 71b6f5dcbd00af6d103128a8454c2c7eeab6e0df Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:05:16 +0800 Subject: [PATCH 14/23] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(exports):=20Export?= =?UTF-8?q?=20AdapterRegistry=20and=20AdapterInfo=20from=20adapters=20pack?= =?UTF-8?q?age?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/agent_assembly/adapters/__init__.py b/agent_assembly/adapters/__init__.py index c16f448..1da5617 100644 --- a/agent_assembly/adapters/__init__.py +++ b/agent_assembly/adapters/__init__.py @@ -1,3 +1,4 @@ from agent_assembly.adapters.base import FrameworkAdapter, GovernanceInterceptor +from agent_assembly.adapters.registry import AdapterInfo, AdapterRegistry -__all__ = ["GovernanceInterceptor", "FrameworkAdapter"] +__all__ = ["GovernanceInterceptor", "FrameworkAdapter", "AdapterInfo", "AdapterRegistry"] From 0a7829223ba17b615b5e73deee5fd3c6361db8f2 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:05:37 +0800 Subject: [PATCH 15/23] =?UTF-8?q?=E2=9C=85=20(tests):=20Add=20auto=5Fdetec?= =?UTF-8?q?t=20importability=20isolation=20test=20with=20mocked=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/adapters/test_registry.py | 61 +++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/test/unit/adapters/test_registry.py b/test/unit/adapters/test_registry.py index e69de29..1119cef 100644 --- a/test/unit/adapters/test_registry.py +++ b/test/unit/adapters/test_registry.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from agent_assembly.adapters import AdapterRegistry, FrameworkAdapter, GovernanceInterceptor + + +class DummyAdapter(FrameworkAdapter): + def __init__(self, framework_name: str) -> None: + self._framework_name = framework_name + self.register_calls = 0 + + def get_framework_name(self) -> str: + return self._framework_name + + def get_supported_versions(self) -> list[str]: + return [">=0.1.0"] + + def register_hooks(self, interceptor: GovernanceInterceptor) -> None: + self.register_calls += 1 + + def unregister_hooks(self) -> None: + return None + + +class EmptyEntryPoints(list[object]): + def select(self, *, group: str) -> list[object]: + del group + return [] + + +def test_auto_detect_activates_only_importable_frameworks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = AdapterRegistry() + importable = DummyAdapter("available_framework") + missing = DummyAdapter("missing_framework") + registry._registered = { + importable.get_framework_name(): importable, + missing.get_framework_name(): missing, + } + + monkeypatch.setattr( + "agent_assembly.adapters.registry.metadata.entry_points", + lambda: EmptyEntryPoints(), + ) + + def fake_import_module(module_name: str) -> object: + if module_name == "available_framework": + return SimpleNamespace(__version__="1.2.3") + raise ImportError + + monkeypatch.setattr("agent_assembly.adapters.base.importlib.import_module", fake_import_module) + + activated = registry.auto_detect() + + assert activated == ["available_framework"] + assert importable.register_calls == 1 + assert missing.register_calls == 0 From dc4ebe2acebc5ad6d6fa7724ac2c8f874b2a7b15 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:05:54 +0800 Subject: [PATCH 16/23] =?UTF-8?q?=E2=9C=85=20(tests):=20Add=20entry-point?= =?UTF-8?q?=20discovery=20test=20for=20third-party=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/adapters/test_registry.py | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/unit/adapters/test_registry.py b/test/unit/adapters/test_registry.py index 1119cef..c31f1b4 100644 --- a/test/unit/adapters/test_registry.py +++ b/test/unit/adapters/test_registry.py @@ -31,6 +31,29 @@ def select(self, *, group: str) -> list[object]: return [] +class FakeEntryPoint: + def __init__(self, name: str, loaded: type[FrameworkAdapter]) -> None: + self.name = name + self._loaded = loaded + + def load(self) -> type[FrameworkAdapter]: + return self._loaded + + +class ThirdPartyAdapter(FrameworkAdapter): + def get_framework_name(self) -> str: + return "third_party_framework" + + def get_supported_versions(self) -> list[str]: + return [">=1.0.0"] + + def register_hooks(self, interceptor: GovernanceInterceptor) -> None: + return None + + def unregister_hooks(self) -> None: + return None + + def test_auto_detect_activates_only_importable_frameworks( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -59,3 +82,24 @@ def fake_import_module(module_name: str) -> object: assert activated == ["available_framework"] assert importable.register_calls == 1 assert missing.register_calls == 0 + + +def test_entry_point_discovery_loads_third_party_adapters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = AdapterRegistry() + + class FakeEntryPoints(list[FakeEntryPoint]): + def select(self, *, group: str) -> list[FakeEntryPoint]: + assert group == "agent_assembly.adapters" + return list(self) + + monkeypatch.setattr( + "agent_assembly.adapters.registry.metadata.entry_points", + lambda: FakeEntryPoints([FakeEntryPoint("third-party", ThirdPartyAdapter)]), + ) + + discovered = registry._discover_entry_point_adapters() + + assert discovered == ["third_party_framework"] + assert "third_party_framework" in registry._registered From 1ca7d20d1b0fefeefe8e74d7fef992dbc8594152 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:06:08 +0800 Subject: [PATCH 17/23] =?UTF-8?q?=E2=9C=85=20(tests):=20Add=20idempotent?= =?UTF-8?q?=20auto=5Fdetect=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/adapters/test_registry.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/unit/adapters/test_registry.py b/test/unit/adapters/test_registry.py index c31f1b4..1a01015 100644 --- a/test/unit/adapters/test_registry.py +++ b/test/unit/adapters/test_registry.py @@ -103,3 +103,25 @@ def select(self, *, group: str) -> list[FakeEntryPoint]: assert discovered == ["third_party_framework"] assert "third_party_framework" in registry._registered + + +def test_auto_detect_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: + registry = AdapterRegistry() + adapter = DummyAdapter("idempotent_framework") + registry._registered = {adapter.get_framework_name(): adapter} + + monkeypatch.setattr( + "agent_assembly.adapters.registry.metadata.entry_points", + lambda: EmptyEntryPoints(), + ) + monkeypatch.setattr( + "agent_assembly.adapters.base.importlib.import_module", + lambda module_name: SimpleNamespace(__version__="9.9.9"), + ) + + first_activation = registry.auto_detect() + second_activation = registry.auto_detect() + + assert first_activation == ["idempotent_framework"] + assert second_activation == [] + assert adapter.register_calls == 1 From 70d8157859baf72dad432608f79fc1865950530f Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:06:24 +0800 Subject: [PATCH 18/23] =?UTF-8?q?=E2=9C=85=20(tests):=20Add=20list=5Factiv?= =?UTF-8?q?e=20real-time=20state=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/adapters/test_registry.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/unit/adapters/test_registry.py b/test/unit/adapters/test_registry.py index 1a01015..181de66 100644 --- a/test/unit/adapters/test_registry.py +++ b/test/unit/adapters/test_registry.py @@ -125,3 +125,31 @@ def test_auto_detect_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: assert first_activation == ["idempotent_framework"] assert second_activation == [] assert adapter.register_calls == 1 + + +def test_list_active_reflects_real_time_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = AdapterRegistry() + adapter = DummyAdapter("stateful_framework") + registry._registered = {adapter.get_framework_name(): adapter} + + monkeypatch.setattr( + "agent_assembly.adapters.registry.metadata.entry_points", + lambda: EmptyEntryPoints(), + ) + monkeypatch.setattr( + "agent_assembly.adapters.base.importlib.import_module", + lambda module_name: SimpleNamespace(__version__="2.0.0"), + ) + + registry.auto_detect() + active_after_detect = registry.list_active() + + assert len(active_after_detect) == 1 + assert active_after_detect[0].name == "stateful_framework" + assert active_after_detect[0].status == "active" + + registry.unregister("stateful_framework") + + assert registry.list_active() == [] From 07fec77a581d6527763bcde32eea144ddb068bae Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:06:39 +0800 Subject: [PATCH 19/23] =?UTF-8?q?=E2=9C=85=20(tests):=20Add=20concurrent?= =?UTF-8?q?=20register=20and=20unregister=20thread-safety=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/adapters/test_registry.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/unit/adapters/test_registry.py b/test/unit/adapters/test_registry.py index 181de66..55540c2 100644 --- a/test/unit/adapters/test_registry.py +++ b/test/unit/adapters/test_registry.py @@ -1,5 +1,6 @@ from __future__ import annotations +from concurrent.futures import ThreadPoolExecutor from types import SimpleNamespace import pytest @@ -153,3 +154,22 @@ def test_list_active_reflects_real_time_state( registry.unregister("stateful_framework") assert registry.list_active() == [] + + +def test_register_unregister_is_thread_safe() -> None: + registry = AdapterRegistry() + + def mutate_registry(thread_id: int) -> None: + for round_id in range(40): + adapter = DummyAdapter(f"concurrent_{thread_id}_{round_id}") + registry.register(adapter) + registry.unregister(adapter.get_framework_name()) + + with ThreadPoolExecutor(max_workers=8) as executor: + futures = [executor.submit(mutate_registry, thread_id) for thread_id in range(8)] + for future in futures: + future.result() + + with registry._lock: + assert isinstance(registry._registered, dict) + assert isinstance(registry._active, dict) From 710d6033a535a558026384d4871d561ce1e225c0 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 12:06:48 +0800 Subject: [PATCH 20/23] =?UTF-8?q?=F0=9F=94=A7=20(typing):=20Enforce=20stri?= =?UTF-8?q?ct=20mypy=20coverage=20for=20registry=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mypy.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mypy.ini b/mypy.ini index 70c6cf5..a2f1140 100644 --- a/mypy.ini +++ b/mypy.ini @@ -23,3 +23,6 @@ strict_concatenate = True [mypy-agent_assembly.adapters.base] strict = True + +[mypy-agent_assembly.adapters.registry] +strict = True From 0e722a4e7182e027a02e4aaebaf986120d887649 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 14:15:37 +0800 Subject: [PATCH 21/23] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(adapters):=20Make?= =?UTF-8?q?=20entry-point=20auto=5Fdetect=20idempotent=20by=20discovery=20?= =?UTF-8?q?key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/registry.py | 7 +++++ test/unit/adapters/test_registry.py | 47 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index 463cc2a..d283c76 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -39,6 +39,7 @@ def __init__(self) -> None: self._registered: dict[str, FrameworkAdapter] = {} self._active: dict[str, FrameworkAdapter] = {} self._errors: dict[str, str] = {} + self._discovered_entry_points: set[str] = set() for framework_name in ("langchain", "langgraph", "crewai", "pydantic_ai"): self._registered[framework_name] = _BuiltinPlaceholderAdapter(framework_name) @@ -98,6 +99,10 @@ def _discover_entry_point_adapters(self) -> list[str]: adapter_entry_points = entry_points.select(group="agent_assembly.adapters") for entry_point in adapter_entry_points: + with self._lock: + if entry_point.name in self._discovered_entry_points: + continue + try: loaded = entry_point.load() except Exception as error: # pragma: no cover - guarded by tests via monkeypatch @@ -123,6 +128,8 @@ def _discover_entry_point_adapters(self) -> list[str]: continue self.register(adapter) + with self._lock: + self._discovered_entry_points.add(entry_point.name) discovered.append(adapter.get_framework_name()) return discovered diff --git a/test/unit/adapters/test_registry.py b/test/unit/adapters/test_registry.py index 55540c2..50336e5 100644 --- a/test/unit/adapters/test_registry.py +++ b/test/unit/adapters/test_registry.py @@ -55,6 +55,22 @@ def unregister_hooks(self) -> None: return None +class CountingEntryPointAdapter(FrameworkAdapter): + register_calls = 0 + + def get_framework_name(self) -> str: + return "entrypoint_counting_framework" + + def get_supported_versions(self) -> list[str]: + return [">=1.0.0"] + + def register_hooks(self, interceptor: GovernanceInterceptor) -> None: + CountingEntryPointAdapter.register_calls += 1 + + def unregister_hooks(self) -> None: + return None + + def test_auto_detect_activates_only_importable_frameworks( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -173,3 +189,34 @@ def mutate_registry(thread_id: int) -> None: with registry._lock: assert isinstance(registry._registered, dict) assert isinstance(registry._active, dict) + + +def test_auto_detect_is_idempotent_for_entry_point_adapters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = AdapterRegistry() + CountingEntryPointAdapter.register_calls = 0 + + class FakeEntryPoints(list[FakeEntryPoint]): + def select(self, *, group: str) -> list[FakeEntryPoint]: + assert group == "agent_assembly.adapters" + return list(self) + + monkeypatch.setattr( + "agent_assembly.adapters.registry.metadata.entry_points", + lambda: FakeEntryPoints([FakeEntryPoint("counting-entrypoint", CountingEntryPointAdapter)]), + ) + + def fake_import_module(module_name: str) -> object: + if module_name == "entrypoint_counting_framework": + return SimpleNamespace(__version__="3.0.0") + raise ImportError + + monkeypatch.setattr("agent_assembly.adapters.base.importlib.import_module", fake_import_module) + + first = registry.auto_detect() + second = registry.auto_detect() + + assert first == ["entrypoint_counting_framework"] + assert second == [] + assert CountingEntryPointAdapter.register_calls == 1 From 35a71d34bb5896317c149da56b65712c4306b5c8 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 14:16:16 +0800 Subject: [PATCH 22/23] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(adapters):=20Use=20?= =?UTF-8?q?resilient=20noop=20governance=20interceptor=20in=20auto=5Fdetec?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/registry.py | 18 ++++++++++-- test/unit/adapters/test_registry.py | 43 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index d283c76..4ebae69 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from importlib import metadata from threading import Lock -from typing import Literal +from typing import Callable, Literal from agent_assembly.adapters.base import FrameworkAdapter @@ -33,6 +33,20 @@ def unregister_hooks(self) -> None: return None +def _noop_interceptor_method(*args: object, **kwargs: object) -> None: + del args, kwargs + return None + + +class _NoopGovernanceInterceptor: + def __getattr__(self, name: str) -> Callable[..., None]: + del name + return _noop_interceptor_method + + +_NOOP_GOVERNANCE_INTERCEPTOR = _NoopGovernanceInterceptor() + + class AdapterRegistry: def __init__(self) -> None: self._lock = Lock() @@ -150,7 +164,7 @@ def auto_detect(self) -> list[str]: continue try: - adapter.register(object()) + adapter.register(_NOOP_GOVERNANCE_INTERCEPTOR) except Exception as error: with self._lock: self._errors[name] = str(error) diff --git a/test/unit/adapters/test_registry.py b/test/unit/adapters/test_registry.py index 50336e5..4d6ab77 100644 --- a/test/unit/adapters/test_registry.py +++ b/test/unit/adapters/test_registry.py @@ -71,6 +71,24 @@ def unregister_hooks(self) -> None: return None +class InterceptorCallingAdapter(FrameworkAdapter): + def __init__(self) -> None: + self.hook_registered = False + + def get_framework_name(self) -> str: + return "interceptor_calling_framework" + + def get_supported_versions(self) -> list[str]: + return [">=1.0.0"] + + def register_hooks(self, interceptor: GovernanceInterceptor) -> None: + interceptor.record_event("adapter-registered") + self.hook_registered = True + + def unregister_hooks(self) -> None: + return None + + def test_auto_detect_activates_only_importable_frameworks( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -220,3 +238,28 @@ def fake_import_module(module_name: str) -> object: assert first == ["entrypoint_counting_framework"] assert second == [] assert CountingEntryPointAdapter.register_calls == 1 + + +def test_auto_detect_uses_resilient_noop_interceptor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = AdapterRegistry() + adapter = InterceptorCallingAdapter() + registry._registered = {adapter.get_framework_name(): adapter} + + monkeypatch.setattr( + "agent_assembly.adapters.registry.metadata.entry_points", + lambda: EmptyEntryPoints(), + ) + + def fake_import_module(module_name: str) -> object: + if module_name == "interceptor_calling_framework": + return SimpleNamespace(__version__="1.0.0") + raise ImportError + + monkeypatch.setattr("agent_assembly.adapters.base.importlib.import_module", fake_import_module) + + activated = registry.auto_detect() + + assert activated == ["interceptor_calling_framework"] + assert adapter.hook_registered is True From f22a0724f61d4c07e4ddac043f0e49c070c24f66 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Mon, 27 Apr 2026 14:17:34 +0800 Subject: [PATCH 23/23] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(adapters):=20Align?= =?UTF-8?q?=20built-in=20key=20to=20pydantic-ai=20with=20python=20import?= =?UTF-8?q?=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/registry.py | 18 ++++++++++++++---- test/unit/adapters/test_registry.py | 8 ++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index 4ebae69..3c620e6 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -17,11 +17,12 @@ class AdapterInfo: class _BuiltinPlaceholderAdapter(FrameworkAdapter): - def __init__(self, framework_name: str) -> None: + def __init__(self, framework_name: str, import_name: str | None = None) -> None: self._framework_name = framework_name + self._import_name = import_name or framework_name def get_framework_name(self) -> str: - return self._framework_name + return self._import_name def get_supported_versions(self) -> list[str]: return [">=0.0.0"] @@ -54,8 +55,17 @@ def __init__(self) -> None: self._active: dict[str, FrameworkAdapter] = {} self._errors: dict[str, str] = {} self._discovered_entry_points: set[str] = set() - for framework_name in ("langchain", "langgraph", "crewai", "pydantic_ai"): - self._registered[framework_name] = _BuiltinPlaceholderAdapter(framework_name) + builtin_frameworks = [ + ("langchain", "langchain"), + ("langgraph", "langgraph"), + ("crewai", "crewai"), + ("pydantic-ai", "pydantic_ai"), + ] + for registry_name, import_name in builtin_frameworks: + self._registered[registry_name] = _BuiltinPlaceholderAdapter( + framework_name=registry_name, + import_name=import_name, + ) def register(self, adapter: FrameworkAdapter) -> None: adapter_name = adapter.get_framework_name() diff --git a/test/unit/adapters/test_registry.py b/test/unit/adapters/test_registry.py index 4d6ab77..4721b4b 100644 --- a/test/unit/adapters/test_registry.py +++ b/test/unit/adapters/test_registry.py @@ -263,3 +263,11 @@ def fake_import_module(module_name: str) -> object: assert activated == ["interceptor_calling_framework"] assert adapter.hook_registered is True + + +def test_builtin_registry_name_uses_pydantic_ai_label_with_python_import_name() -> None: + registry = AdapterRegistry() + + assert "pydantic-ai" in registry._registered + assert "pydantic_ai" not in registry._registered + assert registry._registered["pydantic-ai"].get_framework_name() == "pydantic_ai"