Authoring a framework adapter¶
A framework adapter teaches the Agent Assembly SDK how to govern a third-party AI framework (LangChain, CrewAI, OpenAI Agents, …) without that framework being aware of Agent Assembly. This guide walks you from zero to a published, installable adapter package.
Every adapter implements one ABC — FrameworkAdapter — and is discovered either
by being registered in-tree or by advertising a Python entry point. The companion
reference implementation is examples/adapters/template_adapter.py: a minimal,
runnable adapter you can copy and adapt.
1. Prerequisites¶
- Python 3.10+ (the SDK targets 3.12 in CI; the public API is 3.10-compatible).
- A working understanding of your target framework's hook/callback system — the method, callback, or class you can wrap to observe and gate tool/agent execution. Adapters work by intercepting that point, so you need to know where in the framework a tool call happens before you can govern it.
-
The SDK installed in a virtualenv:
2. Quickstart: copy the reference template¶
The fastest start is to copy the runnable template adapter and run it:
cp examples/adapters/template_adapter.py my_adapter.py
uv run python my_adapter.py # runs the lifecycle demo offline
uv run aasm adapter validate my_adapter.py # checks the contract — expect 7/7 PASS
The template governs a self-contained fictional framework so it runs with no third-party
dependencies and no reachable gateway. Replace the fictional ExampleFramework and the
monkey-patch in register_hooks with your real framework's classes, then re-run the two
commands above.
3. The FrameworkAdapter interface¶
FrameworkAdapter is an ABC with four required (abstract) methods. The base class also
provides several concrete helpers (register, validate_registration, is_available,
get_active_version, set_process_agent_id) that you normally do not override.
| Method | Signature | What you must do |
|---|---|---|
get_framework_name |
() -> str |
Return the canonical importable package name (e.g. "crewai"). Must be non-empty. The registry uses it to check availability via importlib.import_module. |
get_supported_versions |
() -> list[str] |
Return a non-empty list of non-empty PEP 440 version-range strings (e.g. [">=0.1.0"]). |
register_hooks |
(interceptor: GovernanceInterceptor) -> None |
Install your framework-specific monkey-patches, routing intercepted calls through interceptor. |
unregister_hooks |
() -> None |
Tear down every patch installed by register_hooks. Must be idempotent — calling it twice must not raise. |
Implementation notes¶
get_framework_name— must match the framework's import name sois_available()(which the registry calls before activating you) returnsTrueonly when the framework is actually installed. Empty/whitespace names raiseAdapterValidationErrorduringregister().get_supported_versions— an empty list, or any empty range string, raisesAdapterValidationError. These ranges document compatibility; the base class validates their shape, not the running framework version.register_hooks— receives the liveinterceptor. Do the actual monkey-patching here (see §5 Hook patterns). Built-in adapters delegate to one or more internal patch objects exposingapply()/revert(); see ADR-0001 in Development → ADR-0001. Lazily import the framework inside this method so importing your adapter never hard-fails when the framework is absent.unregister_hooks— must revert patches (ideally in reverse install order) and be safe to call when no hooks are active. Guard with a "patched" flag so a double-call is a no-op. The validator enforces this by calling it twice.
Do not call register_hooks directly — call adapter.register(interceptor), which runs
validate_registration() first so contract errors surface before any hook is attached.
A complete, working version of all four methods is in the template adapter; here is the shape:
from agent_assembly.adapters.base import FrameworkAdapter, GovernanceInterceptor
class MyFrameworkAdapter(FrameworkAdapter):
def get_framework_name(self) -> str:
return "my_framework"
def get_supported_versions(self) -> list[str]:
return [">=1.0.0,<2.0.0"]
def register_hooks(self, interceptor: GovernanceInterceptor) -> None:
# Install monkey-patches that route framework calls through interceptor.
...
def unregister_hooks(self) -> None:
# Revert all patches installed by register_hooks(); must be idempotent.
...
4. The GovernanceInterceptor contract and the events you emit¶
GovernanceInterceptor is a structural typing.Protocol with no required
methods. It is a duck-typed marker: any object can be an interceptor. Adapters therefore
call interceptor methods defensively — look the method up with getattr and only call it
when it exists:
check = getattr(interceptor, "check_tool_call", None)
if callable(check):
decision = check(tool_name=tool_name, args=args)
This keeps an adapter working against both the full governance interceptor wired by
init_assembly() and a minimal stub used in tests.
Conventional interceptor methods¶
Because the Protocol is open, there is no fixed enum of event types. The built-in
adapters use the following method-name conventions; mirror the ones that fit your framework's
execution model. Each returns either nothing (a pure notification) or a decision — a
status string "allow" | "deny" | "pending", or a mapping {"status": ..., "reason": ...}.
| Convention method | Direction | Purpose |
|---|---|---|
check_tool_start / check_tool_call |
adapter → interceptor, returns decision | Pre-execution gate for a tool call; deny blocks it. |
wait_for_tool_approval |
adapter → interceptor, returns decision | Block until a pending tool call is approved or rejected (human-in-the-loop). |
get_pending_tool_approval_timeout_seconds |
adapter → interceptor | Configurable timeout for the approval wait. |
record_result / on_tool_end |
adapter → interceptor, no return | Report a completed tool call's output for audit. |
record |
adapter → interceptor, no return | Generic structured event (e.g. action="task_start"). |
These are conventions, not a typed contract
The names above are what today's built-in adapters happen to call (see the CrewAI patch
in agent_assembly/adapters/crewai/patch.py). Always getattr-guard them. A future
release may formalise this into typed methods on the Protocol.
A pending → approval flow looks like:
decision = check_tool_start(...) # may return {"status": "pending"}
if status == "pending":
decision = wait_for_tool_approval(...) # blocks until resolved or times out
# now decision is "allow" or "deny"
5. Hook patterns¶
There are three ways to wire an adapter into a framework. Pick the one your framework supports best.
Callback-based¶
Register a callback/handler object with the framework's own callback system (LangChain's
BaseCallbackHandler is the canonical example; the LangChainAdapter builds one in
register_hooks and exposes it via get_callback_handler()).
- Pros: first-class, framework-sanctioned, survives framework internal refactors, no reliance on private attributes.
- Cons: only available if the framework has a callback system, and only sees the events that system emits — anything outside it is invisible.
Wrapper-based¶
Wrap a public method on a framework class with functools.wraps, run your governance logic,
then delegate to the original. This is what the template adapter does, and what
the CrewAI adapter does to BaseTool.run / Task.execute_sync.
- Pros: works on any framework with a public entry point; you control exactly when the check runs relative to the real call; clean revert by restoring the saved original.
- Cons: couples you to the wrapped method's signature; if the framework changes that method you must follow.
Monkey-patch-based¶
Replace a function or attribute outright (a degenerate case of wrapping where you may target module-level functions or private internals).
- Pros: can reach things with no public hook at all.
- Cons: most brittle; most likely to break on framework upgrades; easiest to leave the
process in a bad state if revert is incomplete. Use only as a last resort, and always store
the original + a "patched" flag so
unregister_hooksis exact and idempotent.
Whichever you choose, unregister_hooks must fully undo it. The template's
_PATCHED_FLAG / _ORIGINAL_RUN_TOOL sentinel pattern is the recommended approach.
6. Testing your adapter¶
There is no AdapterTestHarness fixture in the SDK today
Earlier planning referenced an AdapterTestHarness pytest fixture. It does not exist
in the current codebase. Test your adapter with the two real mechanisms below; if a
shared harness is added later this section will be updated.
Contract validation (the in-tree validator)¶
The SDK ships a contract validator, exposed both as a CLI command and as a Python function. Run it against your adapter file or dotted module:
uv run aasm adapter validate path/to/my_adapter.py
# or: uv run aasm adapter validate my_package.my_adapter
It runs seven checks — inheritance, all four abstract methods implemented, non-empty
framework name, non-empty version ranges, register_hooks signature, unregister_hooks
idempotency, and entry-point metadata (when a pyproject.toml is present). The reference
template passes all seven:
You can also call it programmatically in a unit test:
from agent_assembly.cli.adapter_validator import validate_adapter
from my_package.my_adapter import MyFrameworkAdapter
def test_adapter_passes_contract() -> None:
results = validate_adapter(MyFrameworkAdapter, "my_package/my_adapter.py")
assert all(r.passed for r in results), [r.message for r in results if not r.passed]
Lifecycle unit tests with a stub interceptor¶
Drive register_hooks / unregister_hooks with a tiny recording interceptor and a mocked
(or fictional) framework class, then assert that an allowed call passes through, a denied call
is blocked, and teardown restores the original. The template adapter's __main__ demo is a
runnable model of exactly this flow.
class RecordingInterceptor:
def __init__(self) -> None:
self.calls: list[str] = []
def check_tool_call(self, *, tool_name: str, args: dict) -> dict:
self.calls.append(tool_name)
return {"status": "deny" if tool_name == "shell" else "allow", "reason": None}
def test_denies_shell() -> None:
from examples.adapters.template_adapter import ExampleFramework, TemplateAdapter
adapter, interceptor, fw = TemplateAdapter(), RecordingInterceptor(), ExampleFramework()
adapter.register_hooks(interceptor)
try:
assert fw.run_tool("shell", cmd="rm -rf /").startswith("[BLOCKED")
assert "shell" in interceptor.calls
finally:
adapter.unregister_hooks()
adapter.unregister_hooks() # idempotent
Place real adapter tests under test/unit/adapters/<framework_name>/ (lifecycle, with the
framework mocked) and test/integration/adapters/<framework_name>/ (a minimal end-to-end flow
with the real framework imported), as described in
CONTRIBUTING.md.
7. Publishing: entry points and naming¶
A community adapter ships as its own pip-installable package and is discovered at runtime via
a Python entry point in the agent_assembly.adapters group. AdapterRegistry loads every
entry point in that group, verifies the loaded object is a FrameworkAdapter subclass, and
registers it — so init_assembly() activates it automatically when the framework is present.
Naming convention¶
Name the distribution aa-adapter-<framework> (e.g. aa-adapter-myframework). The
import package can be whatever you like, but the framework name returned by
get_framework_name() must equal the framework's import name.
pyproject.toml entry-point config¶
[project]
name = "aa-adapter-myframework"
version = "0.1.0"
dependencies = ["agent-assembly", "myframework>=1.0.0"]
[project.entry-points."agent_assembly.adapters"]
myframework = "aa_adapter_myframework.adapter:MyFrameworkAdapter"
The entry-point value is module.path:ClassName. The aasm adapter validate
entry_point_metadata check confirms this points at your adapter class when it finds a
pyproject.toml alongside the adapter.
Verify discovery end to end¶
pip install -e . # install your adapter package
uv run aasm adapter validate aa_adapter_myframework.adapter # 7/7 PASS, incl. entry point
python -c "from agent_assembly.adapters.registry import AdapterRegistry; \
print([a.get_framework_name() for a in AdapterRegistry().get_available_adapters_by_priority()])"
get_available_adapters_by_priority() triggers entry-point discovery; your framework name
appears in the list once the framework is importable.
8. PR checklist¶
When contributing a built-in adapter back to this repo (community packages live in their own repos but should still meet this bar), confirm:
- Adapter subclasses
FrameworkAdapterand implements all four abstract methods. -
uv run aasm adapter validate <path-or-module>reports 7 passed, 0 failed. -
unregister_hooksis idempotent and fully reverts every patch. - Framework is imported lazily (inside
register_hooks/ availability helpers), so importing the adapter never fails when the framework is absent. - Unit tests under
test/unit/adapters/<framework_name>/cover the patch install/revert lifecycle (framework mocked). - Integration test under
test/integration/adapters/<framework_name>/exercises a minimal real flow. -
uv run ruff check .,uv run ruff format --check ., anduv run mypy agent_assemblyare clean. - Entry point declared in
pyproject.tomlunder[project.entry-points."agent_assembly.adapters"](for standalone packages). - Distribution named
aa-adapter-<framework>;get_framework_name()matches the framework's import name. - Follows the repo PR checklist.