diff --git a/SEO.md b/SEO.md index c5f2690..f449404 100644 --- a/SEO.md +++ b/SEO.md @@ -75,6 +75,7 @@ that page's frontmatter, never here. | `docs/guides/run-on-postgres` | ctrlrun postgres | Use Postgres when workers on more than one host must share one store. | | `docs/guides/verify-in-ci` | verify agent safety configuration CI | `ctrlrun verify` runs the kernel's own failure scenarios against your policy. | | `docs/guides/export-to-opentelemetry` | opentelemetry AI agent actions | `OTelEventSink` turns every action into one OpenTelemetry span. | +| `docs/guides/langchain-middleware` | langchain middleware tool call policy | CTRLRun checks every tool call your agent makes against a policy you write, before the call runs, and records what happened after. | | `docs/guides/langgraph-adapter` | langgraph interrupt human approval | `ctrlrun-langgraph` makes an `approve` decision surface as a LangGraph `interrupt()`. | | `docs/guides/openai-agents-adapter` | openai agents sdk tool approval | `ctrlrun-openai-agents` makes an `approve` decision stop the run with the SDK's own `ToolApprovalItem`. | | `docs/cookbook/index` | AI agent policy examples | Each recipe is a situation an agent is put in. | diff --git a/docs.json b/docs.json index aa438b0..ced1ba3 100644 --- a/docs.json +++ b/docs.json @@ -109,6 +109,7 @@ "docs/guides/verify-in-ci", "docs/guides/export-to-opentelemetry", "docs/adapters", + "docs/guides/langchain-middleware", "docs/guides/langgraph-adapter", "docs/guides/openai-agents-adapter" ] @@ -611,6 +612,11 @@ "destination": "/docs/guides/export-to-opentelemetry", "permanent": true }, + { + "source": "/guides/langchain-middleware", + "destination": "/docs/guides/langchain-middleware", + "permanent": true + }, { "source": "/guides/langgraph-adapter", "destination": "/docs/guides/langgraph-adapter", diff --git a/docs/adapters.md b/docs/adapters.md index 0d4abe8..7f7c99e 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -26,6 +26,7 @@ rather than growing by one each time a framework is named. | Distribution | Framework | Shape | Reuses | Binding | |---|---|---|---|---| +| `ctrlrun-langchain` | LangChain | the call itself is handed over | `AgentMiddleware.wrap_tool_call`, so `handler` **is** the executor | **prevention** | | `ctrlrun-langgraph` | LangGraph | resumed in place | `interrupt()` + `Command(resume=...)`, and the checkpointer | **prevention** | | `ctrlrun-openai-agents` | OpenAI Agents SDK | decided before invocation | the tool-approval interruption | **attribution** | diff --git a/docs/guides/langchain-middleware.mdx b/docs/guides/langchain-middleware.mdx new file mode 100644 index 0000000..924b40a --- /dev/null +++ b/docs/guides/langchain-middleware.mdx @@ -0,0 +1,110 @@ +--- +title: "Use the LangChain middleware" +description: "Gate every LangChain tool call with ctrlrun-langchain, through wrap_tool_call, so a refused call never runs and every decision leaves a receipt." +--- + +This guide provides a quick overview for getting started with the CTRLRun [middleware](https://docs.langchain.com/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`](https://docs.langchain.com/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. + +## Next + +- [Use the LangGraph adapter](/docs/guides/langgraph-adapter): approvals answered inside the run, through `interrupt()`. +- [Adapters](/docs/adapters): the three ways in, and why this one is prevention. +- [The middleware's README](https://github.com/CTRLRun/ctrlrun/blob/main/adapters/langchain/README.md) · [Get started](/docs/get-started/quickstart) · [Why](/docs/why).