ctrlrun-openai-agents 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.
@@ -0,0 +1,198 @@
1
+ Metadata-Version: 2.4
2
+ Name: ctrlrun-openai-agents
3
+ Version: 1.0.0
4
+ Summary: Route a CTRLRun APPROVE through the OpenAI Agents SDK's tool-approval interruption.
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: openai-agents,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: openai-agents<1.0,>=0.20
18
+
19
+ # ctrlrun-openai-agents
20
+
21
+ Route a CTRLRun `APPROVE` through the **OpenAI Agents SDK's own tool-approval interruption**, so
22
+ the human answers where this SDK's users already answer.
23
+
24
+ - **Supported kernel range:** `ctrlrun>=0.5,<0.6`
25
+ - **Supported framework range:** `openai-agents>=0.20,<1.0`
26
+ - **Primitive reused:** [`needs_approval`, `RunResult.interruptions`, `RunState.approve` / `reject`](https://openai.github.io/openai-agents-python/tools/). Read 2026-09-05.
27
+ - **Framework shape:** decided before invocation (SPEC-v0.5 §3.5).
28
+ - **Conformance:** `4/4 (2 not applicable)` — `binding` and `denial` are N/A, with the reasons below. **Never reported as 6/6.**
29
+
30
+ ## You probably do not need this
31
+
32
+ `@protect` already covers anything running in your process — including a plain `@function_tool`
33
+ body — with no adapter and no framework support. **Most people reading this need `@protect` and
34
+ nothing else.** This buys one thing: when the policy says a human must approve, the SDK stops the
35
+ run with a `ToolApprovalItem` instead of `ApprovalRequired` being raised past the runner.
36
+
37
+ `ctrlrun gateway` is the third way in, and it is not an adapter: it puts the same guarantees in
38
+ front of an MCP tool server, in any language, with no agent change.
39
+
40
+ ## Use
41
+
42
+ ```python
43
+ from ctrlrun import Control, InterruptApprovalProvider, protect
44
+ import ctrlrun_openai_agents as gate
45
+ from ctrlrun_openai_agents import AgentsInterrupt, protected_tool
46
+
47
+ control = Control(
48
+ policy, store,
49
+ approvals=InterruptApprovalProvider(store, AgentsInterrupt()),
50
+ identity=..., authority=...,
51
+ )
52
+
53
+ @protect("stripe.refund", effect="refund:{payment_id}", wait=True, control=control)
54
+ def issue_refund(payment_id: str, amount: int) -> str:
55
+ return stripe.Refund.create(payment_intent=payment_id, amount=amount)
56
+
57
+ async def refund_tool(payment_id: str, amount: int) -> str:
58
+ """Issue a refund for a payment. Amounts are in integer minor units."""
59
+ return issue_refund(payment_id=payment_id, amount=amount)
60
+
61
+ agent = Agent(name="refunds", tools=[protected_tool(control, "stripe.refund", refund_tool)])
62
+
63
+ result = await gate.run(agent, "refund txn_1")
64
+ if result.interruptions:
65
+ state = result.to_state()
66
+ for item in result.interruptions:
67
+ state.approve(item) # or state.reject(item)
68
+ result = await gate.run(agent, state)
69
+ ```
70
+
71
+ **The operator constructs the `Control`** — this adapter never does (SPEC-v0.5 §2.3), so the
72
+ identity provider, the authority document, the environment and the mode are all chosen on the
73
+ line above, by the person deploying it.
74
+
75
+ ### Two helpers, and why they are not optional
76
+
77
+ **`protected_tool(...)`** builds the `function_tool` with `needs_approval=` wired to the policy
78
+ **and `failure_error_function=None`**. This SDK's default is `default_tool_error_function`, which
79
+ catches a tool's exception and returns *"An error occurred while running the tool. Please try
80
+ again."* **to the model**. Under that default an `ActionDenied`, a `DuplicateEffect` or an
81
+ `AmbiguousEffect` reaches your agent as a suggestion to retry — which is the exact failure
82
+ `SPEC-v0.2 §6.10` argues about in the gateway: a refusal by CTRLRun is not an outcome of the
83
+ tool, it is the statement that the tool did not run, and putting it in a channel whose contents
84
+ reach the model as text invites the retry the refusal exists to prevent.
85
+
86
+ **`gate.run(...)` / `gate.run_sync(...)`** are `Runner.run` with CTRLRun's exceptions arriving as
87
+ themselves. The SDK wraps whatever a tool raises in `agents.exceptions.UserError` and chains the
88
+ original as `__cause__`, so a plain `except DuplicateEffect` at your call site never fires. These
89
+ walk the chain and give it back; they decide nothing and hold nothing. `unwrap(error)` is the
90
+ same thing if you would rather call `Runner` yourself.
91
+
92
+ ## The binding: this adapter's is **attribution**
93
+
94
+ `carries_approved_arguments` is `False`, and unlike the LangGraph adapter it is **not a
95
+ constructor argument** — it is a fact about this SDK rather than a choice a deployment makes.
96
+
97
+ The arguments a human answered against live on the `ToolApprovalItem`, which the *caller* holds
98
+ in `RunResult.interruptions`. They are not reachable from a tool body: the run context records
99
+ that a call was approved, keyed by tool name and `call_id`, and not what its arguments were. An
100
+ adapter that handed back the tool's own parameters would be handing back what it was just given,
101
+ which SPEC-v0.5 §3.4 names as manufacturing the check.
102
+
103
+ So CTRLRun still binds the approval to the action that executes — that is `v0.1 §4.2 A1` and it
104
+ holds unconditionally — but **the binding across the interrupt is the SDK's, not CTRLRun's**. In
105
+ that word: *attribution*. The conformance kit reports `binding: not_applicable` with the reason,
106
+ never a pass.
107
+
108
+ **What closes the gap instead is real, and it is the SDK's.** The approval item and the
109
+ invocation are the **same tool call**, bound by `call_id`, and the SDK invokes with exactly that
110
+ call's arguments — it does not re-ask the model in between. That is a strong property. It is
111
+ simply not one CTRLRun can verify, which is the whole distinction §3.4 draws.
112
+
113
+ ## A rejection leaves no CTRLRun evidence
114
+
115
+ The one place this adapter's evidence differs from `@protect`'s, and worth knowing before you go
116
+ looking for an empty log.
117
+
118
+ The SDK does **not invoke** a tool whose approval was refused. So no CTRLRun action is proposed:
119
+ there is no `APPROVAL_DENIED`, no `ACTION_DENIED` and **no receipt**. The refusal is real and it
120
+ is in the SDK's own run output; CTRLRun was never asked about it. The conformance kit reports
121
+ `denial: not_applicable` for the same reason.
122
+
123
+ If you need refusals in the evidence log, record them where you call `state.reject(item)`.
124
+
125
+ ## Where this SDK's behaviour shows through the contract
126
+
127
+ SPEC-v0.5 §7 item 5.
128
+
129
+ **The predicate and `@protect` can disagree.** `approval_gate` answers the SDK's pre-invocation
130
+ question with `ctrlrun.adapter.needs_approval`, which sees the framework's raw arguments — not
131
+ the defaults `@protect` applies, and not a `resource=` template declared only on the decorator.
132
+
133
+ A wrong `True` asks a human about something harmless. A wrong `False` means the SDK does not
134
+ pre-ask, `Control.execute` raises `ApprovalRequired`, and the interrupt finds the SDK holds no
135
+ answer for a call it was never asked about — so **the action is refused** with
136
+ `ApprovalNotAsked`, nothing is written, and the approval request is left `pending` for
137
+ `ctrlrun approve` to answer out of band. In neither direction does an action execute that a
138
+ human did not approve.
139
+
140
+ That sentence is load-bearing and it was not always true here. `AgentsInterrupt.interrupt()`
141
+ originally returned `granted=True` unconditionally, reasoning that a tool body which runs *is*
142
+ the approval. It is — but only for a call the SDK's gate actually asked about, and on the wrong
143
+ `False` path it had not. An independent review found it: a $1,000 refund executing with no human
144
+ and a receipt naming `openai-agents:tool-approval` as the approver, which is a grant nobody made
145
+ written into the evidence log. `interrupt()` now reads
146
+ the SDK's **per-call** approval record — `True`, `False`, or `None` for a call nobody was asked
147
+ about — and only the first two are answers. Not the public `is_tool_approved`, which falls back
148
+ to a sticky per-tool decision that `always_approve=True` sets and would answer `True` for later
149
+ calls no human saw.
150
+
151
+ Two further bindings, because that record answers for a **tool call** and a tool body may raise
152
+ `ApprovalRequired` more than once:
153
+
154
+ - **The answer is bound to the action `protected_tool` gated.** A refund's yes does not
155
+ authorize a `bank.wire` raised beside it in the same body — a different action, a different
156
+ policy row, a different authority scope, and no approval item a human ever saw.
157
+ - **One answer authorizes one request.** A second approval request under the same tool call is a
158
+ decision nobody made.
159
+
160
+ Both are refused with `ApprovalNotAsked`: nothing is written and the request stays `pending`.
161
+ `always_approve=True` is refused the same way — it records a decision about the tool rather than
162
+ about the call, and this adapter will not read it as an answer for a specific action.
163
+
164
+ **So `@protect(wait=True)` on this `Control` must go through `protected_tool`.** A plain
165
+ `function_tool`, a background job, or any other protected call on the same `Control` reaches the
166
+ interrupt with no SDK tool call in scope, and is refused the same way. The provider hangs off the
167
+ `Control` and not off the tool; nothing else links the two.
168
+
169
+ Pass the same `resource=` to `protected_tool`, give the tool no defaulted parameters, and the
170
+ two agree — and then no call is refused this way at all.
171
+
172
+ **Exceptions are wrapped**, and `failure_error_function` swallows them by default. See above;
173
+ this is the one thing an adapter for this SDK cannot leave alone.
174
+
175
+ **Retries.** The SDK's default handling of a tool that raised surfaces the error to the model,
176
+ which may act on it. Measured on `openai-agents` 0.22.0 against a remote that commits and then
177
+ drops the connection, with no effect-level guard, the model retried until the refund had landed
178
+ **three or four times in a single run**, five runs out of five
179
+ (`research/framework-probe/results/2026-09-05.json`). That is behaviour, not quality — it is what
180
+ the documentation says `failure_error_function` does — and it is the clearest argument for
181
+ declaring an `effect=` on anything consequential.
182
+
183
+ ## What this adapter does not do
184
+
185
+ It is **not a second approval path**: it reuses the SDK's own approval interruption and
186
+ reimplements nothing — no prompt, no queue, no polling loop, no resume token of its own. It
187
+ **grants nothing**: the answer is recorded by `InterruptApprovalProvider`, in core, through the
188
+ same two store calls `ctrlrun approve` makes. It **constructs no `Control`** and **supplies no
189
+ principal**.
190
+
191
+ And it is **not a compliance claim**. "Conformance" names a suite of the CTRLRun repository's own
192
+ acceptance tests, run against this adapter. It certifies nothing.
193
+
194
+ ## Versioning
195
+
196
+ `adapters-openai-agents-MAJOR.MINOR`, never a kernel version. This adapter answers to two
197
+ upstreams and neither is the CTRLRun roadmap. The two ranges at the top are what its CI actually
198
+ ran against.
@@ -0,0 +1,180 @@
1
+ # ctrlrun-openai-agents
2
+
3
+ Route a CTRLRun `APPROVE` through the **OpenAI Agents SDK's own tool-approval interruption**, so
4
+ the human answers where this SDK's users already answer.
5
+
6
+ - **Supported kernel range:** `ctrlrun>=0.5,<0.6`
7
+ - **Supported framework range:** `openai-agents>=0.20,<1.0`
8
+ - **Primitive reused:** [`needs_approval`, `RunResult.interruptions`, `RunState.approve` / `reject`](https://openai.github.io/openai-agents-python/tools/). Read 2026-09-05.
9
+ - **Framework shape:** decided before invocation (SPEC-v0.5 §3.5).
10
+ - **Conformance:** `4/4 (2 not applicable)` — `binding` and `denial` are N/A, with the reasons below. **Never reported as 6/6.**
11
+
12
+ ## You probably do not need this
13
+
14
+ `@protect` already covers anything running in your process — including a plain `@function_tool`
15
+ body — with no adapter and no framework support. **Most people reading this need `@protect` and
16
+ nothing else.** This buys one thing: when the policy says a human must approve, the SDK stops the
17
+ run with a `ToolApprovalItem` instead of `ApprovalRequired` being raised past the runner.
18
+
19
+ `ctrlrun gateway` is the third way in, and it is not an adapter: it puts the same guarantees in
20
+ front of an MCP tool server, in any language, with no agent change.
21
+
22
+ ## Use
23
+
24
+ ```python
25
+ from ctrlrun import Control, InterruptApprovalProvider, protect
26
+ import ctrlrun_openai_agents as gate
27
+ from ctrlrun_openai_agents import AgentsInterrupt, protected_tool
28
+
29
+ control = Control(
30
+ policy, store,
31
+ approvals=InterruptApprovalProvider(store, AgentsInterrupt()),
32
+ identity=..., authority=...,
33
+ )
34
+
35
+ @protect("stripe.refund", effect="refund:{payment_id}", wait=True, control=control)
36
+ def issue_refund(payment_id: str, amount: int) -> str:
37
+ return stripe.Refund.create(payment_intent=payment_id, amount=amount)
38
+
39
+ async def refund_tool(payment_id: str, amount: int) -> str:
40
+ """Issue a refund for a payment. Amounts are in integer minor units."""
41
+ return issue_refund(payment_id=payment_id, amount=amount)
42
+
43
+ agent = Agent(name="refunds", tools=[protected_tool(control, "stripe.refund", refund_tool)])
44
+
45
+ result = await gate.run(agent, "refund txn_1")
46
+ if result.interruptions:
47
+ state = result.to_state()
48
+ for item in result.interruptions:
49
+ state.approve(item) # or state.reject(item)
50
+ result = await gate.run(agent, state)
51
+ ```
52
+
53
+ **The operator constructs the `Control`** — this adapter never does (SPEC-v0.5 §2.3), so the
54
+ identity provider, the authority document, the environment and the mode are all chosen on the
55
+ line above, by the person deploying it.
56
+
57
+ ### Two helpers, and why they are not optional
58
+
59
+ **`protected_tool(...)`** builds the `function_tool` with `needs_approval=` wired to the policy
60
+ **and `failure_error_function=None`**. This SDK's default is `default_tool_error_function`, which
61
+ catches a tool's exception and returns *"An error occurred while running the tool. Please try
62
+ again."* **to the model**. Under that default an `ActionDenied`, a `DuplicateEffect` or an
63
+ `AmbiguousEffect` reaches your agent as a suggestion to retry — which is the exact failure
64
+ `SPEC-v0.2 §6.10` argues about in the gateway: a refusal by CTRLRun is not an outcome of the
65
+ tool, it is the statement that the tool did not run, and putting it in a channel whose contents
66
+ reach the model as text invites the retry the refusal exists to prevent.
67
+
68
+ **`gate.run(...)` / `gate.run_sync(...)`** are `Runner.run` with CTRLRun's exceptions arriving as
69
+ themselves. The SDK wraps whatever a tool raises in `agents.exceptions.UserError` and chains the
70
+ original as `__cause__`, so a plain `except DuplicateEffect` at your call site never fires. These
71
+ walk the chain and give it back; they decide nothing and hold nothing. `unwrap(error)` is the
72
+ same thing if you would rather call `Runner` yourself.
73
+
74
+ ## The binding: this adapter's is **attribution**
75
+
76
+ `carries_approved_arguments` is `False`, and unlike the LangGraph adapter it is **not a
77
+ constructor argument** — it is a fact about this SDK rather than a choice a deployment makes.
78
+
79
+ The arguments a human answered against live on the `ToolApprovalItem`, which the *caller* holds
80
+ in `RunResult.interruptions`. They are not reachable from a tool body: the run context records
81
+ that a call was approved, keyed by tool name and `call_id`, and not what its arguments were. An
82
+ adapter that handed back the tool's own parameters would be handing back what it was just given,
83
+ which SPEC-v0.5 §3.4 names as manufacturing the check.
84
+
85
+ So CTRLRun still binds the approval to the action that executes — that is `v0.1 §4.2 A1` and it
86
+ holds unconditionally — but **the binding across the interrupt is the SDK's, not CTRLRun's**. In
87
+ that word: *attribution*. The conformance kit reports `binding: not_applicable` with the reason,
88
+ never a pass.
89
+
90
+ **What closes the gap instead is real, and it is the SDK's.** The approval item and the
91
+ invocation are the **same tool call**, bound by `call_id`, and the SDK invokes with exactly that
92
+ call's arguments — it does not re-ask the model in between. That is a strong property. It is
93
+ simply not one CTRLRun can verify, which is the whole distinction §3.4 draws.
94
+
95
+ ## A rejection leaves no CTRLRun evidence
96
+
97
+ The one place this adapter's evidence differs from `@protect`'s, and worth knowing before you go
98
+ looking for an empty log.
99
+
100
+ The SDK does **not invoke** a tool whose approval was refused. So no CTRLRun action is proposed:
101
+ there is no `APPROVAL_DENIED`, no `ACTION_DENIED` and **no receipt**. The refusal is real and it
102
+ is in the SDK's own run output; CTRLRun was never asked about it. The conformance kit reports
103
+ `denial: not_applicable` for the same reason.
104
+
105
+ If you need refusals in the evidence log, record them where you call `state.reject(item)`.
106
+
107
+ ## Where this SDK's behaviour shows through the contract
108
+
109
+ SPEC-v0.5 §7 item 5.
110
+
111
+ **The predicate and `@protect` can disagree.** `approval_gate` answers the SDK's pre-invocation
112
+ question with `ctrlrun.adapter.needs_approval`, which sees the framework's raw arguments — not
113
+ the defaults `@protect` applies, and not a `resource=` template declared only on the decorator.
114
+
115
+ A wrong `True` asks a human about something harmless. A wrong `False` means the SDK does not
116
+ pre-ask, `Control.execute` raises `ApprovalRequired`, and the interrupt finds the SDK holds no
117
+ answer for a call it was never asked about — so **the action is refused** with
118
+ `ApprovalNotAsked`, nothing is written, and the approval request is left `pending` for
119
+ `ctrlrun approve` to answer out of band. In neither direction does an action execute that a
120
+ human did not approve.
121
+
122
+ That sentence is load-bearing and it was not always true here. `AgentsInterrupt.interrupt()`
123
+ originally returned `granted=True` unconditionally, reasoning that a tool body which runs *is*
124
+ the approval. It is — but only for a call the SDK's gate actually asked about, and on the wrong
125
+ `False` path it had not. An independent review found it: a $1,000 refund executing with no human
126
+ and a receipt naming `openai-agents:tool-approval` as the approver, which is a grant nobody made
127
+ written into the evidence log. `interrupt()` now reads
128
+ the SDK's **per-call** approval record — `True`, `False`, or `None` for a call nobody was asked
129
+ about — and only the first two are answers. Not the public `is_tool_approved`, which falls back
130
+ to a sticky per-tool decision that `always_approve=True` sets and would answer `True` for later
131
+ calls no human saw.
132
+
133
+ Two further bindings, because that record answers for a **tool call** and a tool body may raise
134
+ `ApprovalRequired` more than once:
135
+
136
+ - **The answer is bound to the action `protected_tool` gated.** A refund's yes does not
137
+ authorize a `bank.wire` raised beside it in the same body — a different action, a different
138
+ policy row, a different authority scope, and no approval item a human ever saw.
139
+ - **One answer authorizes one request.** A second approval request under the same tool call is a
140
+ decision nobody made.
141
+
142
+ Both are refused with `ApprovalNotAsked`: nothing is written and the request stays `pending`.
143
+ `always_approve=True` is refused the same way — it records a decision about the tool rather than
144
+ about the call, and this adapter will not read it as an answer for a specific action.
145
+
146
+ **So `@protect(wait=True)` on this `Control` must go through `protected_tool`.** A plain
147
+ `function_tool`, a background job, or any other protected call on the same `Control` reaches the
148
+ interrupt with no SDK tool call in scope, and is refused the same way. The provider hangs off the
149
+ `Control` and not off the tool; nothing else links the two.
150
+
151
+ Pass the same `resource=` to `protected_tool`, give the tool no defaulted parameters, and the
152
+ two agree — and then no call is refused this way at all.
153
+
154
+ **Exceptions are wrapped**, and `failure_error_function` swallows them by default. See above;
155
+ this is the one thing an adapter for this SDK cannot leave alone.
156
+
157
+ **Retries.** The SDK's default handling of a tool that raised surfaces the error to the model,
158
+ which may act on it. Measured on `openai-agents` 0.22.0 against a remote that commits and then
159
+ drops the connection, with no effect-level guard, the model retried until the refund had landed
160
+ **three or four times in a single run**, five runs out of five
161
+ (`research/framework-probe/results/2026-09-05.json`). That is behaviour, not quality — it is what
162
+ the documentation says `failure_error_function` does — and it is the clearest argument for
163
+ declaring an `effect=` on anything consequential.
164
+
165
+ ## What this adapter does not do
166
+
167
+ It is **not a second approval path**: it reuses the SDK's own approval interruption and
168
+ reimplements nothing — no prompt, no queue, no polling loop, no resume token of its own. It
169
+ **grants nothing**: the answer is recorded by `InterruptApprovalProvider`, in core, through the
170
+ same two store calls `ctrlrun approve` makes. It **constructs no `Control`** and **supplies no
171
+ principal**.
172
+
173
+ And it is **not a compliance claim**. "Conformance" names a suite of the CTRLRun repository's own
174
+ acceptance tests, run against this adapter. It certifies nothing.
175
+
176
+ ## Versioning
177
+
178
+ `adapters-openai-agents-MAJOR.MINOR`, never a kernel version. This adapter answers to two
179
+ upstreams and neither is the CTRLRun roadmap. The two ranges at the top are what its CI actually
180
+ ran against.
@@ -0,0 +1,37 @@
1
+ # ctrlrun-openai-agents — 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-openai-agents"
11
+ version = "1.0.0"
12
+ description = "Route a CTRLRun APPROVE through the OpenAI Agents SDK's tool-approval interruption."
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 = ["openai-agents", "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
+ # SPEC-v0.5 §6.3's two ranges. Ranges and not floors: the adapter contract is frozen at v1.0 and
25
+ # not before, so `>=0.5` would claim compatibility with a surface not yet written. T137 asserts
26
+ # the README states these and that CI ran inside them.
27
+ dependencies = [
28
+ "ctrlrun>=0.5,<0.6",
29
+ "openai-agents>=0.20,<1.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/CTRLRun/ctrlrun"
34
+ Repository = "https://github.com/CTRLRun/ctrlrun"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,521 @@
1
+ """Route a CTRLRun `APPROVE` through the OpenAI Agents SDK's own tool-approval interruption.
2
+ SPEC-v0.5 §2, §3.5.
3
+
4
+ **An adapter exists for exactly one reason**, and this is the whole of it: when a policy says a
5
+ refund needs a human, the SDK stops the run with a `ToolApprovalItem` and the human answers
6
+ through `state.approve(...)` / `state.reject(...)` — where this SDK's users already answer —
7
+ instead of `ApprovalRequired` being raised past the runner.
8
+
9
+ This is the **decided-before-invocation** shape (§3.5), and it is the other of the two the
10
+ contract covers. The SDK asks whether a tool call needs approval *before* it invokes the tool,
11
+ so the adapter answers that question with `ctrlrun.adapter.needs_approval` — which resolves the
12
+ principal from the `Control`, builds the Action and evaluates, and **writes nothing**, so a
13
+ predicate the SDK may call more than once leaves no events behind.
14
+
15
+ **You probably do not need this.** `@protect` covers anything in this process with no adapter
16
+ and no framework support. This buys the interrupt and nothing else.
17
+
18
+ Supported kernel range: `ctrlrun>=0.5,<0.6`.
19
+ Supported framework range: `openai-agents>=0.20,<1.0`.
20
+ `README.md` states both, and states why this adapter's binding is **attribution** where
21
+ LangGraph's is prevention.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import dataclasses
27
+ from collections.abc import Callable, Iterator, Mapping
28
+ from contextlib import contextmanager
29
+ from contextvars import ContextVar
30
+ from typing import TYPE_CHECKING, Any
31
+
32
+ from ctrlrun import ApprovalAnswer, PendingApproval
33
+ from ctrlrun.adapter import banner
34
+ from ctrlrun.adapter import needs_approval as _needs_approval
35
+ from ctrlrun.errors import InvalidArgument
36
+ from ctrlrun.policy import OBSERVE
37
+
38
+ if TYPE_CHECKING: # pragma: no cover - an adapter constructs no Control (SPEC-v0.5 §2.3)
39
+ from agents.tool_context import ToolContext
40
+
41
+ from ctrlrun import Control
42
+
43
+ __all__ = [
44
+ "CHANNEL",
45
+ "AgentsInterrupt",
46
+ "ApprovalNotAsked",
47
+ "approval_gate",
48
+ "protected_tool",
49
+ "run",
50
+ "run_sync",
51
+ "unwrap",
52
+ ]
53
+
54
+
55
+ class ApprovalNotAsked(RuntimeError):
56
+ """`interrupt()` was reached for a call the SDK never put to a human.
57
+
58
+ Not a denial: nobody said no, and nobody said yes. SPEC-v0.5 §10's first row is the
59
+ behaviour -- `interrupt()` raises, nothing is written, no grant and no denial, and the
60
+ request is left `pending` so `ctrlrun approve` can still answer it out of band.
61
+
62
+ It is raised on the two paths where this adapter cannot see an answer:
63
+
64
+ * The call reached `Control.execute` without going through `protected_tool`, so no SDK tool
65
+ call is in scope at all -- a plain `function_tool`, a background job, or any other
66
+ `@protect(wait=True)` on the same `Control`. The provider hangs off the `Control` and not
67
+ off the tool, so nothing else links the two.
68
+ * The SDK's own gate never asked about **this call**, which the per-call approval lookup
69
+ reports as `None`. That happens whenever `needs_approval` and the kernel disagree -- the
70
+ predicate sees the arguments the model sent, `Control.execute` sees the arguments the
71
+ function was called with after Python applied its defaults (§3.5) -- and also when the
72
+ human answered with `always_approve`, which records a decision about the *tool* and not
73
+ about this call.
74
+ * The approval belongs to a **different action**. One tool body may raise
75
+ `ApprovalRequired` more than once; the SDK's record is keyed by the tool call, so without
76
+ this a single yes authorized every action raised under it.
77
+ * A **second** approval request arrives under one tool call. One human answer authorizes
78
+ one request.
79
+ """
80
+
81
+
82
+ #: The SDK tool call currently being invoked through `protected_tool`, or `None`.
83
+ #:
84
+ #: `FunctionTool.on_invoke_tool` receives the `ToolContext` -- which carries the tool name, the
85
+ #: `call_id` and the SDK's own record of what the human answered -- and a tool *body* does not.
86
+ #: `protected_tool` wraps the former and binds it here for the duration of the call, so
87
+ #: `interrupt()` can ask the SDK rather than assume it.
88
+ #:
89
+ #: A `ContextVar` rather than a module global: `Runner` runs tool calls concurrently, and a
90
+ #: global would let one call read another's answer. Bound and reset around each invocation, so
91
+ #: it is empty everywhere else -- which is what makes the "no SDK call in scope" path detectable
92
+ #: rather than silently stale.
93
+ class _GatedCall:
94
+ """One SDK tool call, and **the one action its approval answers for**.
95
+
96
+ The action matters as much as the call. The SDK's approval record is keyed by
97
+ `(tool_name, call_id)`, and a tool body may raise `ApprovalRequired` more than once, for
98
+ different actions -- a refund and then a wire. Every one of them arrives at `interrupt()`
99
+ under that same key, so reading the record without asking *what is being approved* answers
100
+ yes to all of them. A human who approved a $5 refund would be recorded as having approved a
101
+ $1,000,000 wire they were never shown.
102
+
103
+ `answered` bounds it further: one human answer authorizes **one** approval request. A second
104
+ distinct `request_id` under the same tool call is a second decision nobody made, even when
105
+ it is for the same action with the same arguments.
106
+ """
107
+
108
+ __slots__ = ("action", "answered", "context")
109
+
110
+ def __init__(self, context: ToolContext, action: str) -> None:
111
+ self.context = context
112
+ self.action = action
113
+ self.answered: set[str] = set()
114
+
115
+
116
+ _CURRENT_CALL: ContextVar[_GatedCall | None] = ContextVar(
117
+ "ctrlrun_openai_agents_current_call", default=None
118
+ )
119
+
120
+
121
+ @contextmanager
122
+ def _bound_call(context: ToolContext, action: str) -> Iterator[None]:
123
+ token = _CURRENT_CALL.set(_GatedCall(context, action))
124
+ try:
125
+ yield
126
+ finally:
127
+ _CURRENT_CALL.reset(token)
128
+
129
+
130
+ #: What reaches a receipt's `approver`. It names a **channel**, never a person: the SDK records
131
+ #: that a tool call was approved and not by whom, and inventing a name would be manufacturing
132
+ #: evidence. SPEC-v0.3 §13 keeps authenticating the approver out of scope, and `v0.1`'s
133
+ #: `"cli:local"` is the same register.
134
+ CHANNEL = "openai-agents:tool-approval"
135
+
136
+
137
+ def _answer_for_this_call(context: ToolContext) -> bool | None:
138
+ """What a human answered about **this** tool call, or `None` if nobody answered about it.
139
+
140
+ Deliberately **not** `RunContextWrapper.is_tool_approved`, which is the obvious call and is
141
+ wrong here. That method falls back to a *sticky* decision keyed by tool name -- the record
142
+ `state.approve(item, always_approve=True)` writes -- and returns `True` for every later
143
+ `call_id` of that tool, including calls no human has seen. Its own source says so: the
144
+ exact-call lookup comes first, and `if approval_entry.approved is True: return True` is what
145
+ runs when that misses.
146
+
147
+ A human who ticked *always approve refunds* has not approved this refund. CTRLRun's whole
148
+ claim is that a grant binds to one action -- `v0.1 §4.2` consumes it against an
149
+ `action_hash` -- and a blanket yes for a tool name is precisely what that exists to refuse.
150
+ With `carries_approved_arguments = False` there is no binding check in core to catch it
151
+ afterwards, so this is the only place it can be caught.
152
+
153
+ `_get_per_call_approval_status_for_key` returns exact-call decisions only and ignores sticky
154
+ ones, which is the question worth asking. It is private to the SDK, so its absence is treated
155
+ as *no answer* rather than falling back to the sticky reading: an SDK release that removes it
156
+ makes this adapter refuse, loudly and in one place, instead of silently going back to
157
+ granting on somebody else's blanket yes. `README.md` states the supported framework range and
158
+ T137 pins it to what CI installed.
159
+ """
160
+ per_call = getattr(context, "_get_per_call_approval_status_for_key", None)
161
+ if per_call is None: # pragma: no cover - a framework outside the supported range
162
+ return None
163
+ answer: bool | None = per_call(context.tool_name, context.tool_call_id)
164
+ return answer
165
+
166
+
167
+ class AgentsInterrupt:
168
+ """The SDK's tool-approval interruption, seen from inside the tool.
169
+
170
+ By the time a protected tool body runs, the SDK has already put the call to a human and been
171
+ told yes: `needs_approval` said the call needed one, the run stopped with a
172
+ `ToolApprovalItem`, somebody called `state.approve(item)`, and the SDK re-ran and invoked the
173
+ tool. So this returns the grant the SDK already holds.
174
+
175
+ **`carries_approved_arguments` is `False`, and it is not a setting.** It is a fact about this
176
+ framework: the arguments a human answered against live on the `ToolApprovalItem`, which the
177
+ *caller* holds in `RunResult.interruptions` and which is not reachable from a tool body — the
178
+ run context records that a call was approved, keyed by tool name and `call_id`, and not what
179
+ its arguments were. An adapter that handed back the tool's own parameters would be handing
180
+ back what it was given, which SPEC-v0.5 §3.4 names as manufacturing the check.
181
+
182
+ So the binding across this interrupt is **attribution**, and `README.md` says so in that
183
+ word. What closes the gap in practice is the SDK's own binding rather than CTRLRun's: the
184
+ approval item and the invocation are the *same tool call*, bound by `call_id`, and the SDK
185
+ invokes with exactly that call's arguments. That is a real property of the framework and it
186
+ is not one CTRLRun can verify, which is the whole distinction §3.4 draws.
187
+ """
188
+
189
+ framework = "openai-agents"
190
+ #: A fact about the framework, not a choice. See the class docstring and `README.md`.
191
+ carries_approved_arguments = False
192
+
193
+ def interrupt(self, pending: PendingApproval) -> ApprovalAnswer:
194
+ """Return the answer **the SDK holds** for this call, and never one of this adapter's.
195
+
196
+ There is no call out to the framework here, and that is this shape: the SDK asked before
197
+ it invoked, so the answer already exists by the time a tool body reaches
198
+ `Control.execute`. The adapter's job is to *read* it — asking again would be a second
199
+ approval path.
200
+
201
+ Reading it is the whole of the job, and this method used to skip it. It returned
202
+ `granted=True` unconditionally, on the premise that *a tool body that runs is the
203
+ approval*. That premise holds only when the SDK's gate actually asked and was told yes,
204
+ and there are reachable paths where it did not:
205
+
206
+ * A `@protect(wait=True)` call on the same `Control` that never went through
207
+ `protected_tool`. The provider hangs off the `Control`, not off the tool.
208
+ * A call the SDK's gate passed without asking, because `needs_approval` and the kernel
209
+ saw different arguments — the predicate sees what the model sent, `Control.execute`
210
+ sees what the function was called with after Python applied its defaults. §3.5 says
211
+ that divergence is harmless *because the human is asked anyway*; a self-granting
212
+ `interrupt()` is what made it unsafe, and the fix belongs here rather than in §3.5.
213
+
214
+ On both, an `APPROVE` executed with no human and wrote a receipt naming an approver
215
+ nobody was — a grant fabricated into the evidence log, which is what §2.3's ban on
216
+ `StateStore.append_event` exists to prevent, reached through the sanctioned door.
217
+
218
+ The SDK's record is read **per call** by `_answer_for_this_call`, which has the three
219
+ states this needs: `True` (a human approved this call), `False` (a human refused it) and
220
+ `None` (nobody was asked about it). Only the first two are answers, and the public
221
+ `is_tool_approved` is not what is read -- see that helper for why.
222
+
223
+ The record answers for a **tool call**, and a tool body may raise `ApprovalRequired`
224
+ more than once. So the answer is bound to the action `protected_tool` gated and to one
225
+ request: a refund's yes does not authorize a wire raised beside it.
226
+
227
+ A **rejection** normally never reaches here: the SDK does not invoke a tool whose
228
+ approval was refused, so the run ends with the rejection in its own output and no
229
+ CTRLRun action is proposed. §7 of `README.md` records that, because it is the one place
230
+ this adapter's evidence differs from `@protect`'s. `False` is still returned as a
231
+ denial rather than assumed unreachable — a human's *no* is an answer, and §2.4 says it
232
+ is recorded by the provider like any other.
233
+ """
234
+ call = _CURRENT_CALL.get()
235
+ if call is None:
236
+ raise ApprovalNotAsked(
237
+ f"{pending.action} reached the approval interrupt outside a tool call driven by "
238
+ "protected_tool, so the OpenAI Agents SDK was never asked and holds no answer. "
239
+ "Nothing was granted and the request is still pending: answer it with "
240
+ "`ctrlrun approve`, or drive the call through protected_tool()."
241
+ )
242
+
243
+ context = call.context
244
+ if pending.action != call.action:
245
+ raise ApprovalNotAsked(
246
+ f"{pending.action} needs approval, but the OpenAI Agents SDK gated "
247
+ f"{call.action!r} for this tool call ({context.tool_name}/"
248
+ f"{context.tool_call_id}). The human answered about {call.action!r} and was "
249
+ f"never shown {pending.action}: a different action, a different policy row and "
250
+ "a different authority scope. Nothing was granted and the request is still "
251
+ "pending: answer it with `ctrlrun approve`, or gate this action with its own "
252
+ "protected_tool()."
253
+ )
254
+ if call.answered and pending.request_id not in call.answered:
255
+ raise ApprovalNotAsked(
256
+ f"{pending.action} needs a second approval under one tool call "
257
+ f"({context.tool_name}/{context.tool_call_id}), which was answered once. One "
258
+ "human answer authorizes one request; the second is a decision nobody made. "
259
+ "Nothing was granted and the request is still pending."
260
+ )
261
+
262
+ answered = _answer_for_this_call(context)
263
+ if answered is None:
264
+ raise ApprovalNotAsked(
265
+ f"{pending.action} needs approval, but the OpenAI Agents SDK never gated this "
266
+ f"call ({context.tool_name}/{context.tool_call_id}) and so holds no answer. "
267
+ "Either the needs_approval predicate and Control.execute disagreed about the "
268
+ "arguments (SPEC-v0.5 §3.5), or the human answered with always_approve, which "
269
+ "records a decision about the tool and not about this call. Nothing was granted "
270
+ "and the request is still pending: answer it with `ctrlrun approve`."
271
+ )
272
+
273
+ call.answered.add(pending.request_id)
274
+ return ApprovalAnswer(granted=answered, approver=CHANNEL)
275
+
276
+
277
+ def protected_tool(
278
+ control: Control,
279
+ action: str,
280
+ function: Callable[..., Any],
281
+ *,
282
+ resource: str | None = None,
283
+ **options: Any,
284
+ ) -> Any:
285
+ """Build a `function_tool` that is gated by `control` and whose refusals reach the caller.
286
+
287
+ Two things, and the second is the one this helper exists for.
288
+
289
+ **`needs_approval=approval_gate(...)`**, so the SDK asks before it invokes.
290
+
291
+ **`failure_error_function=None`**, so a CTRLRun refusal propagates out of `Runner.run`
292
+ instead of being turned into text. This SDK's default is `default_tool_error_function`,
293
+ which catches a tool's exception and returns *"An error occurred while running the tool.
294
+ Please try again."* to the **model**. Under that default an `ActionDenied`, a
295
+ `DuplicateEffect` or an `AmbiguousEffect` reaches an agent as a suggestion to retry — which
296
+ is the exact failure `v0.2 §6.10` argues about in the gateway: a refusal by CTRLRun is not
297
+ an outcome of the tool, it is the statement that the tool did not run, and putting it in a
298
+ channel whose contents reach the model as text invites the retry the refusal exists to
299
+ prevent.
300
+
301
+ An adapter that left the default in place cannot pass the conformance kit, and should not:
302
+ every `kernel` case asserts an exception the caller can see. `SPEC-v0.5.md` §12.6 records
303
+ this as a difference between the two reference adapters that the contract had not
304
+ anticipated — LangGraph propagates a tool's exception and this SDK does not.
305
+
306
+ Pass `failure_error_function=` yourself if you have a reason to; you are then responsible
307
+ for re-raising `ctrlrun.CTRLRunError`, and `README.md` says so.
308
+ """
309
+ from agents import function_tool
310
+
311
+ if "needs_approval" in options:
312
+ # `setdefault` here let a caller replace the policy gate with `lambda *a: False` -- one
313
+ # keyword that turns every APPROVE into an ungated call. SPEC-v0.5 §3.8: an adapter has
314
+ # no flag that relaxes a check, and a keyword that silently wins over the policy is one.
315
+ raise InvalidArgument(
316
+ "protected_tool() sets needs_approval= from the policy and will not take one: a "
317
+ "predicate that overrode it would be an approval gate the policy does not control. "
318
+ "Build the tool with agents.function_tool() yourself if that is what you want."
319
+ )
320
+ options.setdefault("failure_error_function", None)
321
+ options["needs_approval"] = approval_gate(control, action, resource=resource)
322
+ tool = function_tool(function, **options)
323
+
324
+ # Bind the SDK's own record of this call so `AgentsInterrupt.interrupt()` can read the
325
+ # answer instead of assuming one. `on_invoke_tool` is where the `ToolContext` -- tool name,
326
+ # `call_id`, and what the human answered -- is in scope; a tool *body* never sees it. This
327
+ # wraps that one call and touches neither the schema the model is shown nor the body.
328
+ invoke = tool.on_invoke_tool
329
+
330
+ async def _invoke_bound(context: ToolContext, arguments: str) -> Any:
331
+ with _bound_call(context, action):
332
+ return await invoke(context, arguments)
333
+
334
+ return dataclasses.replace(tool, on_invoke_tool=_invoke_bound)
335
+
336
+
337
+ def approval_gate(
338
+ control: Control,
339
+ action: str,
340
+ *,
341
+ resource: str | None = None,
342
+ ) -> Callable[[Any, Mapping[str, Any], str], Any]:
343
+ """The `needs_approval=` callable for a `@function_tool` (SPEC-v0.5 §3.5).
344
+
345
+ ``function_tool(needs_approval=approval_gate(control, "stripe.refund"))``
346
+
347
+ It answers the SDK's pre-invocation question with `ctrlrun.adapter.needs_approval` and
348
+ nothing else: `True` where the combined `v0.3 §4.6` decision is `APPROVE`, `False` otherwise.
349
+
350
+ A `DENY` returns `False` **on purpose**, so the SDK invokes the tool and `Control.execute`
351
+ denies it with a receipt, an `ACTION_DENIED` and the exception the caller catches. Refusing
352
+ inside the predicate would refuse without evidence, and `v0.3 §4.3` is explicit that a denial
353
+ with a principal to attribute it to belongs in the evidence log.
354
+
355
+ The predicate is core's, not this adapter's, because writing it here would have meant
356
+ building an `Action` — and `Action.principal` has no default, so the principal would have
357
+ come from the SDK's session. That is `--principal-from-client-info` (`v0.3 §8.1`), and it is
358
+ the hole SPEC-v0.5 §4.2 exists to close.
359
+
360
+ **The predicate and `@protect` can disagree**, and §3.5 says why: the decorator applies the
361
+ function's defaults and reads its own `resource=` template, and this sees neither. A wrong
362
+ `True` asks a human about something harmless. A wrong `False` means the SDK does not
363
+ pre-ask, `Control.execute` raises `ApprovalRequired`, and `AgentsInterrupt.interrupt()`
364
+ finds the SDK holds no answer for a call it never gated -- so the action is **refused** with
365
+ `ApprovalNotAsked`, nothing is written, and the request is left `pending`.
366
+
367
+ In neither direction does an action execute that a human did not approve. That sentence was
368
+ not true while `interrupt()` granted unconditionally: a wrong `False` executed. It is the
369
+ interrupt's reading of the SDK's per-call record that makes it true, not this predicate.
370
+
371
+ Pass the same `resource=` template the decorator has, and give the tool no defaulted
372
+ parameters, and the two agree and no call is refused this way.
373
+ """
374
+ if not action:
375
+ raise InvalidArgument("approval_gate(action=...) must be a non-empty action name")
376
+
377
+ # SPEC-v0.5 §3.6: logged once per `Control`, never printed, a no-op under `mode: enforce`.
378
+ # Here rather than in `interrupt()` because observe mode never raises `ApprovalRequired`, so
379
+ # the interrupt is exactly the place that is never reached in the mode the banner is for.
380
+ # This is the adapter's attach point -- the first place it is handed the operator's Control.
381
+ banner(control)
382
+
383
+ async def gate(context: Any, params: Mapping[str, Any], call_id: str) -> bool:
384
+ if control.policy.mode == OBSERVE:
385
+ # SPEC-v0.5 §3.6: an adapter never interrupts in observe mode. §3.6 argues it
386
+ # through `Control.execute` -- `ApprovalRequired` is never raised, so `wait()` is
387
+ # never called -- which is true of the resumed-in-place shape and **not of this
388
+ # one**: the primitive is reached from this predicate, one step earlier, and
389
+ # `needs_approval` is `Control.evaluate`, which `v0.3 §6.2` evaluates identically
390
+ # under observe mode. So without this the gate returned True, the SDK stopped the
391
+ # run, and a human was asked.
392
+ #
393
+ # The consequence is worse than the rule: this SDK does not invoke a tool whose
394
+ # approval was declined, so a human's *no* under observe mode would **stop the
395
+ # action** -- and observe mode's whole promise is that nothing is enforced. The
396
+ # action still runs, is still decided in full, and is still recorded; §12.9 has it.
397
+ return False
398
+ return _needs_approval(control, action, dict(params), resource=resource)
399
+
400
+ return gate
401
+
402
+
403
+ def unwrap(error: BaseException) -> BaseException:
404
+ """What the tool actually raised, with the SDK's wrappers taken off.
405
+
406
+ Usually that is a `CTRLRunError` -- an `ActionDenied`, a `DuplicateEffect` -- which is the
407
+ case §12.7 is about. It is deliberately **not** "the first `CTRLRunError` in the chain":
408
+ see the walk below for why that reached past the outcome into a nested failure.
409
+
410
+ This SDK wraps whatever a tool raises in `agents.exceptions.UserError` -- *"Error running
411
+ tool run_it: ..."* -- and chains the original as `__cause__`. So an operator's
412
+ `except DuplicateEffect` does not fire, and `except ActionDenied` does not fire, and the
413
+ one interface this library has for saying *the tool did not run* is lost in transit.
414
+
415
+ `v0.1 §8` prefers explicit exceptions over return codes for exactly this reason, and
416
+ `v0.2 §6.10` argues the same point about the gateway: a refusal by CTRLRun is not an outcome
417
+ of the tool, it is the statement that the tool did not run, and it must be distinguishable.
418
+ So this walks the chain and gives it back.
419
+
420
+ It changes no decision and grants nothing: it re-raises what already happened, in the type
421
+ the kernel raised it as.
422
+
423
+ **`__cause__` only, never `__context__`.** `__cause__` is explicit chaining -- `raise X from
424
+ Y` -- and is the only link that asserts *this error is behind that one*. `__context__` is
425
+ what the interpreter sets whenever any exception is raised while another is being handled,
426
+ and it asserts nothing about causation.
427
+
428
+ Following it here was a hole rather than a nicety. `@protect(wait=True)` runs its approved
429
+ leg **inside** `except ApprovalRequired as pending:`, so every exception on that leg carries
430
+ `__context__ = ApprovalRequired`. An executor that raised a `TimeoutError` after the request
431
+ went out -- which the kernel records as AMBIGUOUS and re-raises unchanged, `v0.1 §5.5` --
432
+ was walked back to that `ApprovalRequired` and handed to the caller as *"requires approval,
433
+ then retry"*. An ambiguous outcome reported as a definite did-not-run, with instructions to
434
+ do it again, on the one path this adapter exists for.
435
+ """
436
+ # Bounded by the exceptions already visited, because an exception chain is not guaranteed
437
+ # to be a chain. `raise X from Y` sets `X.__cause__ = Y`, and doing that where `Y.__cause__`
438
+ # is already `X` closes a **cycle** -- which `run`/`run_sync` below did, so `unwrap` on their
439
+ # output looped forever on exactly the AMBIGUOUS outcome it exists to preserve. That is
440
+ # fixed at the raise as well; this is the half that holds for a chain built anywhere else,
441
+ # and the two are tested separately because either alone would hide the other.
442
+ # Descend through the SDK's **own wrappers only**, and stop at the first thing that is not
443
+ # one -- whatever type that is.
444
+ #
445
+ # Walking every link instead, and returning the first `CTRLRunError` found anywhere, reached
446
+ # past the outcome. Any nested protected call whose error was chained (`raise X from inner`,
447
+ # which is idiomatic) puts a foreign `CTRLRunError` deeper in the chain: a refund recorded
448
+ # AMBIGUOUS came back to the caller as the audit call's `NotExecuted`, whose whole contract
449
+ # is *definitely did not run, safe to retry*. Acting on that retries a refund that may have
450
+ # landed, which is what `v0.1 §5.5` exists to prevent -- the same defect the `__context__`
451
+ # half of this walk was fixed for, left open on the `__cause__` side.
452
+ #
453
+ # Bounded by what it has visited, because a chain is not guaranteed to be acyclic: `raise X
454
+ # from Y` where `Y.__cause__` is already `X` closes a loop, and an unbounded walk never
455
+ # leaves it. `_reraise` keeps this module from building one; this holds for a chain built
456
+ # anywhere else, and the two are tested separately because either alone would hide the other.
457
+ from agents.exceptions import AgentsException, UserError
458
+
459
+ walked: set[int] = set()
460
+ seen: BaseException = error
461
+ while isinstance(seen, UserError | AgentsException) and id(seen) not in walked:
462
+ walked.add(id(seen))
463
+ if seen.__cause__ is None:
464
+ # A wrapper the SDK raised about its own configuration, with nothing behind it.
465
+ break
466
+ seen = seen.__cause__
467
+ return seen
468
+
469
+
470
+ def _reraise(recovered: BaseException, raised: BaseException) -> None:
471
+ """Re-raise the kernel's exception, without closing a cycle in the `__cause__` chain.
472
+
473
+ `raise recovered from raised` reads naturally and was wrong: `recovered` was found *by
474
+ walking* `raised.__cause__`, so setting `recovered.__cause__ = raised` points the chain back
475
+ at something that already points here. `unwrap` then walked it forever, and it did so on the
476
+ two paths that most need an answer -- an executor whose `TimeoutError` the kernel recorded
477
+ as AMBIGUOUS and re-raised (`v0.1 §5.5`), and this adapter's own `ApprovalNotAsked`.
478
+
479
+ There is no `from` clause at all, and no condition under which one would be right: `recovered`
480
+ is reachable from `raised` **by construction**, because walking `raised.__cause__` is how it
481
+ was found. So `raise recovered from raised` closes a cycle every time, not just sometimes --
482
+ a first attempt at this guarded on `recovered.__cause__ is None` and still built one, because
483
+ the kernel's exception legitimately has no cause of its own while the SDK's wrapper points
484
+ at it.
485
+
486
+ Nothing is lost. `recovered.__cause__` stays `None`, which is true -- an `ActionDenied` was
487
+ not *caused by* the `UserError` that wrapped it -- and raising inside an `except` block sets
488
+ `__context__` to the wrapper, which is what a traceback prints and how the SDK's frames stay
489
+ visible.
490
+ """
491
+ raise recovered
492
+
493
+
494
+ async def run(agent: Any, input: Any, **options: Any) -> Any:
495
+ """`Runner.run`, with CTRLRun's exceptions arriving as themselves (see `unwrap`).
496
+
497
+ Use it wherever you would use `Runner.run` and want `except DuplicateEffect` to work. It is
498
+ a thin pass-through: it decides nothing, holds nothing and adds no behaviour of its own.
499
+ """
500
+ from agents import Runner
501
+
502
+ try:
503
+ return await Runner.run(agent, input, **options)
504
+ except BaseException as raised:
505
+ recovered = unwrap(raised)
506
+ if recovered is not raised:
507
+ _reraise(recovered, raised)
508
+ raise
509
+
510
+
511
+ def run_sync(agent: Any, input: Any, **options: Any) -> Any:
512
+ """`Runner.run_sync`, with the same unwrapping."""
513
+ from agents import Runner
514
+
515
+ try:
516
+ return Runner.run_sync(agent, input, **options)
517
+ except BaseException as raised:
518
+ recovered = unwrap(raised)
519
+ if recovered is not raised:
520
+ _reraise(recovered, raised)
521
+ raise
@@ -0,0 +1,198 @@
1
+ Metadata-Version: 2.4
2
+ Name: ctrlrun-openai-agents
3
+ Version: 1.0.0
4
+ Summary: Route a CTRLRun APPROVE through the OpenAI Agents SDK's tool-approval interruption.
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: openai-agents,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: openai-agents<1.0,>=0.20
18
+
19
+ # ctrlrun-openai-agents
20
+
21
+ Route a CTRLRun `APPROVE` through the **OpenAI Agents SDK's own tool-approval interruption**, so
22
+ the human answers where this SDK's users already answer.
23
+
24
+ - **Supported kernel range:** `ctrlrun>=0.5,<0.6`
25
+ - **Supported framework range:** `openai-agents>=0.20,<1.0`
26
+ - **Primitive reused:** [`needs_approval`, `RunResult.interruptions`, `RunState.approve` / `reject`](https://openai.github.io/openai-agents-python/tools/). Read 2026-09-05.
27
+ - **Framework shape:** decided before invocation (SPEC-v0.5 §3.5).
28
+ - **Conformance:** `4/4 (2 not applicable)` — `binding` and `denial` are N/A, with the reasons below. **Never reported as 6/6.**
29
+
30
+ ## You probably do not need this
31
+
32
+ `@protect` already covers anything running in your process — including a plain `@function_tool`
33
+ body — with no adapter and no framework support. **Most people reading this need `@protect` and
34
+ nothing else.** This buys one thing: when the policy says a human must approve, the SDK stops the
35
+ run with a `ToolApprovalItem` instead of `ApprovalRequired` being raised past the runner.
36
+
37
+ `ctrlrun gateway` is the third way in, and it is not an adapter: it puts the same guarantees in
38
+ front of an MCP tool server, in any language, with no agent change.
39
+
40
+ ## Use
41
+
42
+ ```python
43
+ from ctrlrun import Control, InterruptApprovalProvider, protect
44
+ import ctrlrun_openai_agents as gate
45
+ from ctrlrun_openai_agents import AgentsInterrupt, protected_tool
46
+
47
+ control = Control(
48
+ policy, store,
49
+ approvals=InterruptApprovalProvider(store, AgentsInterrupt()),
50
+ identity=..., authority=...,
51
+ )
52
+
53
+ @protect("stripe.refund", effect="refund:{payment_id}", wait=True, control=control)
54
+ def issue_refund(payment_id: str, amount: int) -> str:
55
+ return stripe.Refund.create(payment_intent=payment_id, amount=amount)
56
+
57
+ async def refund_tool(payment_id: str, amount: int) -> str:
58
+ """Issue a refund for a payment. Amounts are in integer minor units."""
59
+ return issue_refund(payment_id=payment_id, amount=amount)
60
+
61
+ agent = Agent(name="refunds", tools=[protected_tool(control, "stripe.refund", refund_tool)])
62
+
63
+ result = await gate.run(agent, "refund txn_1")
64
+ if result.interruptions:
65
+ state = result.to_state()
66
+ for item in result.interruptions:
67
+ state.approve(item) # or state.reject(item)
68
+ result = await gate.run(agent, state)
69
+ ```
70
+
71
+ **The operator constructs the `Control`** — this adapter never does (SPEC-v0.5 §2.3), so the
72
+ identity provider, the authority document, the environment and the mode are all chosen on the
73
+ line above, by the person deploying it.
74
+
75
+ ### Two helpers, and why they are not optional
76
+
77
+ **`protected_tool(...)`** builds the `function_tool` with `needs_approval=` wired to the policy
78
+ **and `failure_error_function=None`**. This SDK's default is `default_tool_error_function`, which
79
+ catches a tool's exception and returns *"An error occurred while running the tool. Please try
80
+ again."* **to the model**. Under that default an `ActionDenied`, a `DuplicateEffect` or an
81
+ `AmbiguousEffect` reaches your agent as a suggestion to retry — which is the exact failure
82
+ `SPEC-v0.2 §6.10` argues about in the gateway: a refusal by CTRLRun is not an outcome of the
83
+ tool, it is the statement that the tool did not run, and putting it in a channel whose contents
84
+ reach the model as text invites the retry the refusal exists to prevent.
85
+
86
+ **`gate.run(...)` / `gate.run_sync(...)`** are `Runner.run` with CTRLRun's exceptions arriving as
87
+ themselves. The SDK wraps whatever a tool raises in `agents.exceptions.UserError` and chains the
88
+ original as `__cause__`, so a plain `except DuplicateEffect` at your call site never fires. These
89
+ walk the chain and give it back; they decide nothing and hold nothing. `unwrap(error)` is the
90
+ same thing if you would rather call `Runner` yourself.
91
+
92
+ ## The binding: this adapter's is **attribution**
93
+
94
+ `carries_approved_arguments` is `False`, and unlike the LangGraph adapter it is **not a
95
+ constructor argument** — it is a fact about this SDK rather than a choice a deployment makes.
96
+
97
+ The arguments a human answered against live on the `ToolApprovalItem`, which the *caller* holds
98
+ in `RunResult.interruptions`. They are not reachable from a tool body: the run context records
99
+ that a call was approved, keyed by tool name and `call_id`, and not what its arguments were. An
100
+ adapter that handed back the tool's own parameters would be handing back what it was just given,
101
+ which SPEC-v0.5 §3.4 names as manufacturing the check.
102
+
103
+ So CTRLRun still binds the approval to the action that executes — that is `v0.1 §4.2 A1` and it
104
+ holds unconditionally — but **the binding across the interrupt is the SDK's, not CTRLRun's**. In
105
+ that word: *attribution*. The conformance kit reports `binding: not_applicable` with the reason,
106
+ never a pass.
107
+
108
+ **What closes the gap instead is real, and it is the SDK's.** The approval item and the
109
+ invocation are the **same tool call**, bound by `call_id`, and the SDK invokes with exactly that
110
+ call's arguments — it does not re-ask the model in between. That is a strong property. It is
111
+ simply not one CTRLRun can verify, which is the whole distinction §3.4 draws.
112
+
113
+ ## A rejection leaves no CTRLRun evidence
114
+
115
+ The one place this adapter's evidence differs from `@protect`'s, and worth knowing before you go
116
+ looking for an empty log.
117
+
118
+ The SDK does **not invoke** a tool whose approval was refused. So no CTRLRun action is proposed:
119
+ there is no `APPROVAL_DENIED`, no `ACTION_DENIED` and **no receipt**. The refusal is real and it
120
+ is in the SDK's own run output; CTRLRun was never asked about it. The conformance kit reports
121
+ `denial: not_applicable` for the same reason.
122
+
123
+ If you need refusals in the evidence log, record them where you call `state.reject(item)`.
124
+
125
+ ## Where this SDK's behaviour shows through the contract
126
+
127
+ SPEC-v0.5 §7 item 5.
128
+
129
+ **The predicate and `@protect` can disagree.** `approval_gate` answers the SDK's pre-invocation
130
+ question with `ctrlrun.adapter.needs_approval`, which sees the framework's raw arguments — not
131
+ the defaults `@protect` applies, and not a `resource=` template declared only on the decorator.
132
+
133
+ A wrong `True` asks a human about something harmless. A wrong `False` means the SDK does not
134
+ pre-ask, `Control.execute` raises `ApprovalRequired`, and the interrupt finds the SDK holds no
135
+ answer for a call it was never asked about — so **the action is refused** with
136
+ `ApprovalNotAsked`, nothing is written, and the approval request is left `pending` for
137
+ `ctrlrun approve` to answer out of band. In neither direction does an action execute that a
138
+ human did not approve.
139
+
140
+ That sentence is load-bearing and it was not always true here. `AgentsInterrupt.interrupt()`
141
+ originally returned `granted=True` unconditionally, reasoning that a tool body which runs *is*
142
+ the approval. It is — but only for a call the SDK's gate actually asked about, and on the wrong
143
+ `False` path it had not. An independent review found it: a $1,000 refund executing with no human
144
+ and a receipt naming `openai-agents:tool-approval` as the approver, which is a grant nobody made
145
+ written into the evidence log. `interrupt()` now reads
146
+ the SDK's **per-call** approval record — `True`, `False`, or `None` for a call nobody was asked
147
+ about — and only the first two are answers. Not the public `is_tool_approved`, which falls back
148
+ to a sticky per-tool decision that `always_approve=True` sets and would answer `True` for later
149
+ calls no human saw.
150
+
151
+ Two further bindings, because that record answers for a **tool call** and a tool body may raise
152
+ `ApprovalRequired` more than once:
153
+
154
+ - **The answer is bound to the action `protected_tool` gated.** A refund's yes does not
155
+ authorize a `bank.wire` raised beside it in the same body — a different action, a different
156
+ policy row, a different authority scope, and no approval item a human ever saw.
157
+ - **One answer authorizes one request.** A second approval request under the same tool call is a
158
+ decision nobody made.
159
+
160
+ Both are refused with `ApprovalNotAsked`: nothing is written and the request stays `pending`.
161
+ `always_approve=True` is refused the same way — it records a decision about the tool rather than
162
+ about the call, and this adapter will not read it as an answer for a specific action.
163
+
164
+ **So `@protect(wait=True)` on this `Control` must go through `protected_tool`.** A plain
165
+ `function_tool`, a background job, or any other protected call on the same `Control` reaches the
166
+ interrupt with no SDK tool call in scope, and is refused the same way. The provider hangs off the
167
+ `Control` and not off the tool; nothing else links the two.
168
+
169
+ Pass the same `resource=` to `protected_tool`, give the tool no defaulted parameters, and the
170
+ two agree — and then no call is refused this way at all.
171
+
172
+ **Exceptions are wrapped**, and `failure_error_function` swallows them by default. See above;
173
+ this is the one thing an adapter for this SDK cannot leave alone.
174
+
175
+ **Retries.** The SDK's default handling of a tool that raised surfaces the error to the model,
176
+ which may act on it. Measured on `openai-agents` 0.22.0 against a remote that commits and then
177
+ drops the connection, with no effect-level guard, the model retried until the refund had landed
178
+ **three or four times in a single run**, five runs out of five
179
+ (`research/framework-probe/results/2026-09-05.json`). That is behaviour, not quality — it is what
180
+ the documentation says `failure_error_function` does — and it is the clearest argument for
181
+ declaring an `effect=` on anything consequential.
182
+
183
+ ## What this adapter does not do
184
+
185
+ It is **not a second approval path**: it reuses the SDK's own approval interruption and
186
+ reimplements nothing — no prompt, no queue, no polling loop, no resume token of its own. It
187
+ **grants nothing**: the answer is recorded by `InterruptApprovalProvider`, in core, through the
188
+ same two store calls `ctrlrun approve` makes. It **constructs no `Control`** and **supplies no
189
+ principal**.
190
+
191
+ And it is **not a compliance claim**. "Conformance" names a suite of the CTRLRun repository's own
192
+ acceptance tests, run against this adapter. It certifies nothing.
193
+
194
+ ## Versioning
195
+
196
+ `adapters-openai-agents-MAJOR.MINOR`, never a kernel version. This adapter answers to two
197
+ upstreams and neither is the CTRLRun roadmap. The two ranges at the top are what its CI actually
198
+ ran against.
@@ -0,0 +1,8 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/ctrlrun_openai_agents/__init__.py
4
+ src/ctrlrun_openai_agents.egg-info/PKG-INFO
5
+ src/ctrlrun_openai_agents.egg-info/SOURCES.txt
6
+ src/ctrlrun_openai_agents.egg-info/dependency_links.txt
7
+ src/ctrlrun_openai_agents.egg-info/requires.txt
8
+ src/ctrlrun_openai_agents.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ ctrlrun<0.6,>=0.5
2
+ openai-agents<1.0,>=0.20