ctrlrun-openai-agents makes an approve decision stop the run with the SDK’s own
ToolApprovalItem instead of ApprovalRequired being raised past the runner. The human
answers with state.approve(item), where this SDK’s users already answer, and one core provider
writes the grant. The binding across the interrupt is the SDK’s, keyed by call_id, so CTRLRun
records who answered and cannot re-check what they answered about: that is attribution, in that
word.
You probably do not need this. A plain @function_tool body under @protect is already
covered. Use it when the SDK’s approval interruption is where your humans answer.
Prerequisites: pip install ctrlrun-openai-agents, openai-agents>=0.20,<1.0. The
blocks are the adapter’s own example, run against a real SDK install in this repository’s CI.
1
Build the Control, and wrap the tool
protected_tool builds the function_tool with needs_approval= answered by the policy
through ctrlrun.adapter.needs_approval, and with failure_error_function=None. That
second part is not optional: the SDK’s default turns a tool’s exception into “An error
occurred while running the tool. Please try again.” for the model, which is exactly the
retry a refusal exists to prevent.2
Run, answer, resume
gate.run and gate.run_sync are Runner.run with CTRLRun’s exceptions arriving as
themselves: the SDK wraps a tool’s exception in UserError, and these walk the chain back.
unwrap(error) does the same if you call Runner yourself.3
Know what a rejection leaves behind
The SDK does not invoke a tool whose approval was refused, so no CTRLRun action is
proposed: no
APPROVAL_DENIED, no ACTION_DENIED, no receipt. The refusal is real and in
the SDK’s run output; CTRLRun was never asked. Record it where you call state.reject(item)
if you need it in the evidence log. The conformance kit reports denial as not applicable
for the same reason.Where the SDK shows through
- The predicate and
@protectcan disagree.needs_approvalsees the raw arguments, not the decorator’s defaults or aresource=declared only there. A wrong yes asks a human about something harmless; a wrong no means the interrupt finds no answer for a call nobody was asked about and refuses it withApprovalNotAsked, nothing written, the request leftpendingforctrlrun approve. In neither direction does an unapproved action execute. - One answer authorizes one request, for the action the tool gated. A refund’s yes does
not authorize a
bank.wireraised beside it, andalways_approve=Trueis refused as an answer because it records a decision about the tool, not the call. - Observe mode: the predicate answers “no approval needed”, because the SDK would not invoke a declined tool and a human’s no would stop what observe mode promises to run.
- Retries: measured on
openai-agents0.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 one run, five runs out of five. Declare aneffect=.
If it didn’t work
ApprovalNotAsked: the tool was invoked without the SDK having asked; pass the sameresource=toprotected_tool, give the tool no defaulted parameters, and route every@protect(wait=True)on thisControlthroughprotected_tool.- The model retries after a refusal:
failure_error_functionwas left at its default; useprotected_tool. except DuplicateEffectnever fires: you calledRunner.run; usegate.runorunwrap.
Next
- Use the LangGraph adapter: the prevention shape.
- Three ways in.
- The adapter’s README · Get started · Why.