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.
- ctrlrun_openai_agents-1.0.0/PKG-INFO +198 -0
- ctrlrun_openai_agents-1.0.0/README.md +180 -0
- ctrlrun_openai_agents-1.0.0/pyproject.toml +37 -0
- ctrlrun_openai_agents-1.0.0/setup.cfg +4 -0
- ctrlrun_openai_agents-1.0.0/src/ctrlrun_openai_agents/__init__.py +521 -0
- ctrlrun_openai_agents-1.0.0/src/ctrlrun_openai_agents.egg-info/PKG-INFO +198 -0
- ctrlrun_openai_agents-1.0.0/src/ctrlrun_openai_agents.egg-info/SOURCES.txt +8 -0
- ctrlrun_openai_agents-1.0.0/src/ctrlrun_openai_agents.egg-info/dependency_links.txt +1 -0
- ctrlrun_openai_agents-1.0.0/src/ctrlrun_openai_agents.egg-info/requires.txt +2 -0
- ctrlrun_openai_agents-1.0.0/src/ctrlrun_openai_agents.egg-info/top_level.txt +1 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ctrlrun_openai_agents
|