Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

OpenAI Agents SDK

The OpenAI Agents SDK needs no adapter. Give it Pyrula’s journaled AsyncOpenAI as its client and run the agent inside a Pyrula @workflow: every model call the SDK makes, handoff and guardrail calls included, is recorded in the run’s journal. After a crash the run resumes from the top and each call that had finished is answered from the journal, with no second request.

The SDK’s function tools run in your process and are not journaled. A side effect is durable when the tool calls a Pyrula @step (see Tools).

Terminal window
pip install "pyrula[agents]" openai-agents

openai-agents 0.21 and later needs openai 3, so it can’t share an environment with pyrula[litellm] until litellm lifts its openai<3 cap. Install the two in separate environments. See Versions.

Set the client once when the worker starts, before the first run. The agent keeps its ordinary model="gpt-6-luna" string.

import asyncio
import json
import httpx2
from agents import (
Agent,
RunConfig,
Runner,
function_tool,
set_default_openai_client,
set_tracing_disabled,
)
from agents.run_config import ToolExecutionConfig
from pyrula.contracts.events import EventKind
from pyrula.workflows import step, workflow
from pyrula.workflows.testing import WorkflowTestEnv
from pyrula.agents.compat.openai import AsyncOpenAI
requests: list[dict] = []
def fake_openai(request: httpx2.Request) -> httpx2.Response:
"""Stands in for api.openai.com so this page runs offline. Leave http_client out for the real API."""
body = json.loads(request.content)
requests.append(body)
answered = any(item.get("type") == "function_call_output" for item in body["input"])
output = (
[{"id": "msg_1", "type": "message", "role": "assistant", "status": "completed",
"content": [{"type": "output_text", "text": "Charged order A-1.", "annotations": []}]}]
if answered
else [{"id": "fc_1", "type": "function_call", "call_id": "call_1", "name": "charge_order",
"arguments": json.dumps({"order": "A-1"}), "status": "completed"}]
)
return httpx2.Response(200, json={
"id": f"resp_{len(requests)}", "object": "response", "created_at": 0, "model": "gpt-6-luna",
"status": "completed", "output": output, "parallel_tool_calls": True,
"tool_choice": "auto", "tools": [],
})
set_default_openai_client(
AsyncOpenAI(
api_key="test",
base_url="http://openai.test/v1",
http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(fake_openai)),
),
use_for_tracing=False,
)
set_tracing_disabled(True)
charged: list[str] = []
receipts: list[str] = []
@step
async def charge(order: str) -> str:
charged.append(order)
return f"charged {order}"
@step
async def send_receipt(text: str) -> str:
receipts.append(text)
if len(receipts) == 1:
raise RuntimeError("the mail server is down")
return "sent"
@function_tool(failure_error_function=None)
async def charge_order(order: str) -> str:
"""Charge an order."""
return await charge(order)
support = Agent(name="support", instructions="Charge the order.", tools=[charge_order], model="gpt-6-luna")
@workflow(name="docs_oas_support")
async def answer(ctx, question: str) -> str:
result = await Runner.run(
support,
question,
run_config=RunConfig(tool_execution=ToolExecutionConfig(max_function_tool_concurrency=1)),
)
await send_receipt(result.final_output)
return result.final_output
async def main() -> None:
env = WorkflowTestEnv()
run = await env.client.submit("docs_oas_support", {"question": "charge order A-1"})
try:
await run.result()
except Exception:
pass # the receipt step failed after the agent finished
assert len(requests) == 2 and charged == ["A-1"]
retried = await run.retry()
assert await retried.result() == "Charged order A-1."
assert len(requests) == 2 # both model calls were served from the journal
assert charged == ["A-1"] # and the charge was not made again
assert len(receipts) == 2 # the step that had failed ran again
assert [
e.payload["step_id"]
for e in env.store.load_run("docs_oas_support", retried.run_id)
if e.kind == EventKind.STEP_DONE
] == [
"call:openai.responses.create:0",
"charge",
"call:openai.responses.create:1",
"send_receipt",
]
asyncio.run(main())

Against the real API, build the client from your key and leave out http_client:

from agents import set_default_openai_client
from pyrula.agents.compat.openai import AsyncOpenAI
set_default_openai_client(AsyncOpenAI(), use_for_tracing=False) # reads OPENAI_API_KEY

Outside a Pyrula run the client passes calls through to OpenAI with a one-time warning, so the same code runs in a script. Call await Runner.run(...) or Runner.run_streamed(...) in the workflow body, never Runner.run_sync. Inside an @agent it works too; as with the other drop-in clients, the model calls are then also metered and held to AgentLimits, while in a plain @workflow they write llm:done but nothing holds a cap, see the drop-in clients.

