Skip to content
31 changes: 23 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
[![Code style: ruff](https://img.shields.io/badge/style-ruff-261230?logo=ruff&logoColor=white)](https://github.com/astral-sh/ruff)
[![Type-checked: mypy](https://img.shields.io/badge/types-mypy-2a6db2?logo=python&logoColor=white)](https://mypy-lang.org/)

Python SDK for **AI Agent Assembly** — a governance-native runtime for AI agents. One `init_assembly()` call wires your agent into the policy gateway and applies pre-execution allow/deny on tool calls, without changing how the agent itself is written.
Python SDK for **AI Agent Assembly** — a governance-native runtime for AI agents. `init_assembly()` registers your agent with the policy gateway and installs the framework adapters; the adapters apply pre-execution allow/deny on tool calls. **For LangChain, `init_assembly()` alone does not govern tool calls: you must pass the SDK's callback handler** (see [Quick Start](#quick-start)). For other frameworks, see [Framework support](docs/examples/framework-support.md) and that framework's example page for what it requires.

> **The SDK hands records to the runtime; it does not give you an audit trail.** The framework adapters offer a governed call's outcome to an audit hook on the governance interceptor, and over a connected runtime that hook writes it to the native event channel — the same one agent registration uses. That is a handoff, **not** evidence: the send is unacknowledged, so this SDK cannot tell you the record arrived, and does not claim it did. Downstream, [AAASM-5783](https://lightning-dust-mite.atlassian.net/browse/AAASM-5783) is open on `report_event` payloads reaching neither the live stream nor the durable entry — until it lands, no SDK can claim ADR 0033 §6 *Observed*. Without a reachable runtime there is no channel at all and nothing is emitted.
>
Expand Down Expand Up @@ -162,7 +162,13 @@ with init_assembly(
api_key="dev-key",
agent_id="quickstart-agent",
mode="sdk-only",
):
) as ctx:
# init_assembly() does NOT hook LangChain by itself. LangChain only calls a
# callback handler it is given, so pass the SDK's handler on every run.
adapter = next((a for a in ctx.adapters if a.get_framework_name() == "langchain"), None)
if adapter is None:
raise RuntimeError("LangChain adapter is not active: is `langchain` importable?")
handler = adapter.get_callback_handler()
llm = FakeListLLM(responses=[
"Thought: I should look up the user.\nAction: whoami\nAction Input: alice\n",
"Thought: I have the answer.\nFinal Answer: alice is in engineering\n",
Expand All @@ -172,14 +178,21 @@ with init_assembly(
"Use the tools.\n{tools}\nTool names: {tool_names}\nQ: {input}\n{agent_scratchpad}"
)
executor = AgentExecutor(agent=create_react_agent(llm, tools, prompt), tools=tools, max_iterations=2)
print(executor.invoke({"input": "Which team is alice on?"})["output"])
result = executor.invoke(
{"input": "Which team is alice on?"},
config={"callbacks": [handler]}, # <- this is what puts the policy gate in front of each tool
)
print(result["output"])
```

What this does:

1. `init_assembly()` registers the agent with the gateway and auto-loads the LangChain adapter — each tool call the wrapped agent makes from now on goes through the policy gate.
2. The `FakeListLLM` replays canned responses so the example runs **offline** with no real LLM.
3. The `with` block tears down the gateway connection and unwinds adapter hooks on exit.
1. `init_assembly()` registers the agent with the gateway and activates the LangChain adapter, which creates one `AssemblyCallbackHandler` (`adapter.get_callback_handler()`). It installs **no** hook into LangChain itself.
2. `config={"callbacks": [handler]}` is what routes each tool call through the policy gate. The handler runs before the tool and raises `ToolExecutionBlockedError` on a deny, so a denied tool's body does not run. **Without this argument a LangChain tool runs regardless of policy**, even though `init_assembly()` succeeded and reports the adapter as active.
3. The `FakeListLLM` replays canned responses so the example runs **offline** with no real LLM.
4. The `with` block tears down the gateway connection and unwinds adapter hooks on exit.

> **What `init_assembly()` alone does not enforce.** It does not govern LangChain tool calls (pass the handler as above), and an offline `sdk-only` run has no gateway policy authority to ask unless a native runtime is reachable. `test/unit/test_quickstart_langchain_denied_tool_wiring.py` pins the wiring: a denied tool run with the handler leaves no side effect, and the same tool run without it executes.

## Public API

Expand All @@ -190,8 +203,10 @@ What this does:
Agent **registration** and the **pre-execution policy check** are not REST
calls: the SDK goes through the native `aa-sdk-client` shim to the core over
gRPC/UDS (ADR 0004) — it never calls a core HTTP endpoint directly for those.
`init_assembly` registers the agent on startup, and a tool call is checked via
the native `query_policy` so a `deny` blocks it before the tool runs.
`init_assembly` registers the agent on startup, and a tool call that reaches an
adapter hook (for LangChain, only when the callback handler is passed in
`callbacks`) is checked via the native `query_policy` so a `deny` blocks it
before the tool runs.

### Control-plane routing

Expand Down
8 changes: 5 additions & 3 deletions docs/concepts/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The SDK governs *third-party* agent frameworks (LangChain, LangGraph, CrewAI, Op

- `get_framework_name() -> str` — the framework's import name (e.g. `"langchain"`).
- `get_supported_versions() -> list[str]` — PEP 440 version specifiers.
- `register_hooks(interceptor) -> None` — install monkey-patches that route framework calls through the governance interceptor.
- `register_hooks(interceptor) -> None` — install monkey-patches that route framework calls through the governance interceptor (except LangChain, which only gets a callback handler; see below).
- `unregister_hooks() -> None` — revert all patches in reverse install order. Must be idempotent.

Adapter authors target this contract and nothing else. The public boundary is intentionally narrow so that adding a new framework does not require changes to the gateway client, the policy interceptor, or the registry's selection logic.
Expand All @@ -37,9 +37,11 @@ Priority matters because two frameworks can coexist in the same process — e.g.

### Per-framework patches — the actual monkey-patching

(LangChain is the exception: its adapter patches nothing; see the first bullet below.)

Each adapter's `register_hooks()` delegates to one or more `RuntimePatch` objects (a `Protocol` defined in `agent_assembly.core.assembly`). A `RuntimePatch` knows how to apply and revert a single monkey-patch on a specific framework class or function. Examples:

- `agent_assembly.adapters.langchain.patch.LangChainPatch` — patches `BaseTool._run` and `BaseTool._arun` so every tool invocation passes through the governance gate.
- `agent_assembly.adapters.langchain.patch.LangChainPatch` — does **not** patch `BaseTool`. It only creates and stores the `AssemblyCallbackHandler` (`runtime.auto_inject_callback_handler`); a LangChain tool is governed only when that handler is passed as `callbacks=[...]`.
- `agent_assembly.adapters.langchain.langgraph_patch.patch_stategraph_compile` — wraps `StateGraph.compile()` so the resulting graph's nodes are wrapped before any invocation.
- `agent_assembly.adapters.crewai.patch.CrewAIPatch` — analogous wrappers for CrewAI's tool invocation entry points.

Expand All @@ -54,7 +56,7 @@ flowchart LR
Registry["AdapterRegistry<br/>get_available_adapters_by_priority()"]
Adapter["FrameworkAdapter<br/>(LangChainAdapter, CrewAIAdapter, …)"]
Patch["RuntimePatch<br/>(LangChainPatch, CrewAIPatch, …)"]
Framework["Third-party framework<br/>(BaseTool._run, StateGraph.compile, …)"]
Framework["Third-party framework<br/>(e.g. StateGraph.compile; LangChain is not patched)"]
Interceptor["GovernanceInterceptor"]
Gateway["Gateway / policy engine"]

Expand Down
5 changes: 3 additions & 2 deletions docs/concepts/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ Assembly**. It does this with a three-layer pattern:
each one's `is_available()` to see if its framework is importable in this process, and
returns the available ones in priority order.
3. **Per-framework patches** — each adapter's `register_hooks()` installs one or more
`RuntimePatch` objects that monkey-patch the framework's actual tool-call entry points
(e.g. LangChain's `BaseTool._run`).
`RuntimePatch` objects. Some adapters patch framework entry points (for example the Agno adapter patches
`FunctionCall.execute` and the LangGraph adapter wraps `StateGraph.compile`); the LangChain
adapter does not patch `BaseTool` — it only creates a callback handler that you pass as `callbacks`.

`init_assembly()` calls `AdapterRegistry.get_available_adapters_by_priority()` exactly once at
startup — this is the **single detection path** (see [ADR-0001](../development/adr/0001-hook-architecture.md)).
Expand Down
34 changes: 25 additions & 9 deletions docs/examples/framework-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,22 @@ with init_assembly(
...
```

Inside the `with` block, the relevant adapter has monkey-patched the framework's tool-call
entry points. On exit, all hooks are removed in reverse order.
Inside the `with` block, each importable framework's adapter has registered its hooks. What a
hook governs depends on the framework: LangChain gets only a callback handler that you must pass
as `callbacks` (see the LangChain quick start below), so run your agent with that wiring. On exit,
all hooks are removed in reverse order.

## Supported frameworks

Each framework below ships an adapter under `agent_assembly/adapters/`. The
**Runnable example** column reflects whether a complete, validated example exists today —
**Runnable example** column reflects whether a complete, validated example exists today (the LangChain row states its own, narrower status) —
either inline in this guide or as a curated script in the central
[`examples`](https://github.com/ai-agent-assembly/examples/tree/master/python)
repository.

| Framework | Adapter | Runnable example |
| --- | --- | --- |
| LangChain | `agent_assembly.adapters.langchain` | ✅ Validated — see [Quick start](#langchain-quick-start) below (runs offline against a mock LLM), plus the [LangChain basic agent](langchain-basic-agent.md) and [LangChain research agent](langchain-research-agent.md) pages. |
| LangChain | `agent_assembly.adapters.langchain` | Example pages present; the callback wiring is pinned by a unit test on a bare tool, but the `AgentExecutor` quick-start snippet has not been run — see [Quick start](#langchain-quick-start) below, plus the [LangChain basic agent](langchain-basic-agent.md) and [LangChain research agent](langchain-research-agent.md) pages. |
| LangGraph | `agent_assembly.adapters.langgraph` | ✅ Validated — see [LangGraph node-level governance](langgraph.md) (state-graph governance; wraps the compiled `StateGraph`). |
| CrewAI | `agent_assembly.adapters.crewai` | ✅ Validated — see [CrewAI research crew](crewai-research-crew.md) (multi-agent crew). |
| OpenAI Agents | `agent_assembly.adapters.openai_agents` | ✅ Validated — see [OpenAI Agents SDK](openai-agents-sdk.md). |
Expand All @@ -58,7 +60,9 @@ repository.

!!! note "Adapter present vs. example present"
Every framework above has an **adapter that is implemented and registered** — `init_assembly()`
will detect and hook the framework whenever it's installed, regardless of the example status.
activates it whenever the framework is importable, regardless of the example status. What an
adapter then governs differs per framework: for LangChain, `init_assembly()` only creates the
callback handler and you must pass it as `callbacks` (see the quick start below).
A ✅ row additionally has a curated, end-to-end runnable example you can clone and run. A ⏳ row
(currently only MCP servers) means the adapter ships but a runnable example is not yet vendored.
Contributions adding one are welcome; see
Expand Down Expand Up @@ -86,7 +90,13 @@ with init_assembly(
api_key="dev-key",
agent_id="quickstart-agent",
mode="sdk-only",
):
) as ctx:
# init_assembly() does NOT hook LangChain by itself. LangChain only calls a
# callback handler it is given, so pass the SDK's handler on every run.
adapter = next((a for a in ctx.adapters if a.get_framework_name() == "langchain"), None)
if adapter is None:
raise RuntimeError("LangChain adapter is not active: is `langchain` importable?")
handler = adapter.get_callback_handler()
llm = FakeListLLM(responses=[
"Thought: I should look up the user.\nAction: whoami\nAction Input: alice\n",
"Thought: I have the answer.\nFinal Answer: alice is in engineering\n",
Expand All @@ -96,11 +106,17 @@ with init_assembly(
"Use the tools.\n{tools}\nTool names: {tool_names}\nQ: {input}\n{agent_scratchpad}"
)
executor = AgentExecutor(agent=create_react_agent(llm, tools, prompt), tools=tools, max_iterations=2)
print(executor.invoke({"input": "Which team is alice on?"})["output"])
result = executor.invoke(
{"input": "Which team is alice on?"},
config={"callbacks": [handler]}, # <- this is what puts the policy gate in front of each tool
)
print(result["output"])
```

The same snippet lives in the repository [README](https://github.com/ai-agent-assembly/python-sdk#quick-start)
and is the canonical, validated example. See the [`examples/`](https://github.com/ai-agent-assembly/python-sdk/tree/master/examples)
Without `config={"callbacks": [handler]}` a LangChain tool runs regardless of policy. The wiring
mechanism is pinned by `test/unit/test_quickstart_langchain_denied_tool_wiring.py` (a bare
`langchain_core` tool; this exact `AgentExecutor` snippet has not been run). The same snippet
lives in the repository [README](https://github.com/ai-agent-assembly/python-sdk#quick-start). See the [`examples/`](https://github.com/ai-agent-assembly/python-sdk/tree/master/examples)
directory for additional in-repo runnable scripts and their status.

## More runnable examples
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/langchain-basic-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Integrate Agent Assembly with LangChain to enforce governance policy on tool cal

## What this example demonstrates

This example wires Agent Assembly governance into a LangChain agent so that each tool call the agent makes is checked against policy *before* the tool runs. It covers:
This example wires Agent Assembly governance into a LangChain agent so that each tool call the agent makes is checked against policy *before* the tool runs. The example calls the `AssemblyCallbackHandler` explicitly; LangChain does not govern a tool unless the handler is passed in `callbacks`. It covers:

- Initializing Agent Assembly with `init_assembly()`.
- Wrapping LangChain tools with `AssemblyCallbackHandler` + a governance interceptor.
Expand Down
9 changes: 5 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Agent Assembly Python SDK

**In plain terms:** this SDK is how a Python agent asks for permission before it acts.
You wrap your existing agent in one `init_assembly()` call, and from that point on each
tool call your agent makes is checked against a governance policy — allowed or denied —
You wrap your existing agent in one `init_assembly()` call, and each
tool call that reaches an installed adapter hook is checked against a governance policy — allowed or denied —
without you rewriting a single line of the agent itself. Over a connected runtime the
outcome of each call is also handed to the runtime's event channel (see below).

Expand All @@ -24,8 +24,9 @@ You keep writing agents the way you always have. The SDK is the seatbelt you cli

The SDK does not replace your agent framework — it **intercepts** it. When you call
`init_assembly()`, the SDK detects which supported framework is importable in your process
and monkey-patches that framework's tool-call entry points so each call passes through a
policy gate first. Today it ships adapters for **LangChain, LangGraph, CrewAI, OpenAI Agents,
and installs its adapter. **For LangChain,** `init_assembly()` only creates the callback handler,
and LangChain runs a tool ungoverned unless you pass it as `callbacks=[...]` (see the README
Quick Start). For other frameworks, see [Framework support](examples/framework-support.md) and that framework's example page for what it requires. Today it ships adapters for **LangChain, LangGraph, CrewAI, OpenAI Agents,
Pydantic AI, Google ADK, and MCP servers**.

```mermaid
Expand Down
Loading
Loading