Skip to content

Commit 4b277e2

Browse files
authored
Merge pull request #126 from ai-agent-assembly/v0.0.1/AAASM-113/docs/adapter_guide
[AAASM-113] 📝 (docs): SDK adapter authoring guide
2 parents 652ddb9 + d3f945d commit 4b277e2

4 files changed

Lines changed: 574 additions & 0 deletions

File tree

docs/guides/authoring-adapters.md

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
# Authoring a framework adapter
2+
3+
A **framework adapter** teaches the Agent Assembly SDK how to govern a third-party AI
4+
framework (LangChain, CrewAI, OpenAI Agents, …) *without* that framework being aware of
5+
Agent Assembly. This guide walks you from zero to a published, installable adapter package.
6+
7+
Every adapter implements one ABC — [`FrameworkAdapter`][src-base] — and is discovered either
8+
by being registered in-tree or by advertising a Python **entry point**. The companion
9+
reference implementation is [`examples/adapters/template_adapter.py`][template]: a minimal,
10+
runnable adapter you can copy and adapt.
11+
12+
[src-base]: https://github.com/ai-agent-assembly/python-sdk/blob/master/agent_assembly/adapters/base.py
13+
[template]: https://github.com/ai-agent-assembly/python-sdk/blob/master/examples/adapters/template_adapter.py
14+
15+
---
16+
17+
## 1. Prerequisites
18+
19+
- **Python 3.10+** (the SDK targets 3.12 in CI; the public API is 3.10-compatible).
20+
- A working understanding of your **target framework's hook/callback system** — the
21+
method, callback, or class you can wrap to observe and gate tool/agent execution.
22+
Adapters work by intercepting that point, so you need to know *where* in the framework
23+
a tool call happens before you can govern it.
24+
- The SDK installed in a virtualenv:
25+
26+
```bash
27+
uv sync
28+
```
29+
30+
---
31+
32+
## 2. Quickstart: copy the reference template
33+
34+
The fastest start is to copy the runnable template adapter and run it:
35+
36+
```bash
37+
cp examples/adapters/template_adapter.py my_adapter.py
38+
uv run python my_adapter.py # runs the lifecycle demo offline
39+
uv run aasm adapter validate my_adapter.py # checks the contract — expect 7/7 PASS
40+
```
41+
42+
The template governs a self-contained fictional framework so it runs with no third-party
43+
dependencies and no reachable gateway. Replace the fictional `ExampleFramework` and the
44+
monkey-patch in `register_hooks` with your real framework's classes, then re-run the two
45+
commands above.
46+
47+
---
48+
49+
## 3. The `FrameworkAdapter` interface
50+
51+
`FrameworkAdapter` is an `ABC` with **four required (abstract) methods**. The base class also
52+
provides several concrete helpers (`register`, `validate_registration`, `is_available`,
53+
`get_active_version`, `set_process_agent_id`) that you normally do **not** override.
54+
55+
| Method | Signature | What you must do |
56+
| --- | --- | --- |
57+
| `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`. |
58+
| `get_supported_versions` | `() -> list[str]` | Return a **non-empty list** of non-empty PEP 440 version-range strings (e.g. `[">=0.1.0"]`). |
59+
| `register_hooks` | `(interceptor: GovernanceInterceptor) -> None` | Install your framework-specific monkey-patches, routing intercepted calls through `interceptor`. |
60+
| `unregister_hooks` | `() -> None` | Tear down every patch installed by `register_hooks`. **Must be idempotent** — calling it twice must not raise. |
61+
62+
### Implementation notes
63+
64+
- **`get_framework_name`** — must match the framework's import name so `is_available()`
65+
(which the registry calls before activating you) returns `True` only when the framework is
66+
actually installed. Empty/whitespace names raise `AdapterValidationError` during
67+
`register()`.
68+
- **`get_supported_versions`** — an empty list, or any empty range string, raises
69+
`AdapterValidationError`. These ranges document compatibility; the base class validates
70+
their *shape*, not the running framework version.
71+
- **`register_hooks`** — receives the live `interceptor`. Do the actual monkey-patching here
72+
(see [§5 Hook patterns](#5-hook-patterns)). Built-in adapters delegate to one or more
73+
internal *patch* objects exposing `apply()` / `revert()`; see ADR-0001 in
74+
[Development → ADR-0001](../development/adr/0001-hook-architecture.md). Lazily import the
75+
framework inside this method so importing your adapter never hard-fails when the framework
76+
is absent.
77+
- **`unregister_hooks`** — must revert patches (ideally in reverse install order) and be
78+
safe to call when no hooks are active. Guard with a "patched" flag so a double-call is a
79+
no-op. The validator enforces this by calling it twice.
80+
81+
Do **not** call `register_hooks` directly — call `adapter.register(interceptor)`, which runs
82+
`validate_registration()` first so contract errors surface *before* any hook is attached.
83+
84+
A complete, working version of all four methods is in the
85+
[template adapter][template]; here is the shape:
86+
87+
```python
88+
from agent_assembly.adapters.base import FrameworkAdapter, GovernanceInterceptor
89+
90+
91+
class MyFrameworkAdapter(FrameworkAdapter):
92+
def get_framework_name(self) -> str:
93+
return "my_framework"
94+
95+
def get_supported_versions(self) -> list[str]:
96+
return [">=1.0.0,<2.0.0"]
97+
98+
def register_hooks(self, interceptor: GovernanceInterceptor) -> None:
99+
# Install monkey-patches that route framework calls through interceptor.
100+
...
101+
102+
def unregister_hooks(self) -> None:
103+
# Revert all patches installed by register_hooks(); must be idempotent.
104+
...
105+
```
106+
107+
---
108+
109+
## 4. The `GovernanceInterceptor` contract and the events you emit
110+
111+
[`GovernanceInterceptor`][src-base] is a **structural** `typing.Protocol` with **no required
112+
methods**. It is a duck-typed marker: any object can be an interceptor. Adapters therefore
113+
call interceptor methods **defensively** — look the method up with `getattr` and only call it
114+
when it exists:
115+
116+
```python
117+
check = getattr(interceptor, "check_tool_call", None)
118+
if callable(check):
119+
decision = check(tool_name=tool_name, args=args)
120+
```
121+
122+
This keeps an adapter working against both the full governance interceptor wired by
123+
`init_assembly()` and a minimal stub used in tests.
124+
125+
### Conventional interceptor methods
126+
127+
Because the Protocol is open, there is **no fixed enum of event types**. The built-in
128+
adapters use the following method-name conventions; mirror the ones that fit your framework's
129+
execution model. Each returns either nothing (a pure notification) or a **decision** — a
130+
status string `"allow" | "deny" | "pending"`, or a mapping `{"status": ..., "reason": ...}`.
131+
132+
| Convention method | Direction | Purpose |
133+
| --- | --- | --- |
134+
| `check_tool_start` / `check_tool_call` | adapter → interceptor, returns decision | Pre-execution gate for a tool call; `deny` blocks it. |
135+
| `wait_for_tool_approval` | adapter → interceptor, returns decision | Block until a `pending` tool call is approved or rejected (human-in-the-loop). |
136+
| `get_pending_tool_approval_timeout_seconds` | adapter → interceptor | Configurable timeout for the approval wait. |
137+
| `record_result` / `on_tool_end` | adapter → interceptor, no return | Report a completed tool call's output for audit. |
138+
| `record` | adapter → interceptor, no return | Generic structured event (e.g. `action="task_start"`). |
139+
140+
!!! note "These are conventions, not a typed contract"
141+
The names above are what today's built-in adapters happen to call (see the CrewAI patch
142+
in `agent_assembly/adapters/crewai/patch.py`). Always `getattr`-guard them. A future
143+
release may formalise this into typed methods on the Protocol.
144+
145+
A **pending → approval** flow looks like:
146+
147+
```python
148+
decision = check_tool_start(...) # may return {"status": "pending"}
149+
if status == "pending":
150+
decision = wait_for_tool_approval(...) # blocks until resolved or times out
151+
# now decision is "allow" or "deny"
152+
```
153+
154+
---
155+
156+
## 5. Hook patterns
157+
158+
There are three ways to wire an adapter into a framework. Pick the one your framework
159+
supports best.
160+
161+
### Callback-based
162+
163+
Register a callback/handler object with the framework's own callback system (LangChain's
164+
`BaseCallbackHandler` is the canonical example; the `LangChainAdapter` builds one in
165+
`register_hooks` and exposes it via `get_callback_handler()`).
166+
167+
- **Pros:** first-class, framework-sanctioned, survives framework internal refactors, no
168+
reliance on private attributes.
169+
- **Cons:** only available if the framework *has* a callback system, and only sees the events
170+
that system emits — anything outside it is invisible.
171+
172+
### Wrapper-based
173+
174+
Wrap a public method on a framework class with `functools.wraps`, run your governance logic,
175+
then delegate to the original. This is what the [template adapter][template] does, and what
176+
the CrewAI adapter does to `BaseTool.run` / `Task.execute_sync`.
177+
178+
- **Pros:** works on any framework with a public entry point; you control exactly when the
179+
check runs relative to the real call; clean revert by restoring the saved original.
180+
- **Cons:** couples you to the wrapped method's signature; if the framework changes that
181+
method you must follow.
182+
183+
### Monkey-patch-based
184+
185+
Replace a function or attribute outright (a degenerate case of wrapping where you may target
186+
module-level functions or private internals).
187+
188+
- **Pros:** can reach things with no public hook at all.
189+
- **Cons:** most brittle; most likely to break on framework upgrades; easiest to leave the
190+
process in a bad state if revert is incomplete. Use only as a last resort, and always store
191+
the original + a "patched" flag so `unregister_hooks` is exact and idempotent.
192+
193+
Whichever you choose, `unregister_hooks` must fully undo it. The template's
194+
`_PATCHED_FLAG` / `_ORIGINAL_RUN_TOOL` sentinel pattern is the recommended approach.
195+
196+
---
197+
198+
## 6. Testing your adapter
199+
200+
!!! warning "There is no `AdapterTestHarness` fixture in the SDK today"
201+
Earlier planning referenced an `AdapterTestHarness` pytest fixture. It does **not** exist
202+
in the current codebase. Test your adapter with the two real mechanisms below; if a
203+
shared harness is added later this section will be updated.
204+
205+
### Contract validation (the in-tree validator)
206+
207+
The SDK ships a contract validator, exposed both as a CLI command and as a Python function.
208+
Run it against your adapter file or dotted module:
209+
210+
```bash
211+
uv run aasm adapter validate path/to/my_adapter.py
212+
# or: uv run aasm adapter validate my_package.my_adapter
213+
```
214+
215+
It runs seven checks — inheritance, all four abstract methods implemented, non-empty
216+
framework name, non-empty version ranges, `register_hooks` signature, `unregister_hooks`
217+
idempotency, and entry-point metadata (when a `pyproject.toml` is present). The reference
218+
template passes all seven:
219+
220+
```text
221+
Results: 7 passed, 0 failed, 7 total
222+
```
223+
224+
You can also call it programmatically in a unit test:
225+
226+
```python
227+
from agent_assembly.cli.adapter_validator import validate_adapter
228+
from my_package.my_adapter import MyFrameworkAdapter
229+
230+
231+
def test_adapter_passes_contract() -> None:
232+
results = validate_adapter(MyFrameworkAdapter, "my_package/my_adapter.py")
233+
assert all(r.passed for r in results), [r.message for r in results if not r.passed]
234+
```
235+
236+
### Lifecycle unit tests with a stub interceptor
237+
238+
Drive `register_hooks` / `unregister_hooks` with a tiny recording interceptor and a mocked
239+
(or fictional) framework class, then assert that an allowed call passes through, a denied call
240+
is blocked, and teardown restores the original. The template adapter's `__main__` demo is a
241+
runnable model of exactly this flow.
242+
243+
```python
244+
class RecordingInterceptor:
245+
def __init__(self) -> None:
246+
self.calls: list[str] = []
247+
248+
def check_tool_call(self, *, tool_name: str, args: dict) -> dict:
249+
self.calls.append(tool_name)
250+
return {"status": "deny" if tool_name == "shell" else "allow", "reason": None}
251+
252+
253+
def test_denies_shell() -> None:
254+
from examples.adapters.template_adapter import ExampleFramework, TemplateAdapter
255+
256+
adapter, interceptor, fw = TemplateAdapter(), RecordingInterceptor(), ExampleFramework()
257+
adapter.register_hooks(interceptor)
258+
try:
259+
assert fw.run_tool("shell", cmd="rm -rf /").startswith("[BLOCKED")
260+
assert "shell" in interceptor.calls
261+
finally:
262+
adapter.unregister_hooks()
263+
adapter.unregister_hooks() # idempotent
264+
```
265+
266+
Place real adapter tests under `test/unit/adapters/<framework_name>/` (lifecycle, with the
267+
framework mocked) and `test/integration/adapters/<framework_name>/` (a minimal end-to-end flow
268+
with the real framework imported), as described in
269+
[CONTRIBUTING.md](https://github.com/ai-agent-assembly/python-sdk/blob/master/CONTRIBUTING.md#adding-a-new-framework-adapter).
270+
271+
---
272+
273+
## 7. Publishing: entry points and naming
274+
275+
A community adapter ships as its own pip-installable package and is discovered at runtime via
276+
a Python **entry point** in the `agent_assembly.adapters` group. `AdapterRegistry` loads every
277+
entry point in that group, verifies the loaded object is a `FrameworkAdapter` subclass, and
278+
registers it — so `init_assembly()` activates it automatically when the framework is present.
279+
280+
### Naming convention
281+
282+
Name the distribution **`aa-adapter-<framework>`** (e.g. `aa-adapter-myframework`). The
283+
import package can be whatever you like, but the framework name returned by
284+
`get_framework_name()` must equal the framework's import name.
285+
286+
### `pyproject.toml` entry-point config
287+
288+
```toml
289+
[project]
290+
name = "aa-adapter-myframework"
291+
version = "0.1.0"
292+
dependencies = ["agent-assembly", "myframework>=1.0.0"]
293+
294+
[project.entry-points."agent_assembly.adapters"]
295+
myframework = "aa_adapter_myframework.adapter:MyFrameworkAdapter"
296+
```
297+
298+
The entry-point **value** is `module.path:ClassName`. The `aasm adapter validate`
299+
`entry_point_metadata` check confirms this points at your adapter class when it finds a
300+
`pyproject.toml` alongside the adapter.
301+
302+
### Verify discovery end to end
303+
304+
```bash
305+
pip install -e . # install your adapter package
306+
uv run aasm adapter validate aa_adapter_myframework.adapter # 7/7 PASS, incl. entry point
307+
python -c "from agent_assembly.adapters.registry import AdapterRegistry; \
308+
print([a.get_framework_name() for a in AdapterRegistry().get_available_adapters_by_priority()])"
309+
```
310+
311+
`get_available_adapters_by_priority()` triggers entry-point discovery; your framework name
312+
appears in the list once the framework is importable.
313+
314+
---
315+
316+
## 8. PR checklist
317+
318+
When contributing a built-in adapter back to this repo (community packages live in their own
319+
repos but should still meet this bar), confirm:
320+
321+
- [ ] Adapter subclasses `FrameworkAdapter` and implements all four abstract methods.
322+
- [ ] `uv run aasm adapter validate <path-or-module>` reports **7 passed, 0 failed**.
323+
- [ ] `unregister_hooks` is idempotent and fully reverts every patch.
324+
- [ ] Framework is imported **lazily** (inside `register_hooks` / availability helpers), so
325+
importing the adapter never fails when the framework is absent.
326+
- [ ] Unit tests under `test/unit/adapters/<framework_name>/` cover the patch install/revert
327+
lifecycle (framework mocked).
328+
- [ ] Integration test under `test/integration/adapters/<framework_name>/` exercises a minimal
329+
real flow.
330+
- [ ] `uv run ruff check .`, `uv run ruff format --check .`, and `uv run mypy agent_assembly`
331+
are clean.
332+
- [ ] Entry point declared in `pyproject.toml` under
333+
`[project.entry-points."agent_assembly.adapters"]` (for standalone packages).
334+
- [ ] Distribution named `aa-adapter-<framework>`; `get_framework_name()` matches the
335+
framework's import name.
336+
- [ ] Follows the repo
337+
[PR checklist](https://github.com/ai-agent-assembly/python-sdk/blob/master/CONTRIBUTING.md#pull-request-checklist).

docs/guides/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ do the [Quick Start](../quick-start.md) first.
66
| Guide | What it covers |
77
| --- | --- |
88
| [Handling allow/deny decisions](handling-decisions.md) | Catch a policy denial, the exception hierarchy, MCP-specific blocks, and observe (dry-run) mode. |
9+
| [Authoring a framework adapter](authoring-adapters.md) | Build, test, and publish a `FrameworkAdapter` for a new framework — interface reference, hook patterns, the contract validator, and entry-point publishing. |
910
| [Type checking](type-checking.md) | Use the SDK's shipped types (PEP 561) with mypy / Pyright in your own project. |
1011

1112
For runnable, end-to-end framework integrations — LangChain, LangGraph, CrewAI, OpenAI Agents,

0 commit comments

Comments
 (0)