use_for_tracing=False keeps the SDK from using this client’s key to export traces. Trace export is a separate path and is sent again on a replay; set_tracing_disabled(True) turns it off.

The SDK runs a function tool in your process, outside the journal. On a resume the model’s request for the tool replays, and the tool body runs again, possibly more than once (a retry replays the body against the journal before its live attempt). Make an effect run once by putting it in a @step the tool calls, and set the two options the example sets:

  • @function_tool(failure_error_function=None). The SDK otherwise turns an exception from the tool into text for the model, and that includes the error of a failed step. The model answers the text and the run goes on as if the tool had only failed. A ReplayInputChanged from the step is hidden the same way and surfaces one model call later. With None the error ends the run with its own type, and a retry resumes it.
  • max_function_tool_concurrency=1 in RunConfig(tool_execution=...). The SDK runs the tool calls of one model reply in parallel, and two calls into the same @step overlap, which fails the run with ConcurrentStepIdError. At 1 each call runs and replays in turn.

A step that is cut off after its effect and before its record runs again on resume, so an effect that can’t repeat needs the step’s idempotency_key (the crash window). A tool without a step is best effort, as for the other drop-in clients: compat.openai’s tools cell. Computer and local-shell tools run in process and are not journaled either.

What the SDK doesOn a resume
A model call, streamed or not, on the default Responses API or on chat_completionsServed from the journal. No request is sent.
A handoffNothing extra: a handoff is a model call and SDK-local bookkeeping.
An input guardrail that runs an agentIts model calls are journaled.
A WebSearchTool or FileSearchTool callJournaled as part of the model call that made it.
A function tool in a @stepThe step’s result is served.
A function tool without a stepRuns again, possibly more than once.
A call made through the SDK’s own retry pathJournaled; the SDK asks the client for with_options(max_retries=0) and gets the journaled one.

A resume replays a call only if what it asked is the same. A changed prompt, history, instructions or tool list raises ReplayInputChanged out of Runner.run, and so does a changed argument to a step inside a tool.

Each call is a step:pending and step:done pair with the step id call:openai.responses.create:<index> (call:openai.chat.completions.create:<index> on chat_completions, and a .stream suffix when streamed), and a tool’s step keeps its own id. Cloud shows them like any other drop-in call.

To use Chat Completions instead of the default Responses API, add one line:

from agents import set_default_openai_api
set_default_openai_api("chat_completions")

Code that must not set process globals passes the client to one run:

from agents import RunConfig
from agents.models.openai_provider import OpenAIProvider
config = RunConfig(model_provider=OpenAIProvider(openai_client=client))
result = await Runner.run(support, question, run_config=config)

A RunConfig keeps the client its provider first resolved. Build a fresh one per run if the default client can change.

A guardrail function is plain code. One that runs an agent has its model calls journaled. Give a guardrail that calls a model run_in_parallel=False. In parallel mode a tripwire cancels the main model call while it is in flight, and a cancelled call is not journaled, so it can be sent again on a retry. The order of the two calls rests on which task starts first, and a change to it raises ReplayInputChanged instead of serving the wrong reply.

Not journaled, and nothing says so when the run starts:

  • The Responses WebSocket transport (set_default_openai_responses_transport("websocket"), use_responses_websocket=True) connects without going through the client. Leave it at the default HTTP transport.
  • LitellmModel, any-llm models, Realtime and voice agents call their provider directly.
  • Function, computer and local-shell tool bodies outside a @step.

Not durable, and documented or loud:

  • SDK Session memory. Runner.run(session=...) adds the first attempt’s items to the session, so on a resume the first call’s input has changed and the run raises ReplayInputChanged. Keep the history in the workflow and pass it as the input.
  • Tool approval interruptions (needs_approval). They end the SDK run and are not a durable pause. Use a Pyrula @tool with requires_approval for that.
  • Trace export, which is sent again on a replay.

The journaled client refuses what a replay could not repeat: Runner.run(conversation_id=...), background responses, and the hosted tools that act (code interpreter, hosted MCP servers, image generation) raise UnjournaledSurface before any request. See the OpenAI drop-in.

Tested with openai-agents 0.24 on openai 3.41, in a CI step of its own. openai-agents 0.21 and later needs openai 3, and pyrula[litellm] caps openai below 3, so the two can’t share an environment until litellm lifts its cap. Releases 0.17 to 0.20 use openai 2.x and call the same two client methods; they are not tested.

The guarantee matrix row is openai-agents (recipe).

View this page as Markdown