Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e0cf62f
📝 (adr): Add hook architecture decision record
Chisanan232 May 1, 2026
d7872bb
📝 (adapters): Add docstring to FrameworkAdapter ABC clarifying public…
Chisanan232 May 1, 2026
bad7031
📝 (core): Add docstring to RuntimePatch protocol clarifying internal …
Chisanan232 May 1, 2026
58d15b7
✨ (langchain): Add LangChainAdapter as FrameworkAdapter subclass
Chisanan232 May 1, 2026
2175a35
✨ (langgraph): Add LangGraphAdapter as FrameworkAdapter subclass
Chisanan232 May 1, 2026
55d2d2b
✨ (crewai): Add CrewAIAdapter as FrameworkAdapter subclass
Chisanan232 May 1, 2026
263faee
✨ (pydantic_ai): Add PydanticAIAdapter as FrameworkAdapter subclass
Chisanan232 May 1, 2026
c82780c
✨ (openai_agents): Add OpenAIAgentsAdapter as FrameworkAdapter subclass
Chisanan232 May 1, 2026
fa6958e
✨ (mcp): Add MCPAdapter as FrameworkAdapter subclass
Chisanan232 May 1, 2026
834c530
♻️ (registry): Replace _BuiltinPlaceholderAdapter with real adapter c…
Chisanan232 May 1, 2026
b3fdf9c
✨ (registry): Register OpenAIAgentsAdapter and MCPAdapter as builtin …
Chisanan232 May 1, 2026
daf6b93
✨ (registry): Add adapter priority ordering and get_available_adapter…
Chisanan232 May 1, 2026
79ffb15
✨ (adapters): Add set_process_agent_id to FrameworkAdapter base
Chisanan232 May 1, 2026
a427a7f
♻️ (core): Route init_assembly() through AdapterRegistry as single de…
Chisanan232 May 1, 2026
21c5755
♻️ (adapters): Update adapter __init__.py exports to include new adap…
Chisanan232 May 1, 2026
9cbf2e8
✅ test(assembly): Update test_assembly.py for adapter-based architecture
Chisanan232 May 1, 2026
77ecafa
🐛 adapter(openai): Guard is_available against missing parent module
Chisanan232 May 1, 2026
c4ebc6e
✅ test(registry): Update test for real adapter registry keys
Chisanan232 May 1, 2026
d34c4fd
✅ test(langchain): Update runtime tests for _register_adapters API
Chisanan232 May 1, 2026
d6cfa31
📝 adr: Add integration path status table to ADR-0001
Chisanan232 May 1, 2026
76f5632
🚨 lint: Apply ruff formatting and import sorting fixes
Chisanan232 May 1, 2026
a144ca5
🐛 shim(langgraph): Restore importlib import removed by linter
Chisanan232 May 1, 2026
fbe687b
🚨 lint: Fix mypy unused-ignore and missing type annotations
Chisanan232 May 1, 2026
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
55 changes: 41 additions & 14 deletions agent_assembly/adapters/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from abc import ABC, abstractmethod
import importlib
from abc import ABC, abstractmethod
from typing import Protocol

from agent_assembly.exceptions import AdapterValidationError
Expand All @@ -10,14 +10,37 @@
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.
This is the **public adapter API** — the interface that SDK users and
third-party plugin authors interact with. Each concrete adapter represents
one AI framework (e.g. LangChain, CrewAI) and knows how to install
governance hooks for that framework.

The two key lifecycle methods are:

- ``register_hooks(interceptor)`` — install framework-specific
monkey-patches that route intercepted calls through the governance
interceptor. Internally, each adapter delegates to one or more
``RuntimePatch`` instances whose ``apply()`` method performs the
actual monkey-patching.

- ``unregister_hooks()`` — tear down all patches installed by this
adapter, delegating to each patch's ``revert()`` method.

Adapters are discovered and activated by ``AdapterRegistry.auto_detect()``
which is the single detection path used by ``init_assembly()``.

Adapters should be registered through ``register()`` so contract
validation errors are raised before framework hooks are attached.

See Also:
``RuntimePatch`` in ``core/assembly.py`` — the internal
monkey-patch protocol with ``apply()`` / ``revert()`` methods.
ADR-0001 (``docs/adr/0001-hook-architecture.md``) for the full
architecture rationale.
"""

@abstractmethod
Expand Down Expand Up @@ -72,21 +95,15 @@ def validate_registration(self) -> None:
"""
framework_name = self.get_framework_name()
if not framework_name.strip():
raise AdapterValidationError(
"Adapter contract invalid: framework name must be non-empty."
)
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."
)
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."
)
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.
Expand All @@ -111,6 +128,16 @@ def is_available(self) -> bool:

return True

def set_process_agent_id(self, agent_id: str | None) -> None:
"""Set the process-level agent ID for governance event attribution.

Adapters that need an agent ID (e.g. LangChain, OpenAI Agents, MCP)
override ``process_agent_id`` as a property. This base method is
a no-op for adapters that do not use an agent ID.
"""
if hasattr(self, "process_agent_id"):
self.process_agent_id = agent_id

