ctrlrun-langgraph 1.0.0__tar.gz
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.
- ctrlrun_langgraph-1.0.0/PKG-INFO +193 -0
- ctrlrun_langgraph-1.0.0/README.md +175 -0
- ctrlrun_langgraph-1.0.0/pyproject.toml +38 -0
- ctrlrun_langgraph-1.0.0/setup.cfg +4 -0
- ctrlrun_langgraph-1.0.0/src/ctrlrun_langgraph/__init__.py +151 -0
- ctrlrun_langgraph-1.0.0/src/ctrlrun_langgraph.egg-info/PKG-INFO +193 -0
- ctrlrun_langgraph-1.0.0/src/ctrlrun_langgraph.egg-info/SOURCES.txt +8 -0
- ctrlrun_langgraph-1.0.0/src/ctrlrun_langgraph.egg-info/dependency_links.txt +1 -0
- ctrlrun_langgraph-1.0.0/src/ctrlrun_langgraph.egg-info/requires.txt +2 -0
- ctrlrun_langgraph-1.0.0/src/ctrlrun_langgraph.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ctrlrun-langgraph
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Route a CTRLRun APPROVE through LangGraph's own interrupt().
|
|
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: langgraph,ctrlrun,human-in-the-loop,agent
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: ctrlrun<0.6,>=0.5
|
|
17
|
+
Requires-Dist: langgraph<2.0,>=1.0
|
|
18
|
+
|
|
19
|
+
# ctrlrun-langgraph
|
|
20
|
+
|
|
21
|
+
Route a CTRLRun `APPROVE` through **LangGraph's own `interrupt()`**, so the human answers where
|
|
22
|
+
your LangGraph users already answer.
|
|
23
|
+
|
|
24
|
+
- **Supported kernel range:** `ctrlrun>=0.5,<0.6`
|
|
25
|
+
- **Supported framework range:** `langgraph>=1.0,<2.0`
|
|
26
|
+
- **Primitive reused:** [`interrupt()` and `Command(resume=...)`](https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/add-human-in-the-loop/), with a checkpointer. Read 2026-09-05.
|
|
27
|
+
- **Framework shape:** resumed in place (SPEC-v0.5 §3.5).
|
|
28
|
+
- **Conformance:** `6/6`, every suite, with `carries_approved_arguments=True`.
|
|
29
|
+
|
|
30
|
+
## You probably do not need this
|
|
31
|
+
|
|
32
|
+
`@protect` already covers anything running in your process — a LangChain tool, a raw model call,
|
|
33
|
+
a plain function — with no adapter and no framework support at all. **Most people reading this
|
|
34
|
+
need `@protect` and nothing else.**
|
|
35
|
+
|
|
36
|
+
This buys exactly one thing over it: when the policy says a human must approve, the request goes
|
|
37
|
+
out through LangGraph's interrupt instead of `ApprovalRequired` being raised past your graph. If
|
|
38
|
+
your deployment has nowhere for a human to answer, or you are happy handling `ApprovalRequired`
|
|
39
|
+
in your own code, stop here.
|
|
40
|
+
|
|
41
|
+
There is a third way in that is not an adapter at all: `ctrlrun gateway` puts the same guarantees
|
|
42
|
+
in front of an MCP tool server, in any language, with no agent change.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```console
|
|
47
|
+
$ pip install ctrlrun-langgraph
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Use
|
|
51
|
+
|
|
52
|
+
The **operator** wires it, on the line where the policy and the store are chosen. This adapter
|
|
53
|
+
never constructs a `Control` (SPEC-v0.5 §2.3), so everything it must not choose — the identity
|
|
54
|
+
provider, the authority document, the environment, the mode — is chosen by the person deploying
|
|
55
|
+
it, in the file they already look at.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from ctrlrun import Control, InterruptApprovalProvider, protect
|
|
59
|
+
from ctrlrun_langgraph import LangGraphInterrupt
|
|
60
|
+
|
|
61
|
+
control = Control(
|
|
62
|
+
policy, store,
|
|
63
|
+
approvals=InterruptApprovalProvider(
|
|
64
|
+
store, LangGraphInterrupt(carries_approved_arguments=True)
|
|
65
|
+
),
|
|
66
|
+
identity=..., authority=...,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@protect("stripe.refund", effect="refund:{payment_id}", wait=True, control=control)
|
|
70
|
+
def issue_refund(payment_id: str, amount: int) -> str:
|
|
71
|
+
return stripe.Refund.create(payment_intent=payment_id, amount=amount)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`wait=True` is what routes the `APPROVE` through the provider — and therefore through
|
|
75
|
+
`interrupt()` — instead of raising past your graph. It is the entire difference this adapter
|
|
76
|
+
makes.
|
|
77
|
+
|
|
78
|
+
Call `issue_refund` from a node, on a graph compiled with a checkpointer:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
graph = builder.compile(checkpointer=InMemorySaver())
|
|
82
|
+
config = {"configurable": {"thread_id": "..."}}
|
|
83
|
+
|
|
84
|
+
result = graph.invoke({"payment_id": "txn_1", "amount": 2000}, config)
|
|
85
|
+
if "__interrupt__" in result:
|
|
86
|
+
pending = graph.get_state(config).tasks[0].interrupts[0].value
|
|
87
|
+
# `pending` is JSON: the action, its arguments, the resource, the principal, the hash and
|
|
88
|
+
# the request's expiry. Put it in front of a human however you already do.
|
|
89
|
+
graph.invoke(
|
|
90
|
+
Command(resume={
|
|
91
|
+
"approved": True,
|
|
92
|
+
"approver": "ada@example.com",
|
|
93
|
+
"arguments": pending["arguments"], # what they answered against
|
|
94
|
+
}),
|
|
95
|
+
config,
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### What you may send back
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
Command(resume=True) # granted, approver "langgraph:interrupt"
|
|
103
|
+
Command(resume=False) # refused
|
|
104
|
+
Command(resume={"approved": True,
|
|
105
|
+
"approver": "ada@example.com",
|
|
106
|
+
"arguments": {...}}) # the arguments the human answered against
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`approved` must be `True` or `False`. A truthy string is not a yes, and is refused with a message
|
|
110
|
+
that names your resume value. This is a payload shape for LangGraph's own resumption channel, not
|
|
111
|
+
a token: nothing is minted, nothing is stored, and there is no id here this adapter invented.
|
|
112
|
+
|
|
113
|
+
## The binding: prevention or attribution
|
|
114
|
+
|
|
115
|
+
`carries_approved_arguments` has **no default**, because the default somebody assumes is the one
|
|
116
|
+
that does not check.
|
|
117
|
+
|
|
118
|
+
**`True` — prevention.** Your resume value must carry `arguments`, and CTRLRun rebuilds the
|
|
119
|
+
proposal with them and compares the action hash. An answer given against €5 that arrives for a
|
|
120
|
+
€5,000 action is refused with `ApprovalMismatch`, the approval is left grantable, and nothing
|
|
121
|
+
runs. This is the setting the conformance results above were produced with, and it is right for
|
|
122
|
+
almost every deployment: your console already knows what it showed the human.
|
|
123
|
+
|
|
124
|
+
**`False` — attribution.** You send back only a verdict. CTRLRun still binds the approval to the
|
|
125
|
+
action that executes — that is `v0.1 §4.2 A1` and it holds unconditionally — but **the binding
|
|
126
|
+
across the interrupt is LangGraph's checkpoint, not CTRLRun's hash**. If the checkpoint replayed a
|
|
127
|
+
different call than the one a human read, evidence will show it afterwards; nothing refuses it
|
|
128
|
+
beforehand. That is *attribution*, in that word, and the conformance kit reports
|
|
129
|
+
`binding: not_applicable` with the reason rather than a pass. Choose it only if your console
|
|
130
|
+
genuinely cannot echo what it displayed.
|
|
131
|
+
|
|
132
|
+
## Where LangGraph's behaviour shows through the contract
|
|
133
|
+
|
|
134
|
+
SPEC-v0.5 §7 item 5 asks every adapter to record this, and for a resumed-in-place framework there
|
|
135
|
+
are three (§3.2.1).
|
|
136
|
+
|
|
137
|
+
**The node runs twice, so the primitive is reached twice.** LangGraph replays the node from the
|
|
138
|
+
checkpoint, so `@protect` builds a **new `Action` with a new `action_id`** and creates a **new
|
|
139
|
+
approval request** on the resumed pass. `interrupt()` is therefore called once to ask and once to
|
|
140
|
+
receive. None of that is a defect and none of it is unsafe — the resumed pass re-runs principal
|
|
141
|
+
expiry, authority and policy at resumption time, so an authority revoked while the human
|
|
142
|
+
deliberated refuses the action then.
|
|
143
|
+
|
|
144
|
+
**`action_id` is not continuous.** The `action_id` in the payload a human saw is the first pass's;
|
|
145
|
+
the one on the receipt is the second's. Both are in the event log under their own
|
|
146
|
+
`ACTION_PROPOSED` and `APPROVAL_REQUESTED`, and correlating them is a reader's work.
|
|
147
|
+
**`action_hash` *is* continuous**, because `action_id` is excluded from the canonical form — which
|
|
148
|
+
is why the binding check above is about content and never about an id.
|
|
149
|
+
|
|
150
|
+
**The first pass's request is orphaned.** It stays `pending` and grantable by `ctrlrun approve`
|
|
151
|
+
for its full TTL, for the same `action_hash`. Not a hole — an approval is single-use and
|
|
152
|
+
hash-bound and is consumed atomically with the reservation — but an operator watching a queue
|
|
153
|
+
will see two requests for one refund.
|
|
154
|
+
|
|
155
|
+
**The approval TTL does not bound the human's deliberation.** They answer against the first
|
|
156
|
+
pass's request; the grant lands on the second pass's, created after they answered. What bounds
|
|
157
|
+
the interval is your checkpoint, which may hold it for a month. If that matters to you, expire
|
|
158
|
+
the thread.
|
|
159
|
+
|
|
160
|
+
**Retries.** LangGraph's retry is explicit and opt-in — a node takes a `RetryPolicy` — and this
|
|
161
|
+
adapter attaches none. Measured on `langgraph` 1.2.11 against a remote that commits and then
|
|
162
|
+
drops the connection, the prebuilt agent surfaced the failure and stopped: one effect, one
|
|
163
|
+
request, five runs out of five (`research/framework-probe/results/2026-09-05.json`). That is
|
|
164
|
+
behaviour, not quality, and it is not a promise about your graph.
|
|
165
|
+
|
|
166
|
+
**The kernel's exceptions arrive as themselves.** SPEC-v0.5 §7 item 6. LangGraph propagates a
|
|
167
|
+
node's exception unchanged, so an `ActionDenied`, a `DuplicateEffect`, an `AmbiguousEffect` or a
|
|
168
|
+
`NotExecuted` raised inside a protected node reaches your `except` clause as itself. **There is
|
|
169
|
+
nothing to call and nothing to unwrap**, and this adapter ships no helper for it.
|
|
170
|
+
|
|
171
|
+
That is worth saying rather than leaving to be inferred, because the other reference adapter is
|
|
172
|
+
the opposite case: the OpenAI Agents SDK turns a tool's exception into text for the model by
|
|
173
|
+
default, and `ctrlrun-openai-agents` has to ship `protected_tool` and `unwrap` to undo it
|
|
174
|
+
(SPEC-v0.5 §12.7). An operator moving between the two should know which side of that line they
|
|
175
|
+
are on, and "the README said nothing" is not an answer to it.
|
|
176
|
+
|
|
177
|
+
## What this adapter does not do
|
|
178
|
+
|
|
179
|
+
It is **not a second approval path**: it reuses `interrupt()` and reimplements nothing — no
|
|
180
|
+
prompt, no queue, no polling loop, no resume token of its own. It **grants nothing**: the answer
|
|
181
|
+
it returns is recorded by `InterruptApprovalProvider`, in core, through the same two store calls
|
|
182
|
+
`ctrlrun approve` makes. It **constructs no `Control`** and **supplies no principal** — an
|
|
183
|
+
adapter sees one and never supplies one.
|
|
184
|
+
|
|
185
|
+
And it is **not a compliance claim**. "Conformance" here names a suite of the CTRLRun
|
|
186
|
+
repository's own acceptance tests, run against this adapter. It certifies nothing.
|
|
187
|
+
|
|
188
|
+
## Versioning
|
|
189
|
+
|
|
190
|
+
`adapters-langgraph-MAJOR.MINOR`, never a kernel version. This adapter answers to two upstreams
|
|
191
|
+
and neither is the CTRLRun roadmap: it breaks when LangGraph makes a breaking release, on that
|
|
192
|
+
project's schedule. Its major version tracks whichever of the two forced the break, and the two
|
|
193
|
+
ranges at the top are what its CI actually ran against.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# ctrlrun-langgraph
|
|
2
|
+
|
|
3
|
+
Route a CTRLRun `APPROVE` through **LangGraph's own `interrupt()`**, so the human answers where
|
|
4
|
+
your LangGraph users already answer.
|
|
5
|
+
|
|
6
|
+
- **Supported kernel range:** `ctrlrun>=0.5,<0.6`
|
|
7
|
+
- **Supported framework range:** `langgraph>=1.0,<2.0`
|
|
8
|
+
- **Primitive reused:** [`interrupt()` and `Command(resume=...)`](https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/add-human-in-the-loop/), with a checkpointer. Read 2026-09-05.
|
|
9
|
+
- **Framework shape:** resumed in place (SPEC-v0.5 §3.5).
|
|
10
|
+
- **Conformance:** `6/6`, every suite, with `carries_approved_arguments=True`.
|
|
11
|
+
|
|
12
|
+
## You probably do not need this
|
|
13
|
+
|
|
14
|
+
`@protect` already covers anything running in your process — a LangChain tool, a raw model call,
|
|
15
|
+
a plain function — with no adapter and no framework support at all. **Most people reading this
|
|
16
|
+
need `@protect` and nothing else.**
|
|
17
|
+
|
|
18
|
+
This buys exactly one thing over it: when the policy says a human must approve, the request goes
|
|
19
|
+
out through LangGraph's interrupt instead of `ApprovalRequired` being raised past your graph. If
|
|
20
|
+
your deployment has nowhere for a human to answer, or you are happy handling `ApprovalRequired`
|
|
21
|
+
in your own code, stop here.
|
|
22
|
+
|
|
23
|
+
There is a third way in that is not an adapter at all: `ctrlrun gateway` puts the same guarantees
|
|
24
|
+
in front of an MCP tool server, in any language, with no agent change.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```console
|
|
29
|
+
$ pip install ctrlrun-langgraph
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Use
|
|
33
|
+
|
|
34
|
+
The **operator** wires it, on the line where the policy and the store are chosen. This adapter
|
|
35
|
+
never constructs a `Control` (SPEC-v0.5 §2.3), so everything it must not choose — the identity
|
|
36
|
+
provider, the authority document, the environment, the mode — is chosen by the person deploying
|
|
37
|
+
it, in the file they already look at.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from ctrlrun import Control, InterruptApprovalProvider, protect
|
|
41
|
+
from ctrlrun_langgraph import LangGraphInterrupt
|
|
42
|
+
|
|
43
|
+
control = Control(
|
|
44
|
+
policy, store,
|
|
45
|
+
approvals=InterruptApprovalProvider(
|
|
46
|
+
store, LangGraphInterrupt(carries_approved_arguments=True)
|
|
47
|
+
),
|
|
48
|
+
identity=..., authority=...,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@protect("stripe.refund", effect="refund:{payment_id}", wait=True, control=control)
|
|
52
|
+
def issue_refund(payment_id: str, amount: int) -> str:
|
|
53
|
+
return stripe.Refund.create(payment_intent=payment_id, amount=amount)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`wait=True` is what routes the `APPROVE` through the provider — and therefore through
|
|
57
|
+
`interrupt()` — instead of raising past your graph. It is the entire difference this adapter
|
|
58
|
+
makes.
|
|
59
|
+
|
|
60
|
+
Call `issue_refund` from a node, on a graph compiled with a checkpointer:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
graph = builder.compile(checkpointer=InMemorySaver())
|
|
64
|
+
config = {"configurable": {"thread_id": "..."}}
|
|
65
|
+
|
|
66
|
+
result = graph.invoke({"payment_id": "txn_1", "amount": 2000}, config)
|
|
67
|
+
if "__interrupt__" in result:
|
|
68
|
+
pending = graph.get_state(config).tasks[0].interrupts[0].value
|
|
69
|
+
# `pending` is JSON: the action, its arguments, the resource, the principal, the hash and
|
|
70
|
+
# the request's expiry. Put it in front of a human however you already do.
|
|
71
|
+
graph.invoke(
|
|
72
|
+
Command(resume={
|
|
73
|
+
"approved": True,
|
|
74
|
+
"approver": "ada@example.com",
|
|
75
|
+
"arguments": pending["arguments"], # what they answered against
|
|
76
|
+
}),
|
|
77
|
+
config,
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### What you may send back
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
Command(resume=True) # granted, approver "langgraph:interrupt"
|
|
85
|
+
Command(resume=False) # refused
|
|
86
|
+
Command(resume={"approved": True,
|
|
87
|
+
"approver": "ada@example.com",
|
|
88
|
+
"arguments": {...}}) # the arguments the human answered against
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`approved` must be `True` or `False`. A truthy string is not a yes, and is refused with a message
|
|
92
|
+
that names your resume value. This is a payload shape for LangGraph's own resumption channel, not
|
|
93
|
+
a token: nothing is minted, nothing is stored, and there is no id here this adapter invented.
|
|
94
|
+
|
|
95
|
+
## The binding: prevention or attribution
|
|
96
|
+
|
|
97
|
+
`carries_approved_arguments` has **no default**, because the default somebody assumes is the one
|
|
98
|
+
that does not check.
|
|
99
|
+
|
|
100
|
+
**`True` — prevention.** Your resume value must carry `arguments`, and CTRLRun rebuilds the
|
|
101
|
+
proposal with them and compares the action hash. An answer given against €5 that arrives for a
|
|
102
|
+
€5,000 action is refused with `ApprovalMismatch`, the approval is left grantable, and nothing
|
|
103
|
+
runs. This is the setting the conformance results above were produced with, and it is right for
|
|
104
|
+
almost every deployment: your console already knows what it showed the human.
|
|
105
|
+
|
|
106
|
+
**`False` — attribution.** You send back only a verdict. CTRLRun still binds the approval to the
|
|
107
|
+
action that executes — that is `v0.1 §4.2 A1` and it holds unconditionally — but **the binding
|
|
108
|
+
across the interrupt is LangGraph's checkpoint, not CTRLRun's hash**. If the checkpoint replayed a
|
|
109
|
+
different call than the one a human read, evidence will show it afterwards; nothing refuses it
|
|
110
|
+
beforehand. That is *attribution*, in that word, and the conformance kit reports
|
|
111
|
+
`binding: not_applicable` with the reason rather than a pass. Choose it only if your console
|
|
112
|
+
genuinely cannot echo what it displayed.
|
|
113
|
+
|
|
114
|
+
## Where LangGraph's behaviour shows through the contract
|
|
115
|
+
|
|
116
|
+
SPEC-v0.5 §7 item 5 asks every adapter to record this, and for a resumed-in-place framework there
|
|
117
|
+
are three (§3.2.1).
|
|
118
|
+
|
|
119
|
+
**The node runs twice, so the primitive is reached twice.** LangGraph replays the node from the
|
|
120
|
+
checkpoint, so `@protect` builds a **new `Action` with a new `action_id`** and creates a **new
|
|
121
|
+
approval request** on the resumed pass. `interrupt()` is therefore called once to ask and once to
|
|
122
|
+
receive. None of that is a defect and none of it is unsafe — the resumed pass re-runs principal
|
|
123
|
+
expiry, authority and policy at resumption time, so an authority revoked while the human
|
|
124
|
+
deliberated refuses the action then.
|
|
125
|
+
|
|
126
|
+
**`action_id` is not continuous.** The `action_id` in the payload a human saw is the first pass's;
|
|
127
|
+
the one on the receipt is the second's. Both are in the event log under their own
|
|
128
|
+
`ACTION_PROPOSED` and `APPROVAL_REQUESTED`, and correlating them is a reader's work.
|
|
129
|
+
**`action_hash` *is* continuous**, because `action_id` is excluded from the canonical form — which
|
|
130
|
+
is why the binding check above is about content and never about an id.
|
|
131
|
+
|
|
132
|
+
**The first pass's request is orphaned.** It stays `pending` and grantable by `ctrlrun approve`
|
|
133
|
+
for its full TTL, for the same `action_hash`. Not a hole — an approval is single-use and
|
|
134
|
+
hash-bound and is consumed atomically with the reservation — but an operator watching a queue
|
|
135
|
+
will see two requests for one refund.
|
|
136
|
+
|
|
137
|
+
**The approval TTL does not bound the human's deliberation.** They answer against the first
|
|
138
|
+
pass's request; the grant lands on the second pass's, created after they answered. What bounds
|
|
139
|
+
the interval is your checkpoint, which may hold it for a month. If that matters to you, expire
|
|
140
|
+
the thread.
|
|
141
|
+
|
|
142
|
+
**Retries.** LangGraph's retry is explicit and opt-in — a node takes a `RetryPolicy` — and this
|
|
143
|
+
adapter attaches none. Measured on `langgraph` 1.2.11 against a remote that commits and then
|
|
144
|
+
drops the connection, the prebuilt agent surfaced the failure and stopped: one effect, one
|
|
145
|
+
request, five runs out of five (`research/framework-probe/results/2026-09-05.json`). That is
|
|
146
|
+
behaviour, not quality, and it is not a promise about your graph.
|
|
147
|
+
|
|
148
|
+
**The kernel's exceptions arrive as themselves.** SPEC-v0.5 §7 item 6. LangGraph propagates a
|
|
149
|
+
node's exception unchanged, so an `ActionDenied`, a `DuplicateEffect`, an `AmbiguousEffect` or a
|
|
150
|
+
`NotExecuted` raised inside a protected node reaches your `except` clause as itself. **There is
|
|
151
|
+
nothing to call and nothing to unwrap**, and this adapter ships no helper for it.
|
|
152
|
+
|
|
153
|
+
That is worth saying rather than leaving to be inferred, because the other reference adapter is
|
|
154
|
+
the opposite case: the OpenAI Agents SDK turns a tool's exception into text for the model by
|
|
155
|
+
default, and `ctrlrun-openai-agents` has to ship `protected_tool` and `unwrap` to undo it
|
|
156
|
+
(SPEC-v0.5 §12.7). An operator moving between the two should know which side of that line they
|
|
157
|
+
are on, and "the README said nothing" is not an answer to it.
|
|
158
|
+
|
|
159
|
+
## What this adapter does not do
|
|
160
|
+
|
|
161
|
+
It is **not a second approval path**: it reuses `interrupt()` and reimplements nothing — no
|
|
162
|
+
prompt, no queue, no polling loop, no resume token of its own. It **grants nothing**: the answer
|
|
163
|
+
it returns is recorded by `InterruptApprovalProvider`, in core, through the same two store calls
|
|
164
|
+
`ctrlrun approve` makes. It **constructs no `Control`** and **supplies no principal** — an
|
|
165
|
+
adapter sees one and never supplies one.
|
|
166
|
+
|
|
167
|
+
And it is **not a compliance claim**. "Conformance" here names a suite of the CTRLRun
|
|
168
|
+
repository's own acceptance tests, run against this adapter. It certifies nothing.
|
|
169
|
+
|
|
170
|
+
## Versioning
|
|
171
|
+
|
|
172
|
+
`adapters-langgraph-MAJOR.MINOR`, never a kernel version. This adapter answers to two upstreams
|
|
173
|
+
and neither is the CTRLRun roadmap: it breaks when LangGraph makes a breaking release, on that
|
|
174
|
+
project's schedule. Its major version tracks whichever of the two forced the break, and the two
|
|
175
|
+
ranges at the top are what its CI actually ran against.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# ctrlrun-langgraph — a separate distribution on the adapters track (SPEC-v0.5 §6).
|
|
2
|
+
#
|
|
3
|
+
# `pip install ctrlrun` must not grow. This depends on `ctrlrun`, never the reverse, and the
|
|
4
|
+
# `ctrlrun` wheel and sdist contain no `adapters/` path (T136).
|
|
5
|
+
[build-system]
|
|
6
|
+
requires = ["setuptools>=68"]
|
|
7
|
+
build-backend = "setuptools.build_meta"
|
|
8
|
+
|
|
9
|
+
[project]
|
|
10
|
+
name = "ctrlrun-langgraph"
|
|
11
|
+
version = "1.0.0"
|
|
12
|
+
description = "Route a CTRLRun APPROVE through LangGraph's own interrupt()."
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
requires-python = ">=3.11"
|
|
15
|
+
authors = [{name = "Arpan Ghoshal", email = "contact@arpanghoshal.com"}]
|
|
16
|
+
license = "Apache-2.0"
|
|
17
|
+
keywords = ["langgraph", "ctrlrun", "human-in-the-loop", "agent"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: Software Development :: Libraries",
|
|
23
|
+
]
|
|
24
|
+
# The two ranges SPEC-v0.5 §6.3 requires, and they are ranges rather than floors: the adapter
|
|
25
|
+
# contract is frozen at v1.0 and not before, so `>=0.5` would be claiming compatibility with a
|
|
26
|
+
# surface that has not been written. The README states the same two, and T137 asserts that what
|
|
27
|
+
# it states is what CI installed.
|
|
28
|
+
dependencies = [
|
|
29
|
+
"ctrlrun>=0.5,<0.6",
|
|
30
|
+
"langgraph>=1.0,<2.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/CTRLRun/ctrlrun"
|
|
35
|
+
Repository = "https://github.com/CTRLRun/ctrlrun"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Route a CTRLRun `APPROVE` through LangGraph's own `interrupt()`. SPEC-v0.5 §2, §3.
|
|
2
|
+
|
|
3
|
+
**An adapter exists for exactly one reason**, and this is the whole of it: when a policy says a
|
|
4
|
+
refund needs a human, the human answers *where LangGraph users already answer* — through
|
|
5
|
+
`interrupt()` and `Command(resume=...)`, in whatever queue or console the deployment already
|
|
6
|
+
routes those to — instead of `ApprovalRequired` being raised past the graph.
|
|
7
|
+
|
|
8
|
+
Everything else is the kernel's, unchanged. The policy, the authority evaluation, the exact
|
|
9
|
+
binding, the reservation, the receipt: all of it is `Control` doing what it does under
|
|
10
|
+
`@protect`, because this module reserves nothing, commits nothing, grants nothing and
|
|
11
|
+
constructs no `Control`. What it contributes is the two lines in `interrupt()` below.
|
|
12
|
+
|
|
13
|
+
**You probably do not need this.** `@protect` already covers anything running in this process,
|
|
14
|
+
including a LangChain tool and a raw model call, with no adapter and no framework support. This
|
|
15
|
+
buys one thing over it: the interrupt. If your graph has nowhere for a human to answer, or you
|
|
16
|
+
are happy for `ApprovalRequired` to reach your own code, use `@protect` and stop here.
|
|
17
|
+
|
|
18
|
+
Supported kernel range: `ctrlrun>=0.5,<0.6`. Supported framework range: `langgraph>=1.0,<2.0`.
|
|
19
|
+
`README.md` states both, and what this adapter's binding check is and is not.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from ctrlrun import ApprovalAnswer, PendingApproval
|
|
28
|
+
from ctrlrun.errors import InvalidArgument
|
|
29
|
+
|
|
30
|
+
__all__ = ["RESUME_SHAPE", "LangGraphInterrupt"]
|
|
31
|
+
|
|
32
|
+
#: The name that reaches a receipt's `approver` when the resume value does not carry one. It
|
|
33
|
+
#: names a **channel** and never a person -- SPEC-v0.3 §13 keeps authenticating the approver out
|
|
34
|
+
#: of scope, and `v0.1`'s `"cli:local"` is the same register. Do not read it as who approved.
|
|
35
|
+
CHANNEL = "langgraph:interrupt"
|
|
36
|
+
|
|
37
|
+
RESUME_SHAPE = """\
|
|
38
|
+
Command(resume=True) # granted, approver "langgraph:interrupt"
|
|
39
|
+
Command(resume=False) # refused
|
|
40
|
+
Command(resume={"approved": True,
|
|
41
|
+
"approver": "ada@example.com",
|
|
42
|
+
"arguments": {...}}) # the arguments the human answered against\
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LangGraphInterrupt:
|
|
47
|
+
"""LangGraph's `interrupt()`, and nothing else (SPEC-v0.5 §2.1).
|
|
48
|
+
|
|
49
|
+
The operator wires it, on the line where they choose the policy and the store::
|
|
50
|
+
|
|
51
|
+
control = Control(
|
|
52
|
+
policy, store,
|
|
53
|
+
approvals=InterruptApprovalProvider(
|
|
54
|
+
store, LangGraphInterrupt(carries_approved_arguments=True)
|
|
55
|
+
),
|
|
56
|
+
identity=..., authority=...,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@protect("stripe.refund", effect="refund:{payment_id}", wait=True, control=control)
|
|
60
|
+
def issue_refund(payment_id: str, amount: int) -> str: ...
|
|
61
|
+
|
|
62
|
+
and then calls `issue_refund` from a graph node, on a graph compiled with a checkpointer.
|
|
63
|
+
`wait=True` is what routes the `APPROVE` here instead of raising past the graph.
|
|
64
|
+
|
|
65
|
+
**This adapter never constructs the `Control`** (SPEC-v0.5 §2.3). Everything an adapter must
|
|
66
|
+
not choose -- the identity provider, the authority document, the environment, the mode -- is
|
|
67
|
+
chosen on the line above, by the person who deployed it.
|
|
68
|
+
|
|
69
|
+
``carries_approved_arguments`` has **no default**, and that is deliberate. It says whether
|
|
70
|
+
your resume value carries back the arguments the human answered against, and therefore
|
|
71
|
+
whether the binding across the interrupt is prevention or attribution (SPEC-v0.5 §3.4). The
|
|
72
|
+
default somebody assumes is the one that does not check, so there isn't one. `README.md`
|
|
73
|
+
argues both settings; `True` is right for almost every deployment, and it is what the
|
|
74
|
+
conformance results in that file were produced with.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
framework = "langgraph"
|
|
78
|
+
|
|
79
|
+
def __init__(self, *, carries_approved_arguments: bool) -> None:
|
|
80
|
+
if not isinstance(carries_approved_arguments, bool):
|
|
81
|
+
raise InvalidArgument(
|
|
82
|
+
"carries_approved_arguments must be True or False, and it must be stated: it "
|
|
83
|
+
"declares whether this deployment's resume value carries back what the human "
|
|
84
|
+
"answered against (SPEC-v0.5 §3.4)"
|
|
85
|
+
)
|
|
86
|
+
self.carries_approved_arguments = carries_approved_arguments
|
|
87
|
+
|
|
88
|
+
def interrupt(self, pending: PendingApproval) -> ApprovalAnswer:
|
|
89
|
+
"""Hand the pending approval to LangGraph and return what came back.
|
|
90
|
+
|
|
91
|
+
The two lines that are this adapter. `interrupt()` raises `GraphInterrupt` on the first
|
|
92
|
+
pass, which LangGraph catches and checkpoints; the resumed run re-enters here and it
|
|
93
|
+
returns the value from `Command(resume=...)`. Neither the raise nor the return is caught
|
|
94
|
+
or converted: an exception out of here reaches `InterruptApprovalProvider`, which writes
|
|
95
|
+
nothing and lets it propagate (SPEC-v0.5 §2.4).
|
|
96
|
+
|
|
97
|
+
`pending.to_dict()` is what a human sees, and it is JSON by construction because
|
|
98
|
+
LangGraph persists it: the payload survives a checkpoint, a restart and a different
|
|
99
|
+
process.
|
|
100
|
+
"""
|
|
101
|
+
from langgraph.types import interrupt as langgraph_interrupt
|
|
102
|
+
|
|
103
|
+
return self.answer(langgraph_interrupt(pending.to_dict()))
|
|
104
|
+
|
|
105
|
+
def answer(self, resume: Any) -> ApprovalAnswer:
|
|
106
|
+
"""Read a resume value. Public so a deployment can unit-test its own answer shape.
|
|
107
|
+
|
|
108
|
+
A bare `True`/`False` is accepted for a deployment whose console has only a button.
|
|
109
|
+
Anything else must be a mapping with `approved`; `approver` is optional and defaults to
|
|
110
|
+
the channel name; `arguments` is **required** where this interrupt declares it carries
|
|
111
|
+
them, because a declaration is not a hint (SPEC-v0.5 §3.4).
|
|
112
|
+
|
|
113
|
+
This is not a resume token and not a second approval path. It is a payload shape for
|
|
114
|
+
LangGraph's own resumption channel: nothing is minted, nothing is stored, and there is
|
|
115
|
+
no id here that this adapter invented.
|
|
116
|
+
"""
|
|
117
|
+
if isinstance(resume, bool):
|
|
118
|
+
answered: Mapping[str, Any] = {"approved": resume}
|
|
119
|
+
elif isinstance(resume, Mapping):
|
|
120
|
+
answered = resume
|
|
121
|
+
else:
|
|
122
|
+
raise InvalidArgument(
|
|
123
|
+
f"the resume value is {type(resume).__name__}; LangGraphInterrupt reads a bool "
|
|
124
|
+
f"or a mapping:\n{RESUME_SHAPE}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if "approved" not in answered:
|
|
128
|
+
raise InvalidArgument(
|
|
129
|
+
f"the resume mapping has no 'approved' key, so it states no verdict:\n"
|
|
130
|
+
f"{RESUME_SHAPE}"
|
|
131
|
+
)
|
|
132
|
+
verdict = answered["approved"]
|
|
133
|
+
if not isinstance(verdict, bool):
|
|
134
|
+
# A truthy string is not a yes. The kernel refuses this too (SPEC-v0.5 §2.2); it is
|
|
135
|
+
# refused here as well so the message names LangGraph's resume value, which is where
|
|
136
|
+
# an operator can fix it.
|
|
137
|
+
raise InvalidArgument(
|
|
138
|
+
f"resume['approved'] is {type(verdict).__name__}, and only True or False is a "
|
|
139
|
+
f"verdict -- a truthy value is not a yes:\n{RESUME_SHAPE}"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
approver = answered.get("approver") or CHANNEL
|
|
143
|
+
arguments = answered.get("arguments")
|
|
144
|
+
if self.carries_approved_arguments and verdict and arguments is None:
|
|
145
|
+
raise InvalidArgument(
|
|
146
|
+
"this LangGraphInterrupt declares carries_approved_arguments=True, so a grant "
|
|
147
|
+
"must carry the arguments the human answered against; without them the binding "
|
|
148
|
+
"across the interrupt would be the checkpoint rather than the action hash "
|
|
149
|
+
f"(SPEC-v0.5 §3.4):\n{RESUME_SHAPE}"
|
|
150
|
+
)
|
|
151
|
+
return ApprovalAnswer(granted=verdict, approver=approver, approved_arguments=arguments)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ctrlrun-langgraph
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Route a CTRLRun APPROVE through LangGraph's own interrupt().
|
|
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: langgraph,ctrlrun,human-in-the-loop,agent
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: ctrlrun<0.6,>=0.5
|
|
17
|
+
Requires-Dist: langgraph<2.0,>=1.0
|
|
18
|
+
|
|
19
|
+
# ctrlrun-langgraph
|
|
20
|
+
|
|
21
|
+
Route a CTRLRun `APPROVE` through **LangGraph's own `interrupt()`**, so the human answers where
|
|
22
|
+
your LangGraph users already answer.
|
|
23
|
+
|
|
24
|
+
- **Supported kernel range:** `ctrlrun>=0.5,<0.6`
|
|
25
|
+
- **Supported framework range:** `langgraph>=1.0,<2.0`
|
|
26
|
+
- **Primitive reused:** [`interrupt()` and `Command(resume=...)`](https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/add-human-in-the-loop/), with a checkpointer. Read 2026-09-05.
|
|
27
|
+
- **Framework shape:** resumed in place (SPEC-v0.5 §3.5).
|
|
28
|
+
- **Conformance:** `6/6`, every suite, with `carries_approved_arguments=True`.
|
|
29
|
+
|
|
30
|
+
## You probably do not need this
|
|
31
|
+
|
|
32
|
+
`@protect` already covers anything running in your process — a LangChain tool, a raw model call,
|
|
33
|
+
a plain function — with no adapter and no framework support at all. **Most people reading this
|
|
34
|
+
need `@protect` and nothing else.**
|
|
35
|
+
|
|
36
|
+
This buys exactly one thing over it: when the policy says a human must approve, the request goes
|
|
37
|
+
out through LangGraph's interrupt instead of `ApprovalRequired` being raised past your graph. If
|
|
38
|
+
your deployment has nowhere for a human to answer, or you are happy handling `ApprovalRequired`
|
|
39
|
+
in your own code, stop here.
|
|
40
|
+
|
|
41
|
+
There is a third way in that is not an adapter at all: `ctrlrun gateway` puts the same guarantees
|
|
42
|
+
in front of an MCP tool server, in any language, with no agent change.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```console
|
|
47
|
+
$ pip install ctrlrun-langgraph
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Use
|
|
51
|
+
|
|
52
|
+
The **operator** wires it, on the line where the policy and the store are chosen. This adapter
|
|
53
|
+
never constructs a `Control` (SPEC-v0.5 §2.3), so everything it must not choose — the identity
|
|
54
|
+
provider, the authority document, the environment, the mode — is chosen by the person deploying
|
|
55
|
+
it, in the file they already look at.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from ctrlrun import Control, InterruptApprovalProvider, protect
|
|
59
|
+
from ctrlrun_langgraph import LangGraphInterrupt
|
|
60
|
+
|
|
61
|
+
control = Control(
|
|
62
|
+
policy, store,
|
|
63
|
+
approvals=InterruptApprovalProvider(
|
|
64
|
+
store, LangGraphInterrupt(carries_approved_arguments=True)
|
|
65
|
+
),
|
|
66
|
+
identity=..., authority=...,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@protect("stripe.refund", effect="refund:{payment_id}", wait=True, control=control)
|
|
70
|
+
def issue_refund(payment_id: str, amount: int) -> str:
|
|
71
|
+
return stripe.Refund.create(payment_intent=payment_id, amount=amount)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`wait=True` is what routes the `APPROVE` through the provider — and therefore through
|
|
75
|
+
`interrupt()` — instead of raising past your graph. It is the entire difference this adapter
|
|
76
|
+
makes.
|
|
77
|
+
|
|
78
|
+
Call `issue_refund` from a node, on a graph compiled with a checkpointer:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
graph = builder.compile(checkpointer=InMemorySaver())
|
|
82
|
+
config = {"configurable": {"thread_id": "..."}}
|
|
83
|
+
|
|
84
|
+
result = graph.invoke({"payment_id": "txn_1", "amount": 2000}, config)
|
|
85
|
+
if "__interrupt__" in result:
|
|
86
|
+
pending = graph.get_state(config).tasks[0].interrupts[0].value
|
|
87
|
+
# `pending` is JSON: the action, its arguments, the resource, the principal, the hash and
|
|
88
|
+
# the request's expiry. Put it in front of a human however you already do.
|
|
89
|
+
graph.invoke(
|
|
90
|
+
Command(resume={
|
|
91
|
+
"approved": True,
|
|
92
|
+
"approver": "ada@example.com",
|
|
93
|
+
"arguments": pending["arguments"], # what they answered against
|
|
94
|
+
}),
|
|
95
|
+
config,
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### What you may send back
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
Command(resume=True) # granted, approver "langgraph:interrupt"
|
|
103
|
+
Command(resume=False) # refused
|
|
104
|
+
Command(resume={"approved": True,
|
|
105
|
+
"approver": "ada@example.com",
|
|
106
|
+
"arguments": {...}}) # the arguments the human answered against
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`approved` must be `True` or `False`. A truthy string is not a yes, and is refused with a message
|
|
110
|
+
that names your resume value. This is a payload shape for LangGraph's own resumption channel, not
|
|
111
|
+
a token: nothing is minted, nothing is stored, and there is no id here this adapter invented.
|
|
112
|
+
|
|
113
|
+
## The binding: prevention or attribution
|
|
114
|
+
|
|
115
|
+
`carries_approved_arguments` has **no default**, because the default somebody assumes is the one
|
|
116
|
+
that does not check.
|
|
117
|
+
|
|
118
|
+
**`True` — prevention.** Your resume value must carry `arguments`, and CTRLRun rebuilds the
|
|
119
|
+
proposal with them and compares the action hash. An answer given against €5 that arrives for a
|
|
120
|
+
€5,000 action is refused with `ApprovalMismatch`, the approval is left grantable, and nothing
|
|
121
|
+
runs. This is the setting the conformance results above were produced with, and it is right for
|
|
122
|
+
almost every deployment: your console already knows what it showed the human.
|
|
123
|
+
|
|
124
|
+
**`False` — attribution.** You send back only a verdict. CTRLRun still binds the approval to the
|
|
125
|
+
action that executes — that is `v0.1 §4.2 A1` and it holds unconditionally — but **the binding
|
|
126
|
+
across the interrupt is LangGraph's checkpoint, not CTRLRun's hash**. If the checkpoint replayed a
|
|
127
|
+
different call than the one a human read, evidence will show it afterwards; nothing refuses it
|
|
128
|
+
beforehand. That is *attribution*, in that word, and the conformance kit reports
|
|
129
|
+
`binding: not_applicable` with the reason rather than a pass. Choose it only if your console
|
|
130
|
+
genuinely cannot echo what it displayed.
|
|
131
|
+
|
|
132
|
+
## Where LangGraph's behaviour shows through the contract
|
|
133
|
+
|
|
134
|
+
SPEC-v0.5 §7 item 5 asks every adapter to record this, and for a resumed-in-place framework there
|
|
135
|
+
are three (§3.2.1).
|
|
136
|
+
|
|
137
|
+
**The node runs twice, so the primitive is reached twice.** LangGraph replays the node from the
|
|
138
|
+
checkpoint, so `@protect` builds a **new `Action` with a new `action_id`** and creates a **new
|
|
139
|
+
approval request** on the resumed pass. `interrupt()` is therefore called once to ask and once to
|
|
140
|
+
receive. None of that is a defect and none of it is unsafe — the resumed pass re-runs principal
|
|
141
|
+
expiry, authority and policy at resumption time, so an authority revoked while the human
|
|
142
|
+
deliberated refuses the action then.
|
|
143
|
+
|
|
144
|
+
**`action_id` is not continuous.** The `action_id` in the payload a human saw is the first pass's;
|
|
145
|
+
the one on the receipt is the second's. Both are in the event log under their own
|
|
146
|
+
`ACTION_PROPOSED` and `APPROVAL_REQUESTED`, and correlating them is a reader's work.
|
|
147
|
+
**`action_hash` *is* continuous**, because `action_id` is excluded from the canonical form — which
|
|
148
|
+
is why the binding check above is about content and never about an id.
|
|
149
|
+
|
|
150
|
+
**The first pass's request is orphaned.** It stays `pending` and grantable by `ctrlrun approve`
|
|
151
|
+
for its full TTL, for the same `action_hash`. Not a hole — an approval is single-use and
|
|
152
|
+
hash-bound and is consumed atomically with the reservation — but an operator watching a queue
|
|
153
|
+
will see two requests for one refund.
|
|
154
|
+
|
|
155
|
+
**The approval TTL does not bound the human's deliberation.** They answer against the first
|
|
156
|
+
pass's request; the grant lands on the second pass's, created after they answered. What bounds
|
|
157
|
+
the interval is your checkpoint, which may hold it for a month. If that matters to you, expire
|
|
158
|
+
the thread.
|
|
159
|
+
|
|
160
|
+
**Retries.** LangGraph's retry is explicit and opt-in — a node takes a `RetryPolicy` — and this
|
|
161
|
+
adapter attaches none. Measured on `langgraph` 1.2.11 against a remote that commits and then
|
|
162
|
+
drops the connection, the prebuilt agent surfaced the failure and stopped: one effect, one
|
|
163
|
+
request, five runs out of five (`research/framework-probe/results/2026-09-05.json`). That is
|
|
164
|
+
behaviour, not quality, and it is not a promise about your graph.
|
|
165
|
+
|
|
166
|
+
**The kernel's exceptions arrive as themselves.** SPEC-v0.5 §7 item 6. LangGraph propagates a
|
|
167
|
+
node's exception unchanged, so an `ActionDenied`, a `DuplicateEffect`, an `AmbiguousEffect` or a
|
|
168
|
+
`NotExecuted` raised inside a protected node reaches your `except` clause as itself. **There is
|
|
169
|
+
nothing to call and nothing to unwrap**, and this adapter ships no helper for it.
|
|
170
|
+
|
|
171
|
+
That is worth saying rather than leaving to be inferred, because the other reference adapter is
|
|
172
|
+
the opposite case: the OpenAI Agents SDK turns a tool's exception into text for the model by
|
|
173
|
+
default, and `ctrlrun-openai-agents` has to ship `protected_tool` and `unwrap` to undo it
|
|
174
|
+
(SPEC-v0.5 §12.7). An operator moving between the two should know which side of that line they
|
|
175
|
+
are on, and "the README said nothing" is not an answer to it.
|
|
176
|
+
|
|
177
|
+
## What this adapter does not do
|
|
178
|
+
|
|
179
|
+
It is **not a second approval path**: it reuses `interrupt()` and reimplements nothing — no
|
|
180
|
+
prompt, no queue, no polling loop, no resume token of its own. It **grants nothing**: the answer
|
|
181
|
+
it returns is recorded by `InterruptApprovalProvider`, in core, through the same two store calls
|
|
182
|
+
`ctrlrun approve` makes. It **constructs no `Control`** and **supplies no principal** — an
|
|
183
|
+
adapter sees one and never supplies one.
|
|
184
|
+
|
|
185
|
+
And it is **not a compliance claim**. "Conformance" here names a suite of the CTRLRun
|
|
186
|
+
repository's own acceptance tests, run against this adapter. It certifies nothing.
|
|
187
|
+
|
|
188
|
+
## Versioning
|
|
189
|
+
|
|
190
|
+
`adapters-langgraph-MAJOR.MINOR`, never a kernel version. This adapter answers to two upstreams
|
|
191
|
+
and neither is the CTRLRun roadmap: it breaks when LangGraph makes a breaking release, on that
|
|
192
|
+
project's schedule. Its major version tracks whichever of the two forced the break, and the two
|
|
193
|
+
ranges at the top are what its CI actually ran against.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/ctrlrun_langgraph/__init__.py
|
|
4
|
+
src/ctrlrun_langgraph.egg-info/PKG-INFO
|
|
5
|
+
src/ctrlrun_langgraph.egg-info/SOURCES.txt
|
|
6
|
+
src/ctrlrun_langgraph.egg-info/dependency_links.txt
|
|
7
|
+
src/ctrlrun_langgraph.egg-info/requires.txt
|
|
8
|
+
src/ctrlrun_langgraph.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ctrlrun_langgraph
|