|
| 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). |
0 commit comments