def get_active_version(self) -> str | None:
"""Return framework `__version__` when present, otherwise `None`.

Expand Down
3 changes: 2 additions & 1 deletion agent_assembly/adapters/crewai/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""CrewAI adapter package."""

from agent_assembly.adapters.crewai.adapter import CrewAIAdapter
from agent_assembly.adapters.crewai.patch import CrewAIPatch

__all__ = ["CrewAIPatch"]
__all__ = ["CrewAIAdapter", "CrewAIPatch"]
28 changes: 28 additions & 0 deletions agent_assembly/adapters/crewai/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""CrewAI framework adapter."""

from __future__ import annotations

from agent_assembly.adapters.base import FrameworkAdapter, GovernanceInterceptor
from agent_assembly.adapters.crewai.patch import CrewAIPatch


class CrewAIAdapter(FrameworkAdapter):
"""Adapter for CrewAI framework governance hook installation."""

def __init__(self) -> None:
self._patch: CrewAIPatch | None = None

def get_framework_name(self) -> str:
return "crewai"

def get_supported_versions(self) -> list[str]:
return [">=0.1.0"]

def register_hooks(self, interceptor: GovernanceInterceptor) -> None:
self._patch = CrewAIPatch(interceptor)
self._patch.apply()

def unregister_hooks(self) -> None:
if self._patch is not None:
self._patch.revert()
self._patch = None
5 changes: 2 additions & 3 deletions agent_assembly/adapters/crewai/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

from __future__ import annotations

import importlib
from dataclasses import dataclass
from functools import wraps
import importlib
from threading import local
from typing import Any, Literal, Mapping

Expand Down Expand Up @@ -111,8 +111,7 @@ def _extract_agent_id_from_inputs(args: tuple[Any, ...], kwargs: dict[str, Any])
def _format_blocked_message(reason: str | None) -> str:
reason_text = reason or "No reason provided."
return (
f"[BLOCKED by governance policy] {reason_text}. "
"Please choose a different approach to accomplish this task."
f"[BLOCKED by governance policy] {reason_text}. " "Please choose a different approach to accomplish this task."
)


Expand Down
4 changes: 3 additions & 1 deletion agent_assembly/adapters/langchain/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
"""LangChain adapter package."""

from agent_assembly.adapters.langchain.adapter import LangChainAdapter
from agent_assembly.adapters.langchain.callback_handler import AssemblyCallbackHandler
from agent_assembly.adapters.langgraph import LangGraphPatch
from agent_assembly.adapters.langchain.langgraph_patch import patch_stategraph_compile
from agent_assembly.adapters.langchain.patch import LangChainPatch
from agent_assembly.adapters.langchain.runtime import (
auto_inject_callback_handler,
get_active_callback_handler,
)
from agent_assembly.adapters.langgraph import LangGraphPatch

__all__ = [
"LangChainAdapter",
"AssemblyCallbackHandler",
"LangChainPatch",
"LangGraphPatch",
Expand Down
47 changes: 47 additions & 0 deletions agent_assembly/adapters/langchain/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""LangChain framework adapter."""

from __future__ import annotations

from typing import Any

from agent_assembly.adapters.base import FrameworkAdapter, GovernanceInterceptor
from agent_assembly.adapters.langchain.patch import LangChainPatch
from agent_assembly.adapters.langchain.runtime import get_active_callback_handler


class LangChainAdapter(FrameworkAdapter):
"""Adapter for LangChain framework governance hook installation."""

def __init__(self, *, process_agent_id: str | None = None) -> None:
self._process_agent_id = process_agent_id
self._patch: LangChainPatch | None = None

@property
def process_agent_id(self) -> str | None:
return self._process_agent_id

@process_agent_id.setter
def process_agent_id(self, value: str | None) -> None:
self._process_agent_id = value

def get_framework_name(self) -> str:
return "langchain"

def get_supported_versions(self) -> list[str]:
return [">=0.1.0"]

def register_hooks(self, interceptor: GovernanceInterceptor) -> None:
self._patch = LangChainPatch(
interceptor,
process_agent_id=self._process_agent_id,
)
self._patch.apply()

def unregister_hooks(self) -> None:
if self._patch is not None:
self._patch.revert()
self._patch = None

def get_callback_handler(self) -> Any:
"""Return the active callback handler created during hook registration."""
return get_active_callback_handler()
2 changes: 0 additions & 2 deletions agent_assembly/adapters/langchain/callback_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@
class _FallbackBaseCallbackHandler:
"""Fallback base type when langchain-core is not installed."""

pass


_CallbackHandlerBase: type[object] = _FallbackBaseCallbackHandler
try: # pragma: no cover - import availability depends on installed extras.
Expand Down
2 changes: 1 addition & 1 deletion agent_assembly/adapters/langchain/langgraph_patch.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Backward-compatible shim for LangGraph patch utilities."""

import importlib
import importlib # noqa: F401 — re-exported for monkeypatch targets in tests

from agent_assembly.adapters.langgraph import patch as _impl

Expand Down
3 changes: 2 additions & 1 deletion agent_assembly/adapters/langgraph/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""LangGraph adapter package."""

