ctrlrun-langchain 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The CTRLRun contributors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Gate a LangChain agent's tool calls with a CTRLRun policy, through `wrap_tool_call`.
|
|
4
|
+
|
|
5
|
+
**This is not the LangGraph adapter, and it is not an adapter at all in SPEC-v0.5 §2's sense.**
|
|
6
|
+
`ctrlrun-langgraph` exists to route an `APPROVE` through `interrupt()`; it reuses a framework's
|
|
7
|
+
human-in-the-loop primitive and contributes two lines. This is the other thing entirely: a
|
|
8
|
+
`wrap_tool_call` middleware, where the framework hands over the call itself.
|
|
9
|
+
|
|
10
|
+
The distinction matters because of what `wrap_tool_call` is. LangChain's own documentation:
|
|
11
|
+
|
|
12
|
+
Intercept execution and control when the handler is called. You decide if the handler is
|
|
13
|
+
called zero times (short-circuit), once (normal flow), or multiple times (retry logic).
|
|
14
|
+
|
|
15
|
+
So `handler` **is** the tool call. That makes it the executor `Control.execute` has always
|
|
16
|
+
wanted, and it closes the gap every observation-hook integration has to live with: there is no
|
|
17
|
+
separate outcome report to arrive late, be swallowed, or never fire. What the tool did is what
|
|
18
|
+
`handler` returned or raised, in the same stack frame, and the receipt says so.
|
|
19
|
+
|
|
20
|
+
Three consequences worth stating, because they are the reason to use this over a log-and-hope
|
|
21
|
+
callback:
|
|
22
|
+
|
|
23
|
+
- **A denial never reaches the tool.** The handler is not called, and the model gets a
|
|
24
|
+
`ToolMessage` saying the call was refused and why.
|
|
25
|
+
- **`once stays once` is real here.** The effect is reserved before `handler` runs and committed
|
|
26
|
+
from its return, so two agents sharing a store cannot both execute the same effect key.
|
|
27
|
+
- **An unknown outcome stays unknown.** If `handler` raises something that is not `NotExecuted`,
|
|
28
|
+
the effect is `AMBIGUOUS` and the next attempt is refused until a human resolves it, rather
|
|
29
|
+
than being retried into a double charge.
|
|
30
|
+
|
|
31
|
+
**You may not need this.** `@protect` already covers any Python callable, including a LangChain
|
|
32
|
+
tool, with no middleware and no framework support. This buys one thing: the gate applies to
|
|
33
|
+
*every* tool the agent can reach, including tools you did not write and cannot decorate.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from collections.abc import Callable, Mapping
|
|
39
|
+
from typing import Any, Final
|
|
40
|
+
|
|
41
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
42
|
+
|
|
43
|
+
from ctrlrun import (
|
|
44
|
+
Action,
|
|
45
|
+
ActionDenied,
|
|
46
|
+
AmbiguousEffect,
|
|
47
|
+
ApprovalRequired,
|
|
48
|
+
Control,
|
|
49
|
+
DuplicateEffect,
|
|
50
|
+
)
|
|
51
|
+
from ctrlrun.effect import resolve_resource
|
|
52
|
+
|
|
53
|
+
__all__ = ["CTRLRunMiddleware"]
|
|
54
|
+
|
|
55
|
+
#: What the model is told when CTRLRun refuses. A refusal is the statement that the tool did
|
|
56
|
+
#: not run, which is not the same as the tool failing, so it names the rule rather than
|
|
57
|
+
#: reporting an error the tool never produced.
|
|
58
|
+
_REFUSED: Final = "CTRLRun refused this call: {reason}. The tool did not run."
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _tool_message(request: Any, content: str) -> Any:
|
|
62
|
+
"""The refusal, in the shape LangChain's own limit middleware uses."""
|
|
63
|
+
from langchain_core.messages import ToolMessage
|
|
64
|
+
|
|
65
|
+
call = request.tool_call
|
|
66
|
+
return ToolMessage(
|
|
67
|
+
content=content,
|
|
68
|
+
tool_call_id=call["id"],
|
|
69
|
+
name=call.get("name"),
|
|
70
|
+
status="error",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CTRLRunMiddleware(AgentMiddleware):
|
|
75
|
+
"""`AgentMiddleware` that runs every tool call through a `Control`.
|
|
76
|
+
|
|
77
|
+
The **operator** constructs it, on the line where the policy, the store and the identity
|
|
78
|
+
provider are chosen. This class never constructs a `Control`: everything it must not
|
|
79
|
+
decide is decided by the person deploying it (SPEC-v0.5 §2.3).
|
|
80
|
+
|
|
81
|
+
control = Control(policy, store, identity=..., authority=...)
|
|
82
|
+
agent = create_agent(model, tools=[...], middleware=[CTRLRunMiddleware(control)])
|
|
83
|
+
|
|
84
|
+
`resource` and `effect` are templates over the tool's arguments, exactly as `@protect`'s
|
|
85
|
+
are, and the policy's own entries are used where none is given here.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
control: Control,
|
|
91
|
+
*,
|
|
92
|
+
resource: str | None = None,
|
|
93
|
+
effect: str | None = None,
|
|
94
|
+
task: str | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
super().__init__()
|
|
97
|
+
self._control = control
|
|
98
|
+
self._resource = resource
|
|
99
|
+
self._effect = effect
|
|
100
|
+
self._task = task
|
|
101
|
+
|
|
102
|
+
# -- the hook ---------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def wrap_tool_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
|
|
105
|
+
"""Decide, then run the handler as the executor, then record what it did."""
|
|
106
|
+
call = request.tool_call
|
|
107
|
+
name = call.get("name") or ""
|
|
108
|
+
arguments: Mapping[str, Any] = call.get("args") or {}
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
action = self._action(name, arguments)
|
|
112
|
+
except Exception as exc: # a policy that cannot name this tool is a refusal
|
|
113
|
+
return _tool_message(request, _REFUSED.format(reason=f"could not be evaluated: {exc}"))
|
|
114
|
+
|
|
115
|
+
returned: list[Any] = []
|
|
116
|
+
|
|
117
|
+
def executor() -> Any:
|
|
118
|
+
# `handler` is the tool call. Its return value is the outcome, and anything it
|
|
119
|
+
# raises that is not `NotExecuted` leaves the effect AMBIGUOUS, which is the
|
|
120
|
+
# honest state for a call whose result nobody established.
|
|
121
|
+
result = handler(request)
|
|
122
|
+
returned.append(result)
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
self._control.execute(action, executor, self._effect_key(name, arguments))
|
|
127
|
+
except ActionDenied as denied:
|
|
128
|
+
return _tool_message(request, _REFUSED.format(reason=denied.reason))
|
|
129
|
+
except ApprovalRequired as pending:
|
|
130
|
+
return _tool_message(
|
|
131
|
+
request,
|
|
132
|
+
f"CTRLRun is holding this call for a human. Approve it with "
|
|
133
|
+
f"'ctrlrun approve {pending.request_id}', then ask again. The tool did not run.",
|
|
134
|
+
)
|
|
135
|
+
except DuplicateEffect as duplicate:
|
|
136
|
+
return _tool_message(
|
|
137
|
+
request,
|
|
138
|
+
f"CTRLRun refused this call: this effect is already {duplicate.state} "
|
|
139
|
+
f"({duplicate.effect_key}). The tool did not run.",
|
|
140
|
+
)
|
|
141
|
+
except AmbiguousEffect as ambiguous:
|
|
142
|
+
return _tool_message(
|
|
143
|
+
request,
|
|
144
|
+
f"CTRLRun refused this call: the outcome of {ambiguous.effect_key} was never "
|
|
145
|
+
f"established, so a retry is unsafe. Resolve it with "
|
|
146
|
+
f"'ctrlrun resolve {ambiguous.effect_key}'. The tool did not run.",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return returned[0] if returned else None
|
|
150
|
+
|
|
151
|
+
async def awrap_tool_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
|
|
152
|
+
"""Async agents reach the same decision through the same `Control`.
|
|
153
|
+
|
|
154
|
+
Deliberately not a parallel implementation. `Control.execute` is synchronous and owns
|
|
155
|
+
the reservation, so a second async path would be a second place the once-only rule is
|
|
156
|
+
enforced, and a second place to get it wrong.
|
|
157
|
+
"""
|
|
158
|
+
import anyio
|
|
159
|
+
|
|
160
|
+
result: list[Any] = []
|
|
161
|
+
|
|
162
|
+
def run() -> None:
|
|
163
|
+
result.append(self.wrap_tool_call(request, lambda r: anyio.from_thread.run(handler, r)))
|
|
164
|
+
|
|
165
|
+
await anyio.to_thread.run_sync(run)
|
|
166
|
+
return result[0]
|
|
167
|
+
|
|
168
|
+
# -- internals --------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def _action(self, name: str, arguments: Mapping[str, Any]) -> Action:
|
|
171
|
+
"""The action this tool call proposes.
|
|
172
|
+
|
|
173
|
+
The principal comes from `Control.resolve_principal`, never from the agent's state: a
|
|
174
|
+
principal supplied by the caller is not an authorization input (SPEC-v0.3 §4.2).
|
|
175
|
+
"""
|
|
176
|
+
principal = self._control.resolve_principal(name)
|
|
177
|
+
template = (
|
|
178
|
+
self._resource
|
|
179
|
+
if self._resource is not None
|
|
180
|
+
else (self._control.policy.resource_template(name))
|
|
181
|
+
)
|
|
182
|
+
return Action(
|
|
183
|
+
name=name,
|
|
184
|
+
arguments=dict(arguments),
|
|
185
|
+
principal=principal,
|
|
186
|
+
resource=None if template is None else resolve_resource(template, arguments),
|
|
187
|
+
environment=self._control.environment,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def _effect_key(self, name: str, arguments: Mapping[str, Any]) -> str | None:
|
|
191
|
+
template = (
|
|
192
|
+
self._effect
|
|
193
|
+
if self._effect is not None
|
|
194
|
+
else (self._control.policy.effect_template(name))
|
|
195
|
+
)
|
|
196
|
+
return None if template is None else resolve_resource(template, arguments)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ctrlrun-langchain
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Gate a LangChain agent's tool calls with a CTRLRun policy, through wrap_tool_call.
|
|
5
|
+
Author-email: Arpan Ghoshal <contact@arpanghoshal.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/CTRLRun/ctrlrun
|
|
8
|
+
Project-URL: Repository, https://github.com/CTRLRun/ctrlrun
|
|
9
|
+
Keywords: langchain,ctrlrun,middleware,guardrails,agent,human-in-the-loop
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: ctrlrun<0.13,>=0.12
|
|
18
|
+
Requires-Dist: langchain<2.0,>=1.0
|
|
19
|
+
|
|
20
|
+
# ctrlrun-langchain
|
|
21
|
+
|
|
22
|
+
Gate a LangChain agent's tool calls with a CTRLRun policy, through **LangChain's own
|
|
23
|
+
`wrap_tool_call`** middleware hook.
|
|
24
|
+
|
|
25
|
+
- **Supported kernel range:** `ctrlrun>=0.12,<0.13`
|
|
26
|
+
- **Supported framework range:** `langchain>=1.0,<2.0`
|
|
27
|
+
- **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.
|
|
28
|
+
- **Framework shape:** the framework hands over the call itself.
|
|
29
|
+
|
|
30
|
+
## This is not the LangGraph adapter
|
|
31
|
+
|
|
32
|
+
`ctrlrun-langgraph` routes an `APPROVE` through `interrupt()`, reusing a human-in-the-loop
|
|
33
|
+
primitive. This is a different thing on a different surface: LangChain's middleware gives the
|
|
34
|
+
tool call itself to the middleware, so `handler` **is** the executor.
|
|
35
|
+
|
|
36
|
+
That closes the gap every observation-hook integration lives with. There is no separate outcome
|
|
37
|
+
report to arrive late, be swallowed, or never fire. What the tool did is what `handler` returned
|
|
38
|
+
or raised, in the same stack frame, and the receipt says so.
|
|
39
|
+
|
|
40
|
+
Three consequences, which are the reason to use this over a log-and-hope callback:
|
|
41
|
+
|
|
42
|
+
- **A denial never reaches the tool.** `handler` is not called, and the model gets a
|
|
43
|
+
`ToolMessage` saying the call was refused and which rule refused it.
|
|
44
|
+
- **Once stays once.** The effect is reserved before `handler` runs and committed from its
|
|
45
|
+
return, so two agents sharing a store cannot both execute the same effect key.
|
|
46
|
+
- **An unknown outcome stays unknown.** Anything `handler` raises that is not `NotExecuted`
|
|
47
|
+
leaves the effect `AMBIGUOUS`, and the next attempt is refused until a human resolves it,
|
|
48
|
+
rather than being retried into a double charge.
|
|
49
|
+
|
|
50
|
+
## You may not need this
|
|
51
|
+
|
|
52
|
+
`@protect` already covers any Python callable, including a LangChain tool, with no middleware
|
|
53
|
+
and no framework support at all. This buys one thing over it: the gate applies to **every** tool
|
|
54
|
+
the agent can reach, including tools you did not write and cannot decorate.
|
|
55
|
+
|
|
56
|
+
There is a third way in that is not an adapter at all: `ctrlrun gateway` puts the same
|
|
57
|
+
guarantees in front of an MCP tool server, in any language, with no agent change.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```console
|
|
62
|
+
$ pip install ctrlrun-langchain
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Use
|
|
66
|
+
|
|
67
|
+
The **operator** wires it, on the line where the policy, the store and the identity provider are
|
|
68
|
+
chosen. This middleware never constructs a `Control` (SPEC-v0.5 §2.3), so everything it must not
|
|
69
|
+
decide — the identity provider, the authority document, the environment, the mode — is chosen by
|
|
70
|
+
the person deploying it.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from langchain.agents import create_agent
|
|
74
|
+
from ctrlrun import Control
|
|
75
|
+
from ctrlrun_langchain import CTRLRunMiddleware
|
|
76
|
+
|
|
77
|
+
control = Control.from_file("ctrlrun.yaml")
|
|
78
|
+
|
|
79
|
+
agent = create_agent(
|
|
80
|
+
model="gpt-5.5",
|
|
81
|
+
tools=[lookup, issue_refund],
|
|
82
|
+
middleware=[CTRLRunMiddleware(control)],
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
With a policy that says refunds up to €50 are autonomous and the rest are denied:
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
schema: ctrlrun.policy/v2
|
|
90
|
+
actions:
|
|
91
|
+
lookup:
|
|
92
|
+
decision: allow
|
|
93
|
+
issue_refund:
|
|
94
|
+
effect: "refund:{payment_id}"
|
|
95
|
+
rules:
|
|
96
|
+
- when: { amount_gte: 0, amount_lte: 5000 }
|
|
97
|
+
decision: allow
|
|
98
|
+
- decision: deny
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
the agent's own tool calls are decided before they run:
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
lookup the tool runs
|
|
105
|
+
issue_refund amount=900000 CTRLRun refused this call: rule[1]. The tool did not run.
|
|
106
|
+
rm_rf CTRLRun refused this call: unknown_action. The tool did not run.
|
|
107
|
+
issue_refund amount=1000 the tool runs
|
|
108
|
+
issue_refund amount=1000 (again) CTRLRun refused this call: this effect is already committed
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Nothing is default-allow: a tool the policy does not name is refused, which is why `rm_rf` above
|
|
112
|
+
never reaches `handler`.
|
|
113
|
+
|
|
114
|
+
**Every protected call needs a principal.** In production that is an identity provider that
|
|
115
|
+
verifies a credential; in development it is `with ctrlrun.context(agent="support-agent"):`
|
|
116
|
+
around the agent invocation. Without one the action is denied before the policy is consulted:
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
ActionDenied: lookup: no principal is available; wrap the call in
|
|
120
|
+
'with ctrlrun.context(agent=...)', or install an identity provider that answers
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
That is fail-closed and deliberate: who is acting is an authorization input, and a library that
|
|
124
|
+
accepted a self-asserted principal would be accepting the agent's word for its own authority.
|
|
125
|
+
|
|
126
|
+
## Approvals
|
|
127
|
+
|
|
128
|
+
Where the policy says `approve`, this middleware refuses the call and tells the model the
|
|
129
|
+
request id, rather than blocking the agent while a human deliberates:
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
CTRLRun is holding this call for a human. Approve it with 'ctrlrun approve apr_...',
|
|
133
|
+
then ask again. The tool did not run.
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
If you want the human answered *inside* the run instead, that is what `ctrlrun-langgraph` is
|
|
137
|
+
for: LangGraph's `interrupt()` suspends the graph, and the resumed run re-presents the same
|
|
138
|
+
proposal under the granted approval.
|
|
139
|
+
|
|
140
|
+
## What this does not do
|
|
141
|
+
|
|
142
|
+
- It does not decide anything. The policy does, and the policy is the operator's file.
|
|
143
|
+
- It does not grant approvals. `InterruptApprovalProvider`, `ctrlrun approve` and the webhook
|
|
144
|
+
are the only places a grant is written, and this is not one of them.
|
|
145
|
+
- It does not supply a principal, and it never reads one from agent state.
|
|
146
|
+
|
|
147
|
+
Apache-2.0, same as the kernel.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
ctrlrun_langchain/__init__.py,sha256=1p1GzHC7Yap-SvBactl01rX92Rv6y5KZd-o8g0hJAXo,8378
|
|
2
|
+
ctrlrun_langchain-1.0.0.dist-info/METADATA,sha256=R5rnBgZ69e1jlYPCvkLy4_zjXn1riKOBWQjZpKstq4I,6230
|
|
3
|
+
ctrlrun_langchain-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
4
|
+
ctrlrun_langchain-1.0.0.dist-info/top_level.txt,sha256=HMAdxCF8QUr80gxLYdsK5M8CqCAmdp8qxfE46NBo-H0,18
|
|
5
|
+
ctrlrun_langchain-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ctrlrun_langchain
|