Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
120 changes: 120 additions & 0 deletions adapters/langchain/DOCS-PAGE-DRAFT.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
---
title: "CTRLRun integration"
description: "Integrate with the CTRLRun middleware using LangChain Python."
---

<!--
DRAFT for langchain-ai/docs → src/oss/python/integrations/middleware/ctrlrun.mdx

Do not submit until `ctrlrun-langchain` is on PyPI: the details table renders live PyPI
version and download badges, and a page whose badges 404 is a page that gets closed.

Submitting also needs, in the same PR:
- src/docs.json — the page added to the Integrations nav under middleware
- the all-integrations table entry, per src/oss/python/integrations/middleware/index.mdx
-->

This guide provides a quick overview for getting started with the CTRLRun [middleware](/oss/langchain/middleware/overview/). CTRLRun checks every tool call your agent makes against a policy you write, before the call runs, and records what happened after.

## Overview

### Details

| Class | Package | Serializable | Downloads | Version |
| :--- | :--- | :---: | :---: | :---: |
| `CTRLRunMiddleware` | [`ctrlrun-langchain`](https://pypi.org/project/ctrlrun-langchain/) | beta/❌ | ![PyPI - Downloads](https://img.shields.io/pypi/dm/ctrlrun-langchain?style=flat-square&label=%20) | ![PyPI - Version](https://img.shields.io/pypi/v/ctrlrun-langchain?style=flat-square&label=%20) |

### Features

- **Policy-gated tool calls** — a refused call never reaches the tool, and the model is told which rule refused it
- **Once stays once** — an effect key executes at most once, across processes sharing a store
- **Unknown outcomes stay unknown** — a tool that raises leaves the effect unresolved rather than retried
- **Human approval** — a policy decision of `approve` holds the call for a person
- **A receipt for every decision** — requests, decisions and results, refusals included

---

## Setup

No account and no API key. CTRLRun is a library, and the policy is a file in your repository.

### Installation

```bash
pip install ctrlrun-langchain
```

### Write a policy

`ctrlrun.yaml` says how much autonomy each tool gets. Unknown tools are denied; there is no default-allow.

```yaml
schema: ctrlrun.policy/v2
actions:
lookup_order:
decision: allow
issue_refund:
effect: "refund:{payment_id}"
rules:
- when: { amount_gte: 0, amount_lte: 5000 } # up to €50.00, autonomous
decision: allow
- when: { amount_gte: 0, amount_lte: 500000 } # up to €5,000.00, ask a human
decision: approve
- decision: deny
```

## Instantiation

```python
from langchain.agents import create_agent
from ctrlrun import Control
from ctrlrun_langchain import CTRLRunMiddleware

control = Control.from_file("ctrlrun.yaml")

agent = create_agent(
model="gpt-5.5",
tools=[lookup_order, issue_refund],
middleware=[CTRLRunMiddleware(control)],
)
```

## Invocation

```python
import ctrlrun

with ctrlrun.context(agent="support-agent"):
result = agent.invoke({"messages": [{"role": "user", "content": "refund order 4471"}]})
```

Every protected call needs a principal: who is acting is an authorization input, so a call without one is denied before the policy is consulted. `ctrlrun.context(...)` supplies it in development; in production an identity provider verifies a credential instead.

## What the agent sees

The middleware uses [`wrap_tool_call`](/oss/langchain/middleware/custom), so a refused call is short-circuited — the tool is never invoked, and the model receives a `ToolMessage` explaining why:

```text
issue_refund amount=900000 CTRLRun refused this call: rule[2]. The tool did not run.
rm_rf CTRLRun refused this call: unknown_action. The tool did not run.
issue_refund amount=1000 (the tool runs)
issue_refund amount=1000 CTRLRun refused this call: this effect is already committed
```

That last line is the property worth knowing about. Because `handler` is the executor, the effect is reserved before the tool runs and committed from what it returned. Two agents sharing a store cannot both execute the same effect key, and a tool that raises leaves the outcome `AMBIGUOUS` rather than `FAILED` — so the retry is refused until a person resolves it, instead of becoming a double charge.

## Approvals

Where the policy says `approve`, the call is held and the model is told how to release it:

```text
CTRLRun is holding this call for a human. Approve it with 'ctrlrun approve apr_...',
then ask again. The tool did not run.
```

To have the human answered *inside* the run instead, use [`ctrlrun-langgraph`](https://pypi.org/project/ctrlrun-langgraph/), which routes the approval through LangGraph's `interrupt()` and re-presents the same proposal on resume.

## API reference

- [CTRLRun documentation](https://docs.ctrlrun.dev/)
- [`ctrlrun-langchain` source](https://github.com/CTRLRun/ctrlrun/tree/main/adapters/langchain)
128 changes: 128 additions & 0 deletions adapters/langchain/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# ctrlrun-langchain

Gate a LangChain agent's tool calls with a CTRLRun policy, through **LangChain's own
`wrap_tool_call`** middleware hook.

- **Supported kernel range:** `ctrlrun>=0.12,<0.13`
- **Supported framework range:** `langchain>=1.0,<2.0`
- **Primitive reused:** [`AgentMiddleware.wrap_tool_call`](https://docs.langchain.com/oss/langchain/middleware/custom), whose contract is *"Intercept execution and control when the handler is called. You decide if the handler is called zero times (short-circuit), once (normal flow), or multiple times."* Read 2026-09-16.
- **Framework shape:** the framework hands over the call itself.

## This is not the LangGraph adapter

`ctrlrun-langgraph` routes an `APPROVE` through `interrupt()`, reusing a human-in-the-loop
primitive. This is a different thing on a different surface: LangChain's middleware gives the
tool call itself to the middleware, so `handler` **is** the executor.

That closes the gap every observation-hook integration lives with. There is no separate outcome
report to arrive late, be swallowed, or never fire. What the tool did is what `handler` returned
or raised, in the same stack frame, and the receipt says so.

Three consequences, which are the reason to use this over a log-and-hope callback:

- **A denial never reaches the tool.** `handler` is not called, and the model gets a
`ToolMessage` saying the call was refused and which rule refused it.
- **Once stays once.** The effect is reserved before `handler` runs and committed from its
return, so two agents sharing a store cannot both execute the same effect key.
- **An unknown outcome stays unknown.** Anything `handler` raises that is not `NotExecuted`
leaves the effect `AMBIGUOUS`, and the next attempt is refused until a human resolves it,
rather than being retried into a double charge.

## You may not need this

`@protect` already covers any Python callable, including a LangChain tool, with no middleware
and no framework support at all. This buys one thing over it: the gate applies to **every** tool
the agent can reach, including tools you did not write and cannot decorate.

There is a third way in that is not an adapter at all: `ctrlrun gateway` puts the same
guarantees in front of an MCP tool server, in any language, with no agent change.

## Install

```console
$ pip install ctrlrun-langchain
```

## Use

The **operator** wires it, on the line where the policy, the store and the identity provider are
chosen. This middleware never constructs a `Control` (SPEC-v0.5 §2.3), so everything it must not
decide — the identity provider, the authority document, the environment, the mode — is chosen by
the person deploying it.

```python
from langchain.agents import create_agent
from ctrlrun import Control
from ctrlrun_langchain import CTRLRunMiddleware

control = Control.from_file("ctrlrun.yaml")

agent = create_agent(
model="gpt-5.5",
tools=[lookup, issue_refund],
middleware=[CTRLRunMiddleware(control)],
)
```

With a policy that says refunds up to €50 are autonomous and the rest are denied:

```yaml
schema: ctrlrun.policy/v2
actions:
lookup:
decision: allow
issue_refund:
effect: "refund:{payment_id}"
rules:
- when: { amount_gte: 0, amount_lte: 5000 }
decision: allow
- decision: deny
```

the agent's own tool calls are decided before they run:

```text
lookup the tool runs
issue_refund amount=900000 CTRLRun refused this call: rule[1]. The tool did not run.
rm_rf CTRLRun refused this call: unknown_action. The tool did not run.
issue_refund amount=1000 the tool runs
issue_refund amount=1000 (again) CTRLRun refused this call: this effect is already committed
```

Nothing is default-allow: a tool the policy does not name is refused, which is why `rm_rf` above
never reaches `handler`.

**Every protected call needs a principal.** In production that is an identity provider that
verifies a credential; in development it is `with ctrlrun.context(agent="support-agent"):`
around the agent invocation. Without one the action is denied before the policy is consulted:

```text
ActionDenied: lookup: no principal is available; wrap the call in
'with ctrlrun.context(agent=...)', or install an identity provider that answers
```

That is fail-closed and deliberate: who is acting is an authorization input, and a library that
accepted a self-asserted principal would be accepting the agent's word for its own authority.

## Approvals

Where the policy says `approve`, this middleware refuses the call and tells the model the
request id, rather than blocking the agent while a human deliberates:

```text
CTRLRun is holding this call for a human. Approve it with 'ctrlrun approve apr_...',
then ask again. The tool did not run.
```

If you want the human answered *inside* the run instead, that is what `ctrlrun-langgraph` is
for: LangGraph's `interrupt()` suspends the graph, and the resumed run re-presents the same
proposal under the granted approval.

## What this does not do

- It does not decide anything. The policy does, and the policy is the operator's file.
- It does not grant approvals. `InterruptApprovalProvider`, `ctrlrun approve` and the webhook
are the only places a grant is written, and this is not one of them.
- It does not supply a principal, and it never reads one from agent state.

Apache-2.0, same as the kernel.
43 changes: 43 additions & 0 deletions adapters/langchain/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# ctrlrun-langchain — a separate distribution on the adapters track (SPEC-v0.5 §6).
#
# `pip install ctrlrun` must not grow. This depends on `ctrlrun`, never the reverse, and the
# `ctrlrun` wheel and sdist contain no `adapters/` path (T136).
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "ctrlrun-langchain"
version = "1.0.0"
description = "Gate a LangChain agent's tool calls with a CTRLRun policy, through wrap_tool_call."
readme = "README.md"
requires-python = ">=3.11"
authors = [{name = "Arpan Ghoshal", email = "contact@arpanghoshal.com"}]
license = "Apache-2.0"
keywords = ["langchain", "ctrlrun", "middleware", "guardrails", "agent", "human-in-the-loop"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Topic :: Software Development :: Libraries",
"Topic :: Security",
]
# The two ranges SPEC-v0.5 §6.3 requires. `wrap_tool_call` is a LangChain 1.x surface, and the
# README states the same two, which T137's sibling asserts.
dependencies = [
"ctrlrun>=0.12,<0.13",
"langchain>=1.0,<2.0",
]

[project.urls]
Homepage = "https://github.com/CTRLRun/ctrlrun"
Repository = "https://github.com/CTRLRun/ctrlrun"

[tool.setuptools.packages.find]
where = ["src"]

# `langchain` ships no stubs mypy can resolve from outside its own tree. CI type-checks `src`
# only (scripts/check.sh), so this is for anyone running mypy over the adapter directly.
[[tool.mypy.overrides]]
module = ["langchain.*", "langchain_core.*"]
ignore_missing_imports = true
Loading