Skip to main content

ctrlrun-openai-agents

Route a CTRLRun APPROVE through the OpenAI Agents SDK's own tool-approval interruption, so the human answers where this SDK's users already answer.

  • Supported kernel range: ctrlrun>=0.5,<0.6
  • Supported framework range: openai-agents>=0.20,<1.0
  • Primitive reused: needs_approval, RunResult.interruptions, RunState.approve / reject. Read 2026-09-05.
  • Framework shape: decided before invocation (SPEC-v0.5 §3.5).
  • Conformance: 4/4 (2 not applicable)binding and denial are N/A, with the reasons below. Never reported as 6/6.

You probably do not need this

@protect already covers anything running in your process — including a plain @function_tool body — with no adapter and no framework support. Most people reading this need @protect and nothing else. This buys one thing: when the policy says a human must approve, the SDK stops the run with a ToolApprovalItem instead of ApprovalRequired being raised past the runner.

ctrlrun gateway is the third way in, and it is not an adapter: it puts the same guarantees in front of an MCP tool server, in any language, with no agent change.

Use

from ctrlrun import Control, InterruptApprovalProvider, protect
import ctrlrun_openai_agents as gate
from ctrlrun_openai_agents import AgentsInterrupt, protected_tool

control = Control(
    policy, store,
    approvals=InterruptApprovalProvider(store, AgentsInterrupt()),
    identity=..., authority=...,
)

@protect("stripe.refund", effect="refund:{payment_id}", wait=True, control=control)
def issue_refund(payment_id: str, amount: int) -> str:
    return stripe.Refund.create(payment_intent=payment_id, amount=amount)

async def refund_tool(payment_id: str, amount: int) -> str:
    """Issue a refund for a payment. Amounts are in integer minor units."""
    return issue_refund(payment_id=payment_id, amount=amount)

agent = Agent(name="refunds", tools=[protected_tool(control, "stripe.refund", refund_tool)])

result = await gate.run(agent, "refund txn_1")
if result.interruptions:
    state = result.to_state()
    for item in result.interruptions:
        state.approve(item)          # or state.reject(item)
    result = await gate.run(agent, state)

The operator constructs the Control — this adapter never does (SPEC-v0.5 §2.3), so the identity provider, the authority document, the environment and the mode are all chosen on the line above, by the person deploying it.

Two helpers, and why they are not optional

protected_tool(...) builds the function_tool with needs_approval= wired to the policy and failure_error_function=None. This SDK's default is default_tool_error_function, which catches a tool's exception and returns "An error occurred while running the tool. Please try again." to the model. Under that default an ActionDenied, a DuplicateEffect or an AmbiguousEffect reaches your agent as a suggestion to retry — which is the exact failure SPEC-v0.2 §6.10 argues about in the gateway: a refusal by CTRLRun is not an outcome of the tool, it is the statement that the tool did not run, and putting it in a channel whose contents reach the model as text invites the retry the refusal exists to prevent.

gate.run(...) / gate.run_sync(...) are Runner.run with CTRLRun's exceptions arriving as themselves. The SDK wraps whatever a tool raises in agents.exceptions.UserError and chains the original as __cause__, so a plain except DuplicateEffect at your call site never fires. These walk the chain and give it back; they decide nothing and hold nothing. unwrap(error) is the same thing if you would rather call Runner yourself.

The binding: this adapter's is attribution

carries_approved_arguments is False, and unlike the LangGraph adapter it is not a constructor argument — it is a fact about this SDK rather than a choice a deployment makes.

The arguments a human answered against live on the ToolApprovalItem, which the caller holds in RunResult.interruptions. They are not reachable from a tool body: the run context records that a call was approved, keyed by tool name and call_id, and not what its arguments were. An adapter that handed back the tool's own parameters would be handing back what it was just given, which SPEC-v0.5 §3.4 names as manufacturing the check.

So CTRLRun still binds the approval to the action that executes — that is v0.1 §4.2 A1 and it holds unconditionally — but the binding across the interrupt is the SDK's, not CTRLRun's. In that word: attribution. The conformance kit reports binding: not_applicable with the reason, never a pass.

What closes the gap instead is real, and it is the SDK's. The approval item and the invocation are the same tool call, bound by call_id, and the SDK invokes with exactly that call's arguments — it does not re-ask the model in between. That is a strong property. It is simply not one CTRLRun can verify, which is the whole distinction §3.4 draws.

A rejection leaves no CTRLRun evidence

The one place this adapter's evidence differs from @protect's, and worth knowing before you go looking for an empty log.

The SDK does not invoke a tool whose approval was refused. So no CTRLRun action is proposed: there is no APPROVAL_DENIED, no ACTION_DENIED and no receipt. The refusal is real and it is in the SDK's own run output; CTRLRun was never asked about it. The conformance kit reports denial: not_applicable for the same reason.

If you need refusals in the evidence log, record them where you call state.reject(item).

Where this SDK's behaviour shows through the contract

SPEC-v0.5 §7 item 5.

