diff --git a/CHANGELOG.md b/CHANGELOG.md index 358bd9bb2..d146f045b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,9 @@ to include examples, links to docs, or any other relevant information. without separately installing `mcp`. Previously the import failed with an `ImportError` because `google.adk.tools.mcp_tool` only exports `McpToolset` when `mcp` is installed. +- `temporalio.contrib.openai_agents` no longer crashes when a plain `dict` + is passed for `run_config`. (openai-agents >= 0.19.0 accepts `dict` run + configs at its public runner API) ### Security diff --git a/temporalio/contrib/openai_agents/_openai_runner.py b/temporalio/contrib/openai_agents/_openai_runner.py index 478217c8f..ea2e6e5df 100644 --- a/temporalio/contrib/openai_agents/_openai_runner.py +++ b/temporalio/contrib/openai_agents/_openai_runner.py @@ -99,6 +99,28 @@ def _has_sandbox_agent(agent: Agent[Any], seen: set[int] | None = None) -> bool: return False +def _coerce_run_config(value: object) -> RunConfig: + """openai-agents >= 0.19 also accepts a plain dict for ``run_config``. + + This function normalizes to a RunConfig instance. + """ + if isinstance(value, RunConfig): + return value + if not isinstance(value, dict): + raise TypeError( + f"run_config must be a RunConfig instance or a dict, got {type(value).__name__}" + ) + field_names = { + config_field.name + for config_field in dataclasses.fields(RunConfig) + if config_field.init + } + unknown_fields = sorted(str(name) for name in value if name not in field_names) + if unknown_fields: + raise TypeError(f"Unknown run_config settings: {', '.join(unknown_fields)}") + return RunConfig(**value) + + class TemporalOpenAIRunner(AgentRunner): """Temporal Runner for OpenAI agents. @@ -148,8 +170,9 @@ def _prepare_workflow_run( raise ValueError("Temporal workflows don't support SQLite sessions.") run_config = kwargs.get("run_config") - if run_config is None: - run_config = RunConfig() + run_config = ( + RunConfig() if run_config is None else _coerce_run_config(run_config) + ) if run_config.model and not isinstance(run_config.model, _TemporalModelStub): if not isinstance(run_config.model, str): diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index df12685f0..25597ee55 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -89,7 +89,10 @@ ) from temporalio.contrib.openai_agents._invoke_model_activity import _build_tool from temporalio.contrib.openai_agents._model_parameters import ModelSummaryProvider -from temporalio.contrib.openai_agents._openai_runner import _convert_agent +from temporalio.contrib.openai_agents._openai_runner import ( + _coerce_run_config, + _convert_agent, +) from temporalio.contrib.openai_agents._temporal_model_stub import ( _TemporalModelStub, ) @@ -2074,13 +2077,14 @@ def get_model(self, model_name: str | None) -> Model: return self._model +MULTIPLE_MODELS_FINAL_RESPONSE = "I'm here to help! Was there a specific task you needed assistance with regarding the storeroom?" + + def multiple_models_mock_model(): return TestModel.returning_responses( [ ResponseBuilders.tool_call("{}", "transfer_to_underling"), - ResponseBuilders.output_message( - "I'm here to help! Was there a specific task you needed assistance with regarding the storeroom?" - ), + ResponseBuilders.output_message(MULTIPLE_MODELS_FINAL_RESPONSE), ] ) @@ -2160,6 +2164,84 @@ async def test_run_config_models(client: Client): assert provider.model_names == {"gpt-4o"} +@workflow.defn +class DictRunConfigWorkflow: + """Same agents as MultipleModelWorkflow, but passes run_config as a plain + dict, which openai-agents >= 0.19 accepts at its public runner boundaries.""" + + @workflow.run + async def run(self) -> str: + underling = Agent[None]( + name="Underling", + instructions="You do all the work you are told.", + ) + + starting_agent = Agent[None]( + name="Lazy Assistant", + model="gpt-4o-mini", + instructions="You delegate all your work to another agent.", + handoffs=[underling], + ) + # Typed as Any so this also type-checks against openai-agents + # versions whose run_config annotation does not include dict. + dict_run_config: Any = {"model": "gpt-4o"} + result = await Runner.run( + starting_agent=starting_agent, + input="Have you cleaned the store room yet?", + run_config=dict_run_config, + ) + return result.final_output + + +async def test_dict_run_config_models(client: Client): + # A dict run_config must behave identically to the equivalent + # RunConfig(model="gpt-4o") in test_run_config_models above. + provider = AssertDifferentModelProvider(multiple_models_mock_model()) + async with AgentEnvironment( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=120) + ), + model_provider=provider, + ) as env: + client = env.applied_on_client(client) + + async with new_worker( + client, + DictRunConfigWorkflow, + ) as worker: + workflow_handle = await client.start_workflow( + DictRunConfigWorkflow.run, + id=f"dict-run-config-model-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=10), + ) + result = await workflow_handle.result() + + # Only the model from the runconfig override is used + assert provider.model_names == {"gpt-4o"} + assert result == MULTIPLE_MODELS_FINAL_RESPONSE + + +def test_coerce_run_config_validation(): + # Mirrors upstream agents' normalization: equivalent RunConfig out of a + # dict, and the same TypeErrors for invalid input. + coerced = _coerce_run_config({"model": "gpt-4o", "workflow_name": "wf"}) + assert isinstance(coerced, RunConfig) + assert coerced.model == "gpt-4o" + assert coerced.workflow_name == "wf" + + run_config = RunConfig(model="gpt-4o") + assert _coerce_run_config(run_config) is run_config + + with pytest.raises(TypeError, match="Unknown run_config settings: bogus_setting"): + _coerce_run_config({"model": "gpt-4o", "bogus_setting": True}) + + with pytest.raises( + TypeError, match="run_config must be a RunConfig instance or a dict, got int" + ): + _coerce_run_config(42) + + async def test_summary_provider(client: Client): class SummaryProvider(ModelSummaryProvider): def provide(