dex-agents-openai-agents 0.1.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.
@@ -0,0 +1,105 @@
1
+ # License
2
+
3
+ Copyright (c) 2026 Super Durable, Inc.
4
+
5
+ Portions of this software are licensed as follows:
6
+
7
+ - Legacy Materials remain under their original licenses as described in
8
+ `LEGACY_NOTICES.md`.
9
+ - Third-Party Materials remain under the licenses provided by their owners.
10
+ - The `docs/` and `examples/` directories are not licensed under the
11
+ Sustainable Use License and retain their existing licensing status.
12
+ - All other material owned or licensable by Super Durable, Inc. is available
13
+ under the Sustainable Use License below.
14
+
15
+ See `LEGACY_NOTICES.md` for the applicable legacy and third-party notices.
16
+
17
+ ## Sustainable Use License
18
+
19
+ Version 1.0
20
+
21
+ ### Acceptance
22
+
23
+ By using the software, you agree to all of the terms and conditions below.
24
+
25
+ ### Copyright License
26
+
27
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
28
+ non-sublicensable, non-transferable license to use, copy, distribute, make
29
+ available, and prepare derivative works of the software, in each case subject
30
+ to the limitations below.
31
+
32
+ ### Limitations
33
+
34
+ You may use or modify the software only for your own internal business purposes
35
+ or for non-commercial or personal use. You may distribute the software or
36
+ provide it to others only if you do so free of charge for non-commercial
37
+ purposes. You may not alter, remove, or obscure any licensing, copyright, or
38
+ other notices of the licensor in the software. Any use of the licensor's
39
+ trademarks is subject to applicable law.
40
+
41
+ ### Patents
42
+
43
+ The licensor grants you a license, under any patent claims the licensor can
44
+ license, or becomes able to license, to make, have made, use, sell, offer for
45
+ sale, import and have imported the software, in each case subject to the
46
+ limitations and conditions in this license. This license does not cover any
47
+ patent claims that you cause to be infringed by modifications or additions to
48
+ the software.
49
+
50
+ If you or your company make any written claim that the software infringes or
51
+ contributes to infringement of any patent, your patent license for the software
52
+ granted under these terms ends immediately. If your company makes such a claim,
53
+ your patent license ends immediately for work on behalf of your company.
54
+
55
+ ### Notices
56
+
57
+ You must ensure that anyone who gets a copy of any part of the software from
58
+ you also gets a copy of these terms. If you modify the software, you must
59
+ include in any modified copies of the software a prominent notice stating that
60
+ you have modified the software.
61
+
62
+ ### No Other Rights
63
+
64
+ These terms do not imply any licenses other than those expressly granted in
65
+ these terms.
66
+
67
+ ### Termination
68
+
69
+ If you use the software in violation of these terms, such use is not licensed,
70
+ and your license will automatically terminate. If the licensor provides you
71
+ with a notice of your violation, and you cease all violation of this license no
72
+ later than 30 days after you receive that notice, your license will be
73
+ reinstated retroactively. However, if you violate these terms after such
74
+ reinstatement, any additional violation of these terms will cause your license
75
+ to terminate automatically and permanently.
76
+
77
+ ### No Liability
78
+
79
+ As far as the law allows, the software comes as is, without any warranty or
80
+ condition, and the licensor will not be liable to you for any damages arising
81
+ out of these terms or the use or nature of the software, under any kind of
82
+ legal claim.
83
+
84
+ ### Definitions
85
+
86
+ The "licensor" is the entity offering these terms.
87
+
88
+ The "software" is the software the licensor makes available under these terms,
89
+ including any portion of it.
90
+
91
+ "You" refers to the individual or entity agreeing to these terms.
92
+
93
+ "Your company" is any legal entity, sole proprietorship, or other kind of
94
+ organization that you work for, plus all organizations that have control over,
95
+ are under the control of, or are under common control with that organization.
96
+ Control means ownership of substantially all the assets of an entity, or the
97
+ power to direct its management and policies by vote, contract, or otherwise.
98
+ Control can be direct or indirect.
99
+
100
+ "Your license" is the license granted to you for the software under these
101
+ terms.
102
+
103
+ "Use" means anything you do with the software requiring your license.
104
+
105
+ "Trademark" means trademarks, service marks, and similar rights.
@@ -0,0 +1,279 @@
1
+ Metadata-Version: 2.4
2
+ Name: dex-agents-openai-agents
3
+ Version: 0.1.0
4
+ Summary: Run OpenAI Agents SDK agents as durable Dex agent sessions
5
+ Author: Super Durable
6
+ License-Expression: LicenseRef-Sustainable-Use-1.0
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Typing :: Typed
12
+ Requires-Dist: dex-agents==0.1.0
13
+ Requires-Dist: dex-python-sdk==1.30.0
14
+ Requires-Dist: openai-agents>=0.24,<0.25
15
+ Requires-Python: >=3.11
16
+ Project-URL: Homepage, https://github.com/superdurable/dex/tree/main/agents/python/openai-agents
17
+ Project-URL: Repository, https://github.com/superdurable/dex
18
+ Project-URL: Changelog, https://github.com/superdurable/dex/blob/main/agents/CHANGELOG.md
19
+ Description-Content-Type: text/markdown
20
+
21
+ # dex-agents-openai-agents
22
+
23
+ `dex-agents-openai-agents` (import `dex_agents_openai_agents`) runs
24
+ [OpenAI Agents SDK](https://github.com/openai/openai-agents-python) agents as
25
+ durable Dex agent sessions. It plugs the SDK into the shared session Flow of
26
+ [`dex-agents`](https://github.com/superdurable/dex/blob/main/agents/python/core/README.md). The plan is
27
+ [agent-framework-adapters.md](https://github.com/superdurable/dex/blob/main/docs/design/plan/agent-framework-adapters.md).
28
+
29
+ One Flow execution is one agent session; the Flow ID is the session ID. The
30
+ session runs in **Step mode**. Every model call is one `RunModel` Step. Every
31
+ function tool call, READ included, is its own Step, and the calls of one model
32
+ response run in parallel. A crash repeats at most the one model call or tool
33
+ call that was running, and a single-attempt write is never repeated silently.
34
+
35
+ ## Installation
36
+
37
+ The package needs Python 3.11 or later. It requires `dex-agents` at the same
38
+ version, the released `dex-python-sdk==1.30.0`, and `openai-agents>=0.24,<0.25`
39
+ (0.24.0 is locked and tested):
40
+
41
+ ```bash
42
+ pip install dex-agents-openai-agents
43
+ ```
44
+
45
+ Develop the package inside this repository with uv 0.12.2. There it resolves
46
+ `dex-agents` from `../core` by path through `[tool.uv.sources]`; the published
47
+ wheel requires `dex-agents==<same version>` from PyPI instead:
48
+
49
+ ```bash
50
+ cd agents/python/openai-agents
51
+ uv sync --locked
52
+ DEXCLI=/path/to/dexcli ./ci.sh
53
+ ```
54
+
55
+ ## Example
56
+
57
+ ```python
58
+ import asyncio
59
+ import contextlib
60
+
61
+ from agents import Agent, function_tool
62
+ from dex import AsyncClient, AsyncWorker, BlobCacheConfig, ClientOptions, Registry
63
+ from dex import WorkerOptions, open_blob_cache
64
+ from dex_agents import AgentSessionClient, ToolEffect, ToolPolicy
65
+ from dex_agents_openai_agents import (
66
+ get_current_deferred_tool_execution,
67
+ openai_agents_session_flow,
68
+ )
69
+
70
+
71
+ @function_tool
72
+ def search_orders(customer_id: str) -> str:
73
+ """Find a customer's open orders."""
74
+ return f"customer {customer_id} has order 7"
75
+
76
+
77
+ @function_tool
78
+ async def create_refund(order_id: str) -> str:
79
+ """Refund one order."""
80
+ execution = get_current_deferred_tool_execution()
81
+ key = execution.idempotency_key if execution else order_id
82
+ return f"refunded order {order_id} with idempotency key {key}"
83
+
84
+
85
+ agent = Agent(
86
+ name="Support",
87
+ instructions="Help the customer with their orders.",
88
+ tools=[search_orders, create_refund],
89
+ )
90
+ flow = openai_agents_session_flow(
91
+ agent,
92
+ flow_type="SupportAgent",
93
+ tool_policies={
94
+ "search_orders": ToolPolicy(ToolEffect.READ),
95
+ "create_refund": ToolPolicy(ToolEffect.NON_IDEMPOTENT_WRITE),
96
+ },
97
+ )
98
+
99
+
100
+ async def main() -> None:
101
+ registry = Registry([flow], allow_async_handlers=True)
102
+ cache = open_blob_cache(BlobCacheConfig(".dex-cache", 64 * 1024 * 1024))
103
+ worker = AsyncWorker(registry, cache, WorkerOptions(server_address="localhost:8801"))
104
+ client = AsyncClient(registry, cache, ClientOptions(server_address="localhost:8801"))
105
+ serving = asyncio.create_task(worker.start())
106
+ try:
107
+ await worker.wait_pull_registered(timeout=30)
108
+ sessions = AgentSessionClient(client, flow)
109
+ await sessions.start_session("session-42", "Refund my open order. I am customer 3.")
110
+ while True:
111
+ snapshot = await sessions.get_agent_snapshot("session-42")
112
+ if snapshot.pending_interrupts:
113
+ interrupt = snapshot.pending_interrupts[0]
114
+ await sessions.resolve_interrupt(
115
+ "session-42", interrupt.interrupt_id, approved=True
116
+ )
117
+ elif snapshot.state is not None and snapshot.state.turn_count == 1:
118
+ print(snapshot.messages[-1].text)
119
+ break
120
+ await asyncio.sleep(0.5)
121
+ finally:
122
+ await client.close()
123
+ await worker.stop()
124
+ with contextlib.suppress(asyncio.CancelledError):
125
+ await serving
126
+ cache.close()
127
+
128
+
129
+ asyncio.run(main())
130
+ ```
131
+
132
+ The agent uses the SDK's default OpenAI model, so set `OPENAI_API_KEY`, and run
133
+ `dexcli dev` first. The first `RunModel` stops after the model asks for
134
+ `search_orders`. `CallTool` runs the search, `ResumeTurn` collects the result,
135
+ and the next `RunModel` gives it to the model, which asks for `create_refund`.
136
+ That call waits in `AwaitToolApproval`. After approval it runs once in
137
+ `CallSingleAttemptTool`, and the last `RunModel` answers. A UI would poll the
138
+ snapshot or read `AgentSessionClient.read_agent_event` instead of the loop
139
+ above.
140
+
141
+ `OpenAIAgentsRunner` is the runner itself. Use it with `AgentSessionFlow`
142
+ directly when you build `AgentSessionOptions` yourself, and call
143
+ `runner.validate_tool_policies(...)` with the same policies.
144
+
145
+ ## Effects and policies
146
+
147
+ Policies are keyed by the tool's qualified name (`FunctionTool.qualified_name`):
148
+ the tool name, or `<namespace>.<name>` for a namespaced tool. Every policy must
149
+ name a function tool of the agent graph. A tool without a policy gets
150
+ `default_tool_effect`, which defaults to `unclassified`. A function tool
151
+ converted from MCP with `readOnlyHint` defaults to `read`, and one with
152
+ `idempotentHint` to `idempotent_write`.
153
+
154
+ | Effect | Step | Retry | Crash mid-call | Default approval |
155
+ | --- | --- | --- | --- | --- |
156
+ | `read` | `CallTool` | Up to 3 attempts; then `failed` | Runs again | No |
157
+ | `idempotent_write` | `CallTool` | Up to 3 attempts, key `<flowId>/<turnId>/<toolCallId>`; then `outcome_unknown` | Runs again with the same key | No |
158
+ | `non_idempotent_write` | `CallSingleAttemptTool` | One attempt; then `outcome_unknown` | Outcome unknown | Yes |
159
+ | `unclassified` | `CallSingleAttemptTool` | One attempt; then `outcome_unknown` | Outcome unknown | Yes |
160
+
161
+ A tool the application marked with `needs_approval` waits in
162
+ `AwaitToolApproval` whatever its policy. A `needs_approval` callable decides
163
+ per call, through the SDK's own evaluation: when argument validation changes
164
+ the values, such as by filling a default, the call needs approval without
165
+ calling the callable, exactly as in a plain SDK run.
166
+
167
+ A tool call Step first parses the arguments and validates them against a
168
+ decorated tool's parameters. Invalid arguments record `failed` with the SDK's
169
+ `Invalid JSON input for tool <name>` message, and the tool does not run. The
170
+ Step then runs the tool's own `on_invoke_tool` with a `ToolContext`. The SDK's
171
+ tool failure formatter is off there, so an exception reaches Dex. Raise
172
+ `dex_agents.ToolExecutionError` for a failure the model should see; the
173
+ session records `failed` and does not retry. Any other exception fails the
174
+ Step and follows the table above. Inside the tool,
175
+ `get_current_deferred_tool_execution()` returns the call's idempotency key and
176
+ attempt. Tools that run inside an agent tool's nested run get `None`, so they
177
+ never reuse the outer call's key.
178
+
179
+ The Step runs the tool's input guardrails after argument validation and
180
+ before the body. A `reject_content` result records `failed` with the
181
+ guardrail's message, and a tripwire records `failed` with the guardrail's
182
+ name; in both cases the body does not run. The resumed `RunModel` does not run
183
+ input guardrails again. Output guardrails run when the resumed `RunModel`
184
+ receives the recorded result.
185
+
186
+ A tool with an output type (`function_tool(output_type=...)`) records the JSON
187
+ its type adapter dumps, and the resumed run validates it again. A failed,
188
+ rejected, or unknown result reaches the model as the SDK's tool error output,
189
+ which skips the output schema.
190
+
191
+ When a turn fails after tool calls finished, the session stores a turn-failure
192
+ note through `build_user_message`. The runner stores it as a user input item,
193
+ so the next turn's model sees which calls already ran.
194
+
195
+ ## How it works
196
+
197
+ - History: each stored `AgentMessage` holds one item of
198
+ `RunResult.to_input_list()` in `framework_item`, plus a display projection.
199
+ A new turn replays every stored item as client-managed input.
200
+ - Gate: the runner copies the agent and every handoff target once, at
201
+ construction. Later changes to those agents do not reach the runner. Agents
202
+ used as tools are not copied, so changes to them and their tools do. Each copied `FunctionTool` gets a `needs_approval` callable
203
+ that always returns `True`, so the SDK stops with `result.interruptions`
204
+ right after the model asks for tools. They become `ToolCallsRequested`, in
205
+ model order, with `RunState.to_json()` as the framework run state.
206
+ - Resume: `RunState.from_json` restores the run. The runner approves each
207
+ interruption whose record is final, so the copied tool returns the recorded
208
+ result, or the `describe_tool_call_outcome` text for failed and unknown
209
+ calls, instead of running. It rejects rejected calls with their rejection
210
+ message. The copied tool never runs the real tool.
211
+ - One model call: a `RunModel` makes one model call. A handoff is not a
212
+ function tool, so the SDK runs it and makes the next agent's model call in
213
+ the same `RunModel`; that Step reports both calls.
214
+ - Streaming: output text and reasoning-summary deltas go to `AgentEvents`,
215
+ coalesced to about 64 characters per Stream message, or flushed after
216
+ 100 ms without a new event.
217
+ - Usage: each `RunModel` reports its own model calls and tokens from the run's
218
+ `Usage`.
219
+
220
+ ## Limits
221
+
222
+ - Tools the SDK runs itself cannot be gated, so the runner refuses them by
223
+ default: tools of `Agent.mcp_servers`, sandbox capabilities of a
224
+ `SandboxAgent`, agents that override `get_all_tools`, and `ComputerTool`,
225
+ `CustomTool`, `ApplyPatchTool`, `LocalShellTool`, or a local `ShellTool`.
226
+ With `allow_at_least_once_tools=True` they run inside `RunModel`, repeat when
227
+ it retries, and let one `RunModel` make several model calls. MCP server tools
228
+ take no policy, and a local MCP server that requires approval fails the
229
+ turn. Wrap a write in a `function_tool` to give it its own Step.
230
+ - Provider-hosted tools (`WebSearchTool`, `FileSearchTool`,
231
+ `CodeInterpreterTool`, `ImageGenerationTool`, `HostedMCPTool`,
232
+ `ToolSearchTool`, `ProgrammaticToolCallingTool`, and a hosted `ShellTool`)
233
+ run inside the model call and rerun with it. A hosted MCP approval request
234
+ fails the turn; set `require_approval` to `"never"` or use
235
+ `on_approval_request`. Function tools that generated code calls through
236
+ `ProgrammaticToolCallingTool` use the same gated copies, but that path has no
237
+ test yet.
238
+ - Handoffs must be `Agent` objects or `Handoff` objects made with
239
+ `handoff(agent)`. Handoff callbacks and input filters run inside `RunModel`.
240
+ Function tool names must be unique across the agent graph, because a tool
241
+ call Step finds its tool by name.
242
+ - An agent used as a tool (`Agent.as_tool()`) is one tool call: its nested run,
243
+ with its model calls and tools, runs inside that call's Step. The runner
244
+ refuses a nested agent whose tool needs approval, and records the call
245
+ `failed` if a nested run still stops for approval, such as after a later
246
+ change to the nested agent. The nested run's usage is not added to the
247
+ session usage, because a tool call Step reports no usage.
248
+ - A tool's output is recorded as text. Structured outputs without an output
249
+ type, such as images, reach the model as their JSON text.
250
+ - A tool input guardrail that trips records the call `failed` instead of
251
+ failing the run, as the SDK's `ToolInputGuardrailTripwireTriggered` would.
252
+ `pre_approval_tool_input_guardrails` has no effect, because input guardrails
253
+ run in the tool call Step, after approval.
254
+ - Run hooks, agent hooks, input and output guardrails run inside `RunModel` and
255
+ run again when it retries. A guardrail tripwire fails the attempt.
256
+ - `max_turns` counts model calls across all `RunModel` Steps of one session
257
+ turn.
258
+ - The serialized `RunState` holds the turn's whole input, including history,
259
+ while tool calls run. A mapping run context is written into it too.
260
+ - Server-managed conversation state (`previous_response_id`,
261
+ `conversation_id`) and SDK `Session` memory are not used; the session is the
262
+ history.
263
+ - Realtime and voice agents are not supported.
264
+ - The gate and the error-output path use SDK internals pinned to
265
+ `openai-agents` 0.24: `evaluate_function_tool_approval`, `get_public_agent`,
266
+ the handoff target reference, the handled-failure flag on `ToolContext`, and
267
+ the nested agent tool result store.
268
+ - The core's limits apply: history is never compacted, every call adds a Step
269
+ round trip, and a running turn cannot be cancelled.
270
+
271
+ ## Testing
272
+
273
+ `tests/` runs a real `Agent` graph against a private `dexcli dev --engine zd`
274
+ engine and asserts each turn's Step shape from the Flow history. The scripted
275
+ model in `tests/scripted_model.py` implements the SDK `Model` interface and
276
+ derives every response from its input, so no model key is needed and a
277
+ retried `RunModel` gets the same response. It streams through
278
+ `agents.testing`'s `ScriptedModel` and records every model call, so tests
279
+ count them. The Worker runs as a child process that the tests SIGKILL.
@@ -0,0 +1,259 @@
1
+ # dex-agents-openai-agents
2
+
3
+ `dex-agents-openai-agents` (import `dex_agents_openai_agents`) runs
4
+ [OpenAI Agents SDK](https://github.com/openai/openai-agents-python) agents as
5
+ durable Dex agent sessions. It plugs the SDK into the shared session Flow of
6
+ [`dex-agents`](https://github.com/superdurable/dex/blob/main/agents/python/core/README.md). The plan is
7
+ [agent-framework-adapters.md](https://github.com/superdurable/dex/blob/main/docs/design/plan/agent-framework-adapters.md).
8
+
9
+ One Flow execution is one agent session; the Flow ID is the session ID. The
10
+ session runs in **Step mode**. Every model call is one `RunModel` Step. Every
11
+ function tool call, READ included, is its own Step, and the calls of one model
12
+ response run in parallel. A crash repeats at most the one model call or tool
13
+ call that was running, and a single-attempt write is never repeated silently.
14
+
15
+ ## Installation
16
+
17
+ The package needs Python 3.11 or later. It requires `dex-agents` at the same
18
+ version, the released `dex-python-sdk==1.30.0`, and `openai-agents>=0.24,<0.25`
19
+ (0.24.0 is locked and tested):
20
+
21
+ ```bash
22
+ pip install dex-agents-openai-agents
23
+ ```
24
+
25
+ Develop the package inside this repository with uv 0.12.2. There it resolves
26
+ `dex-agents` from `../core` by path through `[tool.uv.sources]`; the published
27
+ wheel requires `dex-agents==<same version>` from PyPI instead:
28
+
29
+ ```bash
30
+ cd agents/python/openai-agents
31
+ uv sync --locked
32
+ DEXCLI=/path/to/dexcli ./ci.sh
33
+ ```
34
+
35
+ ## Example
36
+
37
+ ```python
38
+ import asyncio
39
+ import contextlib
40
+
41
+ from agents import Agent, function_tool
42
+ from dex import AsyncClient, AsyncWorker, BlobCacheConfig, ClientOptions, Registry
43
+ from dex import WorkerOptions, open_blob_cache
44
+ from dex_agents import AgentSessionClient, ToolEffect, ToolPolicy
45
+ from dex_agents_openai_agents import (
46
+ get_current_deferred_tool_execution,
47
+ openai_agents_session_flow,
48
+ )
49
+
50
+
51
+ @function_tool
52
+ def search_orders(customer_id: str) -> str:
53
+ """Find a customer's open orders."""
54
+ return f"customer {customer_id} has order 7"
55
+
56
+
57
+ @function_tool
58
+ async def create_refund(order_id: str) -> str:
59
+ """Refund one order."""
60
+ execution = get_current_deferred_tool_execution()
61
+ key = execution.idempotency_key if execution else order_id
62
+ return f"refunded order {order_id} with idempotency key {key}"
63
+
64
+
65
+ agent = Agent(
66
+ name="Support",
67
+ instructions="Help the customer with their orders.",
68
+ tools=[search_orders, create_refund],
69
+ )
70
+ flow = openai_agents_session_flow(
71
+ agent,
72
+ flow_type="SupportAgent",
73
+ tool_policies={
74
+ "search_orders": ToolPolicy(ToolEffect.READ),
75
+ "create_refund": ToolPolicy(ToolEffect.NON_IDEMPOTENT_WRITE),
76
+ },
77
+ )
78
+
79
+
80
+ async def main() -> None:
81
+ registry = Registry([flow], allow_async_handlers=True)
82
+ cache = open_blob_cache(BlobCacheConfig(".dex-cache", 64 * 1024 * 1024))
83
+ worker = AsyncWorker(registry, cache, WorkerOptions(server_address="localhost:8801"))
84
+ client = AsyncClient(registry, cache, ClientOptions(server_address="localhost:8801"))
85
+ serving = asyncio.create_task(worker.start())
86
+ try:
87
+ await worker.wait_pull_registered(timeout=30)
88
+ sessions = AgentSessionClient(client, flow)
89
+ await sessions.start_session("session-42", "Refund my open order. I am customer 3.")
90
+ while True:
91
+ snapshot = await sessions.get_agent_snapshot("session-42")
92
+ if snapshot.pending_interrupts:
93
+ interrupt = snapshot.pending_interrupts[0]
94
+ await sessions.resolve_interrupt(
95
+ "session-42", interrupt.interrupt_id, approved=True
96
+ )
97
+ elif snapshot.state is not None and snapshot.state.turn_count == 1:
98
+ print(snapshot.messages[-1].text)
99
+ break
100
+ await asyncio.sleep(0.5)
101
+ finally:
102
+ await client.close()
103
+ await worker.stop()
104
+ with contextlib.suppress(asyncio.CancelledError):
105
+ await serving
106
+ cache.close()
107
+
108
+
109
+ asyncio.run(main())
110
+ ```
111
+
112
+ The agent uses the SDK's default OpenAI model, so set `OPENAI_API_KEY`, and run
113
+ `dexcli dev` first. The first `RunModel` stops after the model asks for
114
+ `search_orders`. `CallTool` runs the search, `ResumeTurn` collects the result,
115
+ and the next `RunModel` gives it to the model, which asks for `create_refund`.
116
+ That call waits in `AwaitToolApproval`. After approval it runs once in
117
+ `CallSingleAttemptTool`, and the last `RunModel` answers. A UI would poll the
118
+ snapshot or read `AgentSessionClient.read_agent_event` instead of the loop
119
+ above.
120
+
121
+ `OpenAIAgentsRunner` is the runner itself. Use it with `AgentSessionFlow`
122
+ directly when you build `AgentSessionOptions` yourself, and call
123
+ `runner.validate_tool_policies(...)` with the same policies.
124
+
125
+ ## Effects and policies
126
+
127
+ Policies are keyed by the tool's qualified name (`FunctionTool.qualified_name`):
128
+ the tool name, or `<namespace>.<name>` for a namespaced tool. Every policy must
129
+ name a function tool of the agent graph. A tool without a policy gets
130
+ `default_tool_effect`, which defaults to `unclassified`. A function tool
131
+ converted from MCP with `readOnlyHint` defaults to `read`, and one with
132
+ `idempotentHint` to `idempotent_write`.
133
+
134
+ | Effect | Step | Retry | Crash mid-call | Default approval |
135
+ | --- | --- | --- | --- | --- |
136
+ | `read` | `CallTool` | Up to 3 attempts; then `failed` | Runs again | No |
137
+ | `idempotent_write` | `CallTool` | Up to 3 attempts, key `<flowId>/<turnId>/<toolCallId>`; then `outcome_unknown` | Runs again with the same key | No |
138
+ | `non_idempotent_write` | `CallSingleAttemptTool` | One attempt; then `outcome_unknown` | Outcome unknown | Yes |
139
+ | `unclassified` | `CallSingleAttemptTool` | One attempt; then `outcome_unknown` | Outcome unknown | Yes |
140
+
141
+ A tool the application marked with `needs_approval` waits in
142
+ `AwaitToolApproval` whatever its policy. A `needs_approval` callable decides
143
+ per call, through the SDK's own evaluation: when argument validation changes
144
+ the values, such as by filling a default, the call needs approval without
145
+ calling the callable, exactly as in a plain SDK run.
146
+
147
+ A tool call Step first parses the arguments and validates them against a
148
+ decorated tool's parameters. Invalid arguments record `failed` with the SDK's
149
+ `Invalid JSON input for tool <name>` message, and the tool does not run. The
150
+ Step then runs the tool's own `on_invoke_tool` with a `ToolContext`. The SDK's
151
+ tool failure formatter is off there, so an exception reaches Dex. Raise
152
+ `dex_agents.ToolExecutionError` for a failure the model should see; the
153
+ session records `failed` and does not retry. Any other exception fails the
154
+ Step and follows the table above. Inside the tool,
155
+ `get_current_deferred_tool_execution()` returns the call's idempotency key and
156
+ attempt. Tools that run inside an agent tool's nested run get `None`, so they
157
+ never reuse the outer call's key.
158
+
159
+ The Step runs the tool's input guardrails after argument validation and
160
+ before the body. A `reject_content` result records `failed` with the
161
+ guardrail's message, and a tripwire records `failed` with the guardrail's
162
+ name; in both cases the body does not run. The resumed `RunModel` does not run
163
+ input guardrails again. Output guardrails run when the resumed `RunModel`
164
+ receives the recorded result.
165
+
166
+ A tool with an output type (`function_tool(output_type=...)`) records the JSON
167
+ its type adapter dumps, and the resumed run validates it again. A failed,
168
+ rejected, or unknown result reaches the model as the SDK's tool error output,
169
+ which skips the output schema.
170
+
171
+ When a turn fails after tool calls finished, the session stores a turn-failure
172
+ note through `build_user_message`. The runner stores it as a user input item,
173
+ so the next turn's model sees which calls already ran.
174
+
175
+ ## How it works
176
+
177
+ - History: each stored `AgentMessage` holds one item of
178
+ `RunResult.to_input_list()` in `framework_item`, plus a display projection.
179
+ A new turn replays every stored item as client-managed input.
180
+ - Gate: the runner copies the agent and every handoff target once, at
181
+ construction. Later changes to those agents do not reach the runner. Agents
182
+ used as tools are not copied, so changes to them and their tools do. Each copied `FunctionTool` gets a `needs_approval` callable
183
+ that always returns `True`, so the SDK stops with `result.interruptions`
184
+ right after the model asks for tools. They become `ToolCallsRequested`, in
185
+ model order, with `RunState.to_json()` as the framework run state.
186
+ - Resume: `RunState.from_json` restores the run. The runner approves each
187
+ interruption whose record is final, so the copied tool returns the recorded
188
+ result, or the `describe_tool_call_outcome` text for failed and unknown
189
+ calls, instead of running. It rejects rejected calls with their rejection
190
+ message. The copied tool never runs the real tool.
191
+ - One model call: a `RunModel` makes one model call. A handoff is not a
192
+ function tool, so the SDK runs it and makes the next agent's model call in
193
+ the same `RunModel`; that Step reports both calls.
194
+ - Streaming: output text and reasoning-summary deltas go to `AgentEvents`,
195
+ coalesced to about 64 characters per Stream message, or flushed after
196
+ 100 ms without a new event.
197
+ - Usage: each `RunModel` reports its own model calls and tokens from the run's
198
+ `Usage`.
199
+
200
+ ## Limits
201
+
202
+ - Tools the SDK runs itself cannot be gated, so the runner refuses them by
203
+ default: tools of `Agent.mcp_servers`, sandbox capabilities of a
204
+ `SandboxAgent`, agents that override `get_all_tools`, and `ComputerTool`,
205
+ `CustomTool`, `ApplyPatchTool`, `LocalShellTool`, or a local `ShellTool`.
206
+ With `allow_at_least_once_tools=True` they run inside `RunModel`, repeat when
207
+ it retries, and let one `RunModel` make several model calls. MCP server tools
208
+ take no policy, and a local MCP server that requires approval fails the
209
+ turn. Wrap a write in a `function_tool` to give it its own Step.
210
+ - Provider-hosted tools (`WebSearchTool`, `FileSearchTool`,
211
+ `CodeInterpreterTool`, `ImageGenerationTool`, `HostedMCPTool`,
212
+ `ToolSearchTool`, `ProgrammaticToolCallingTool`, and a hosted `ShellTool`)
213
+ run inside the model call and rerun with it. A hosted MCP approval request
214
+ fails the turn; set `require_approval` to `"never"` or use
215
+ `on_approval_request`. Function tools that generated code calls through
216
+ `ProgrammaticToolCallingTool` use the same gated copies, but that path has no
217
+ test yet.
218
+ - Handoffs must be `Agent` objects or `Handoff` objects made with
219
+ `handoff(agent)`. Handoff callbacks and input filters run inside `RunModel`.
220
+ Function tool names must be unique across the agent graph, because a tool
221
+ call Step finds its tool by name.
222
+ - An agent used as a tool (`Agent.as_tool()`) is one tool call: its nested run,
223
+ with its model calls and tools, runs inside that call's Step. The runner
224
+ refuses a nested agent whose tool needs approval, and records the call
225
+ `failed` if a nested run still stops for approval, such as after a later
226
+ change to the nested agent. The nested run's usage is not added to the
227
+ session usage, because a tool call Step reports no usage.
228
+ - A tool's output is recorded as text. Structured outputs without an output
229
+ type, such as images, reach the model as their JSON text.
230
+ - A tool input guardrail that trips records the call `failed` instead of
231
+ failing the run, as the SDK's `ToolInputGuardrailTripwireTriggered` would.
232
+ `pre_approval_tool_input_guardrails` has no effect, because input guardrails
233
+ run in the tool call Step, after approval.
234
+ - Run hooks, agent hooks, input and output guardrails run inside `RunModel` and
235
+ run again when it retries. A guardrail tripwire fails the attempt.
236
+ - `max_turns` counts model calls across all `RunModel` Steps of one session
237
+ turn.
238
+ - The serialized `RunState` holds the turn's whole input, including history,
239
+ while tool calls run. A mapping run context is written into it too.
240
+ - Server-managed conversation state (`previous_response_id`,
241
+ `conversation_id`) and SDK `Session` memory are not used; the session is the
242
+ history.
243
+ - Realtime and voice agents are not supported.
244
+ - The gate and the error-output path use SDK internals pinned to
245
+ `openai-agents` 0.24: `evaluate_function_tool_approval`, `get_public_agent`,
246
+ the handoff target reference, the handled-failure flag on `ToolContext`, and
247
+ the nested agent tool result store.
248
+ - The core's limits apply: history is never compacted, every call adds a Step
249
+ round trip, and a running turn cannot be cancelled.
250
+
251
+ ## Testing
252
+
253
+ `tests/` runs a real `Agent` graph against a private `dexcli dev --engine zd`
254
+ engine and asserts each turn's Step shape from the Flow history. The scripted
255
+ model in `tests/scripted_model.py` implements the SDK `Model` interface and
256
+ derives every response from its input, so no model key is needed and a
257
+ retried `RunModel` gets the same response. It streams through
258
+ `agents.testing`'s `ScriptedModel` and records every model call, so tests
259
+ count them. The Worker runs as a child process that the tests SIGKILL.