The predicate and @protect can disagree. approval_gate answers the SDK's pre-invocation question with ctrlrun.adapter.needs_approval, which sees the framework's raw arguments — not the defaults @protect applies, and not a resource= template declared only on the decorator.

A wrong True asks a human about something harmless. A wrong False means the SDK does not pre-ask, Control.execute raises ApprovalRequired, and the interrupt finds the SDK holds no answer for a call it was never asked about — so the action is refused with ApprovalNotAsked, nothing is written, and the approval request is left pending for ctrlrun approve to answer out of band. In neither direction does an action execute that a human did not approve.

That sentence is load-bearing and it was not always true here. AgentsInterrupt.interrupt() originally returned granted=True unconditionally, reasoning that a tool body which runs is the approval. It is — but only for a call the SDK's gate actually asked about, and on the wrong False path it had not. An independent review found it: a $1,000 refund executing with no human and a receipt naming openai-agents:tool-approval as the approver, which is a grant nobody made written into the evidence log. interrupt() now reads the SDK's per-call approval record — True, False, or None for a call nobody was asked about — and only the first two are answers. Not the public is_tool_approved, which falls back to a sticky per-tool decision that always_approve=True sets and would answer True for later calls no human saw.

Two further bindings, because that record answers for a tool call and a tool body may raise ApprovalRequired more than once:

  • The answer is bound to the action protected_tool gated. A refund's yes does not authorize a bank.wire raised beside it in the same body — a different action, a different policy row, a different authority scope, and no approval item a human ever saw.
  • One answer authorizes one request. A second approval request under the same tool call is a decision nobody made.

Both are refused with ApprovalNotAsked: nothing is written and the request stays pending. always_approve=True is refused the same way — it records a decision about the tool rather than about the call, and this adapter will not read it as an answer for a specific action.

So @protect(wait=True) on this Control must go through protected_tool. A plain function_tool, a background job, or any other protected call on the same Control reaches the interrupt with no SDK tool call in scope, and is refused the same way. The provider hangs off the Control and not off the tool; nothing else links the two.

Pass the same resource= to protected_tool, give the tool no defaulted parameters, and the two agree — and then no call is refused this way at all.

Exceptions are wrapped, and failure_error_function swallows them by default. See above; this is the one thing an adapter for this SDK cannot leave alone.

Retries. The SDK's default handling of a tool that raised surfaces the error to the model, which may act on it. Measured on openai-agents 0.22.0 against a remote that commits and then drops the connection, with no effect-level guard, the model retried until the refund had landed three or four times in a single run, five runs out of five (research/framework-probe/results/2026-09-05.json). That is behaviour, not quality — it is what the documentation says failure_error_function does — and it is the clearest argument for declaring an effect= on anything consequential.

What this adapter does not do

It is not a second approval path: it reuses the SDK's own approval interruption and reimplements nothing — no prompt, no queue, no polling loop, no resume token of its own. It grants nothing: the answer is recorded by InterruptApprovalProvider, in core, through the same two store calls ctrlrun approve makes. It constructs no Control and supplies no principal.

And it is not a compliance claim. "Conformance" names a suite of the CTRLRun repository's own acceptance tests, run against this adapter. It certifies nothing.

Versioning

adapters-openai-agents-MAJOR.MINOR, never a kernel version. This adapter answers to two upstreams and neither is the CTRLRun roadmap. The two ranges at the top are what its CI actually ran against.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ctrlrun_openai_agents-1.0.0.tar.gz (18.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ctrlrun_openai_agents-1.0.0-py3-none-any.whl (15.8 kB view details)

Uploaded Python 3

File details

Details for the file ctrlrun_openai_agents-1.0.0.tar.gz.

File metadata

  • Download URL: ctrlrun_openai_agents-1.0.0.tar.gz
  • Upload date:
  • Size: 18.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for ctrlrun_openai_agents-1.0.0.tar.gz
Algorithm Hash digest
SHA256 7f44d8dd353d2f1fd192ba310683772acf856c2c5bb8ab8346059f9edbda0958
MD5 e8f5090f03d0677e68e9b67c4c023d42
BLAKE2b-256 163c63ed22e0c9fdd6dea11a2e4edd19d2a84066e2e83d1e9e0fd3ac72aa494d

See more details on using hashes here.

Provenance

The following attestation bundles were made for ctrlrun_openai_agents-1.0.0.tar.gz:

Publisher: publish.yml on CTRLRun/ctrlrun

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file ctrlrun_openai_agents-1.0.0-py3-none-any.whl.

File metadata

File hashes

Hashes for ctrlrun_openai_agents-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0deb263b2676f7d869a0ffaf90007f9cb185d17ba4ca1a3863a6cca693f42749
MD5 955395d246837665d0eb4af2e0d66573
BLAKE2b-256 cd34e902c9eb3207a345119c968bdba95ca6479dc1acd6e3d9d2a4324befd052

See more details on using hashes here.

Provenance

The following attestation bundles were made for ctrlrun_openai_agents-1.0.0-py3-none-any.whl:

Publisher: publish.yml on CTRLRun/ctrlrun

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page