from agent_assembly.adapters.langgraph.adapter import LangGraphAdapter
from agent_assembly.adapters.langgraph.patch import LangGraphPatch

__all__ = ["LangGraphPatch"]
__all__ = ["LangGraphAdapter", "LangGraphPatch"]
28 changes: 28 additions & 0 deletions agent_assembly/adapters/langgraph/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""LangGraph framework adapter."""

from __future__ import annotations

from agent_assembly.adapters.base import FrameworkAdapter, GovernanceInterceptor
from agent_assembly.adapters.langgraph.patch import LangGraphPatch


class LangGraphAdapter(FrameworkAdapter):
"""Adapter for LangGraph framework governance hook installation."""

def __init__(self) -> None:
self._patch: LangGraphPatch | None = None

def get_framework_name(self) -> str:
return "langgraph"

def get_supported_versions(self) -> list[str]:
return [">=0.1.0"]

def register_hooks(self, interceptor: GovernanceInterceptor) -> None:
self._patch = LangGraphPatch(interceptor)
self._patch.apply()

def unregister_hooks(self) -> None:
if self._patch is not None:
self._patch.revert()
self._patch = None
10 changes: 6 additions & 4 deletions agent_assembly/adapters/langgraph/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

from __future__ import annotations

from dataclasses import dataclass
import importlib
import inspect
from dataclasses import dataclass
from typing import Any

_PATCHED_FLAG = "_agent_assembly_compile_patched"
Expand Down Expand Up @@ -197,9 +197,7 @@ def _wrap_node_map(node_map: Any, callback_handler: Any) -> bool:
wrapped_any = False
for node_name, node_executor in list(items_method()):
if callable(node_executor):
wrapped_executor = _make_assembly_node_wrapper(
str(node_name), node_executor, callback_handler
)
wrapped_executor = _make_assembly_node_wrapper(str(node_name), node_executor, callback_handler)
if wrapped_executor is node_executor:
continue
try:
Expand Down Expand Up @@ -268,6 +266,7 @@ def _wrap_graph_invoke_fallback(compiled_graph: Any, callback_handler: Any) -> N
return None

if inspect.iscoroutinefunction(invoke):

async def wrapped_async_invoke(*invoke_args: Any, **invoke_kwargs: Any) -> Any:
state = _extract_state(invoke_args, invoke_kwargs)
config = _extract_config(invoke_args, invoke_kwargs)
Expand All @@ -281,8 +280,10 @@ async def wrapped_async_invoke(*invoke_args: Any, **invoke_kwargs: Any) -> Any:
config=config,
)
return result

wrapped_invoke: Any = wrapped_async_invoke
else:

def wrapped_sync_invoke(*invoke_args: Any, **invoke_kwargs: Any) -> Any:
state = _extract_state(invoke_args, invoke_kwargs)
config = _extract_config(invoke_args, invoke_kwargs)
Expand All @@ -296,6 +297,7 @@ def wrapped_sync_invoke(*invoke_args: Any, **invoke_kwargs: Any) -> Any:
config=config,
)
return result

wrapped_invoke = wrapped_sync_invoke

setattr(wrapped_invoke, _INVOKE_WRAPPED_FLAG, True)
Expand Down
3 changes: 2 additions & 1 deletion agent_assembly/adapters/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""MCP adapter package."""

from agent_assembly.adapters.mcp.adapter import MCPAdapter
from agent_assembly.adapters.mcp.patch import MCPClientPatch

__all__ = ["MCPClientPatch"]
__all__ = ["MCPAdapter", "MCPClientPatch"]
40 changes: 40 additions & 0 deletions agent_assembly/adapters/mcp/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""MCP framework adapter."""

from __future__ import annotations

from agent_assembly.adapters.base import FrameworkAdapter, GovernanceInterceptor
from agent_assembly.adapters.mcp.patch import MCPClientPatch


class MCPAdapter(FrameworkAdapter):
"""Adapter for MCP client governance hook installation."""

def __init__(self, *, process_agent_id: str | None = None) -> None:
self._process_agent_id = process_agent_id
self._patch: MCPClientPatch | None = None

@property
def process_agent_id(self) -> str | None:
return self._process_agent_id

@process_agent_id.setter
def process_agent_id(self, value: str | None) -> None:
self._process_agent_id = value

def get_framework_name(self) -> str:
return "mcp"

def get_supported_versions(self) -> list[str]:
return [">=1.0.0"]

def register_hooks(self, interceptor: GovernanceInterceptor) -> None:
self._patch = MCPClientPatch(
callback_handler=interceptor,
process_agent_id=self._process_agent_id,
)
self._patch.apply()

def unregister_hooks(self) -> None:
if self._patch is not None:
self._patch.revert()
self._patch = None
Loading