Skip to content

Allow applications to explicitly approve replay-unsafe model retries #4283

Description

@Filimoa

Please read this first

  • I read the Agents SDK runner-managed retry and Responses WebSocket documentation.
  • I searched open and closed issues for replay safety, response-started disconnects, and retry-policy overrides.
  • I reproduced this with openai-agents 0.19.4 and confirmed the relevant behavior remains on main.

Describe the feature

The runner-managed retry API currently has an ownership gap: applications can provide a ModelRetrySettings.policy, but that policy is not invoked when provider advice marks replay as unsafe.

This occurs when a non-streamed Runner.run() request over the Responses WebSocket transport receives one or more response events and then loses the connection before receiving a terminal event.

The observed exception is:

websockets.exceptions.ConnectionClosedError: no close frame received or sent

The WebSocket adapter correctly:

  1. Detects that response processing started.
  2. Drops the failed connection.
  3. Marks the failure with replay_safety="unsafe".

However, the runner then returns RetryDecision(retry=False) before invoking the configured application retry policy:

https://github.com/openai/openai-agents-python/blob/main/src/agents/run_internal/model_retry.py#L276-L315

This is consistent with the documented safety boundary that requests are not replayed after a response event arrives:

https://openai.github.io/openai-agents-python/models/#runner-managed-retries

I agree with that behavior as the default. The SDK cannot determine whether replaying an interrupted request is acceptable for a particular application:

  • For non-streamed Runner.run(), partial model output has not been exposed to the application.
  • Application-local function tools from the incomplete model response have not been executed.
  • Previously completed Runner turns and local tool outputs can be preserved by retrying only the current model request.
  • However, hosted tools or other provider-side work may already have occurred and could be duplicated.

Therefore, this should not become an automatic retry. The application needs a supported way to explicitly accept that risk for a narrowly defined workload, such as a read-only research or evaluation run.

Current limitation

RetryDecision already contains an internal _approves_replay field:

https://github.com/openai/openai-agents-python/blob/main/src/agents/retry.py#L115-L123

Internal provider policies can set that approval when the provider considers replay safe, but an application retry policy cannot express the equivalent application-owned decision.

The only current workaround is to wrap or subclass OpenAIResponsesWSModel and override get_retry_advice() so that an application decision is presented as provider advice:

ModelRetryAdvice(
    suggested=True,
    replay_safety="safe",
)

That works, but it requires low-level model/provider plumbing and assigns the decision to the wrong abstraction boundary.

Proposed API

Please expose deliberate application replay approval through the existing retry-policy API.

For example:

RetryDecision(
    retry=True,
    approve_unsafe_replay=True,
    reason="Application approved one replay of this read-only non-streamed turn",
)

The exact field name is flexible, but it should be intentionally explicit. An ordinary boolean True or RetryDecision(retry=True) must not bypass replay protection.

The runner would evaluate the application policy for a provider-unsafe failure and proceed only when the returned decision both requests a retry and explicitly approves replay.

The policy also needs enough public, structured context to scope that approval safely. In particular, it should be possible to determine whether:

  • A response event had already arrived.
  • User-visible streamed output was emitted.
  • The request uses previous_response_id or conversation_id.
  • Provider replay safety is safe, unsafe, or unknown.
  • The failure was a response-started transport disconnect rather than another unsafe condition.

Conceptually:

def retry_policy(context):
    if (
        context.attempt == 1
        and not context.stream
        and context.replay.response_started
        and context.replay.reason == "connection_closed"
        and isinstance(context.error, ConnectionClosedError)
    ):
        return RetryDecision(
            retry=True,
            approve_unsafe_replay=True,
            reason="Approved one replay of a read-only model turn",
        )

    return RetryDecision(retry=False)

Required safety properties

  • Preserve the current no-replay default.
  • Do not let ordinary retry=True override replay protection.
  • Keep abort errors as absolute vetoes.
  • Keep retries blocked after user-visible streamed output has been emitted.
  • Require explicit approval for every replay-unsafe retry decision.
  • Continue respecting max_retries, backoff, cancellation, and tracing.
  • Retry only the current model request so completed Runner turns and application-local tool work are not repeated.
  • Use the fresh connection already created after the WebSocket adapter drops the failed socket.
  • Expose stateful-request information so applications can avoid approving replays that depend on unavailable connection-local state.

Deterministic control-flow reproduction

The policy bypass can be reproduced without making an API request:

import asyncio

from agents.retry import ModelRetryAdvice, RetryDecision
from agents.run_internal.model_retry import _evaluate_retry

policy_called = False


def policy(_context):
    global policy_called
    policy_called = True
    return RetryDecision(retry=True)


async def main():
    decision = await _evaluate_retry(
        error=RuntimeError("WebSocket closed after response processing started"),
        attempt=1,
        max_retries=1,
        retry_policy=policy,
        retry_backoff=None,
        stream=False,
        replay_unsafe_request=False,
        emitted_retry_unsafe_event=False,
        provider_advice=ModelRetryAdvice(
            suggested=False,
            replay_safety="unsafe",
        ),
    )

    print(decision.retry, policy_called)


asyncio.run(main())

Current output:

False False

There is presently no public RetryDecision value that can produce an explicitly application-approved replay.

Environment

  • openai-agents: 0.19.4
  • openai: 2.45.0
  • websockets: 15.0.1
  • Python: 3.14.3
  • Transport: OpenAI Responses WebSocket
  • Runner interface: non-streamed Runner.run()
  • auto_previous_response_id=True

I observed this twice during evaluation runs and less frequently in production. Both evaluation failures occurred well before the documented 60-minute WebSocket connection limit.

Alternatives considered

Retry the entire run

This can repeat already-completed tool calls and sub-agent work. The existing runner-managed current-model retry boundary is the correct recovery scope.

Override OpenAIResponsesWSModel.get_retry_advice()

This is functional as a workaround, but requires model/provider wrapping and makes an application-owned risk decision appear to be provider-owned advice.

Automatically classify response-started disconnects as safe

This would be incorrect because hosted or provider-side work may already have occurred. Application approval must remain explicit.

Resume the interrupted response

The Responses WebSocket documentation describes reconnecting between turns but does not document resuming an interrupted in-flight WebSocket response. Cursor-based stream resumption is documented for background SSE responses, while WebSocket response.create does not use background mode:

https://developers.openai.com/api/docs/guides/websocket-mode

https://developers.openai.com/api/docs/guides/background#streaming-a-background-response

Related issues

This is related to, but distinct from:

Those issues addressed retry classification before response processing began and correctly preserved the post-response replay veto. This request preserves that veto by default while allowing a sufficiently informed application to override it explicitly and narrowly.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions