OpenAI Agents SDK RunState can persist a run that is waiting for human approval, let the original process exit, and resume the approved or rejected run in another worker.
That solves durability. It does not solve exactly-once execution.
In my reproducible openai-agents==0.18.3 lab:
- side effects before approval: 0
- side effects after cross-process approval and resume: 1
- side effects after rejection: 0
- side effects after replaying the same approved state in two workers: 2
- side effects after adding an idempotency ledger: 1
Reproduction setup
Python 3.10.2
openai-agents 0.18.3
RunState schema 1.12
SQLite
deterministic custom Model
no external model provider API
The deterministic model always emits one deploy_release tool call, then returns a final message after receiving the tool result. This isolates SDK interruption, serialization, and resume behavior from model variance and network behavior.
Pause before the side effect
from agents import function_tool
@function_tool(needs_approval=True)
def deploy_release(release: str, idempotency_key: str) -> str:
return f"deployed:{release}"
result = await Runner.run(
agent,
"Deploy release 2026.07.24",
context=app_context,
)
assert len(result.interruptions) == 1
assert effect_count() == 0
state = result.to_state()
needs_approval=True is enforced by the runner at tool execution time. The model has already proposed a call and produced arguments, but the Python function has not run.
Serialize only durable context
RunContextWrapper.context participates in RunState serialization. A plain mapping containing a token can therefore write that token into the state blob.
Use a strict serializer that keeps identifiers and reconstructs runtime dependencies later:
def context_serializer(context: AppContext):
return {"tenant_id": context.tenant_id}
payload = state.to_json(
context_serializer=context_serializer,
strict_context=True,
)
On restore, obtain credentials from a secret manager instead of the blob.
The application still needs an approval record
RunState is an SDK execution snapshot. It should not be the only record for:
- who requested the action
- who is authorized to approve it
- tenant and resource boundaries
- expiry and revocation
- the immutable tool argument digest
- queue delivery and worker leases
- whether the external side effect already happened
A production design separates:
run_state_blob SDK execution snapshot
approval_request authorization and lifecycle
idempotency_ledger external-effect ownership and result reuse
The approval UI should display an immutable review snapshot. Canonicalize the tool arguments and store a SHA-256 digest. Before execution, the worker extracts the arguments from RunState and recomputes the digest. A mismatch invalidates the approval.
Resume in another process
state = await RunState.from_json(
initial_agent,
stored_payload,
context_deserializer=context_deserializer,
strict_context=True,
)
interruption = state.get_interruptions()[0]
state.approve(interruption)
await persist(state)
The worker then rebuilds a compatible agent and resumes:
agent = AGENT_FACTORIES[record.app_state_version](runtime_dependencies)
state = await load_state(agent)
result = await Runner.run(agent, state)
from_json() still requires an initial Agent because JSON cannot recreate Python functions, tool implementations, clients, database connections, or a complete executable graph.
The duplicate-delivery failure
Worker A consumed the approved state and executed the tool. I then replayed the original approved state to Worker B instead of using Worker A's new result.
{
"worker_a": "deployed:2026.07.24",
"worker_b": "deployed:2026.07.24",
"effect_count": 2
}
The SDK cannot know whether this is a queue redelivery, a lost acknowledgement, an operator retry, or a second legitimate resume. The snapshot says the call is approved and unfinished, so executing it again is consistent.
Claim execution with an idempotency ledger
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT result FROM idempotency_ledger WHERE idempotency_key = ?",
(idempotency_key,),
).fetchone()
if existing:
return f"reused:{existing[0]}"
result = perform_external_effect()
connection.execute(
"INSERT INTO idempotency_ledger(idempotency_key, result) VALUES (?, ?)",
(idempotency_key, result),
)
return result
The operation key should be generated by the application, for example:
tenant_id + approval_id + logical_operation + target_resource
If the downstream API supports an idempotency key, forward the same value. A local ledger cannot close the crash window where the remote operation succeeds but the local result is not recorded. In that case, query the downstream system or mark the operation as uncertain and require reconciliation.
Production checklist
- verify zero side effects before approval
- keep approval authorization separate from RunState
- persist a tool argument digest
- encrypt state blobs and apply tenant ACLs
- route old states through versioned Agent factories
- design queues for at-least-once delivery
- make side-effecting tools idempotent
- test rejection, expiry, revocation, redelivery, lost ACKs, and corrupted blobs
The complete article includes the experiment files, state shapes, SQL schema, failure logs, and architecture diagrams.
Top comments (0)