From 197434cb160d7a219807a42dad29bfa4cbe79023 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 28 May 2026 12:42:40 -0700 Subject: [PATCH 1/4] Tolerate OpenAI model schema drift in the agents payload converter The OpenAI SDK response models can drift from live API payloads (e.g. a deprecated-but-required field the API has stopped sending, such as ActionSearch.query on web_search_call results). The SDK tolerates this when parsing responses, but strict TypeAdapter.validate_json on the workflow side does not, which breaks deserializing ModelResponse across the activity boundary. Add a lenient fallback to the OpenAI agents payload converter: try strict pydantic validation first and, only on ValidationError, rebuild via OpenAI's own construct_type (handling the agents dataclass wrapper). The happy path is unchanged; the fallback retires once upstream fixes the field requiredness. --- .../openai_agents/_temporal_openai_agents.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 60b4b36ef..539c0c259 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -1,12 +1,21 @@ """Initialize Temporal OpenAI Agents overrides.""" import dataclasses +import json import typing from collections.abc import AsyncIterator, Callable, Iterator, Sequence from contextlib import asynccontextmanager, contextmanager from datetime import timedelta +import pydantic from agents import ModelProvider, Trace, set_trace_provider + +# construct_type is OpenAI's lenient (non-validating) model builder, the same +# one the SDK uses to parse live API responses. It is in a private module but +# has no public alias. +from openai._models import construct_type + +import temporalio.api.common.v1 from agents.run import get_default_agent_runner, set_default_agent_runner from agents.tracing import get_trace_provider from agents.tracing.provider import DefaultTraceProvider @@ -25,6 +34,7 @@ from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError from temporalio.contrib.opentelemetry._tracer_provider import ReplaySafeTracerProvider from temporalio.contrib.pydantic import ( + PydanticJSONPlainPayloadConverter, PydanticPayloadConverter, ToJsonOptions, ) @@ -64,12 +74,64 @@ def _set_open_ai_agent_temporal_overrides( set_trace_provider(previous_trace_provider or DefaultTraceProvider()) +def _lenient_construct(type_: typing.Any, value: typing.Any) -> typing.Any: + """Build ``value`` into ``type_`` without enforcing required fields. + + OpenAI's ``construct_type`` handles its own response models (and the + unions/lists thereof), but not the ``agents`` dataclasses that wrap them + (e.g. ``ModelResponse``), so the dataclass layer is reconstructed here and + each field delegated to ``construct_type``. ``include_extras`` preserves the + ``Annotated`` discriminators the unions rely on. + """ + if ( + isinstance(type_, type) + and dataclasses.is_dataclass(type_) + and isinstance(value, dict) + ): + hints = typing.get_type_hints(type_, include_extras=True) + return type_( + **{ + field.name: _lenient_construct( + hints.get(field.name, object), value[field.name] + ) + for field in dataclasses.fields(type_) + if field.name in value + } + ) + return construct_type(type_=type_, value=value) + + +class _OpenAIJSONPlainPayloadConverter(PydanticJSONPlainPayloadConverter): + """Strict pydantic deserialization with a lenient fallback. + + OpenAI's response models can drift from live API payloads (e.g. a + deprecated-but-required field the API has stopped sending). The SDK tolerates + this when parsing responses, but strict ``validate_json`` on the workflow + side does not, so fall back to lenient construction when validation fails. + """ + + def from_payload( + self, + payload: temporalio.api.common.v1.Payload, + type_hint: type | None = None, + ) -> typing.Any: + """See base class.""" + try: + return super().from_payload(payload, type_hint) + except pydantic.ValidationError: + if type_hint is None: + raise + return _lenient_construct(type_hint, json.loads(payload.data)) + + class OpenAIPayloadConverter(PydanticPayloadConverter): """PayloadConverter for OpenAI agents.""" def __init__(self) -> None: """Initialize a payload converter.""" super().__init__(ToJsonOptions(exclude_unset=True)) + lenient = _OpenAIJSONPlainPayloadConverter(ToJsonOptions(exclude_unset=True)) + self.converters = {**self.converters, lenient.encoding.encode(): lenient} def _data_converter(converter: DataConverter | None) -> DataConverter: From 321d485d4cba9a2b102d46ed39fa4fd277ed89f3 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 28 May 2026 12:52:24 -0700 Subject: [PATCH 2/4] Update research mock to emit web_search action with queries, not query Mirror the current OpenAI API, which returns the search action with the plural `queries` and omits the deprecated singular `query`. Built via model_construct so the required-but-unset `query` is excluded on serialization, exercising the lenient converter fallback in the use_local_model path without needing a live API key. --- tests/contrib/openai_agents/test_openai.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 294acc1d0..de0af3923 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -547,7 +547,9 @@ def research_mock_model(): id="", status="completed", type="web_search_call", - action=ActionSearch(query="", type="search"), + action=ActionSearch.model_construct( + type="search", queries=[""] + ), ), ResponseBuilders.response_output_message("Granada"), ], From 5d3ef1132aa24102b6178866190645716294b3fb Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 28 May 2026 13:14:07 -0700 Subject: [PATCH 3/4] Sort imports in OpenAI agents overrides --- .../contrib/openai_agents/_temporal_openai_agents.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 539c0c259..33d950b42 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -9,6 +9,9 @@ import pydantic from agents import ModelProvider, Trace, set_trace_provider +from agents.run import get_default_agent_runner, set_default_agent_runner +from agents.tracing import get_trace_provider +from agents.tracing.provider import DefaultTraceProvider # construct_type is OpenAI's lenient (non-validating) model builder, the same # one the SDK uses to parse live API responses. It is in a private module but @@ -16,10 +19,6 @@ from openai._models import construct_type import temporalio.api.common.v1 -from agents.run import get_default_agent_runner, set_default_agent_runner -from agents.tracing import get_trace_provider -from agents.tracing.provider import DefaultTraceProvider - from temporalio.contrib.openai_agents._invoke_model_activity import ModelActivity from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters from temporalio.contrib.openai_agents._openai_runner import ( From 6062c0ed993dae47cc613f22baac2abd29cde868 Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 28 May 2026 15:15:22 -0700 Subject: [PATCH 4/4] Reimplement OpenAIPayloadConverter as a CompositePayloadConverter Build the converter tuple directly instead of mutating self.converters after super().__init__(). --- .../openai_agents/_temporal_openai_agents.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/temporalio/contrib/openai_agents/_temporal_openai_agents.py b/temporalio/contrib/openai_agents/_temporal_openai_agents.py index 33d950b42..43594657f 100644 --- a/temporalio/contrib/openai_agents/_temporal_openai_agents.py +++ b/temporalio/contrib/openai_agents/_temporal_openai_agents.py @@ -34,12 +34,13 @@ from temporalio.contrib.opentelemetry._tracer_provider import ReplaySafeTracerProvider from temporalio.contrib.pydantic import ( PydanticJSONPlainPayloadConverter, - PydanticPayloadConverter, ToJsonOptions, ) from temporalio.converter import ( + CompositePayloadConverter, DataConverter, DefaultPayloadConverter, + JSONPlainPayloadConverter, ) from temporalio.plugin import SimplePlugin from temporalio.worker import WorkflowRunner @@ -123,14 +124,22 @@ def from_payload( return _lenient_construct(type_hint, json.loads(payload.data)) -class OpenAIPayloadConverter(PydanticPayloadConverter): +class OpenAIPayloadConverter(CompositePayloadConverter): """PayloadConverter for OpenAI agents.""" def __init__(self) -> None: """Initialize a payload converter.""" - super().__init__(ToJsonOptions(exclude_unset=True)) - lenient = _OpenAIJSONPlainPayloadConverter(ToJsonOptions(exclude_unset=True)) - self.converters = {**self.converters, lenient.encoding.encode(): lenient} + json_payload_converter = _OpenAIJSONPlainPayloadConverter( + ToJsonOptions(exclude_unset=True) + ) + super().__init__( + *( + c + if not isinstance(c, JSONPlainPayloadConverter) + else json_payload_converter + for c in DefaultPayloadConverter.default_encoding_payload_converters + ) + ) def _data_converter(converter: DataConverter | None) -> DataConverter: