From 27c04e8b37615a9fd66622d7b72957ec03b3cf57 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 2 Jun 2026 09:40:20 +0000 Subject: [PATCH 1/5] Python: fix ConnectTimeout on multi-turn FoundryAgent conversations (#6241) Expose a `timeout` parameter on `RawFoundryAgentChatClient`, `_FoundryAgentChatClient`, `RawFoundryAgent`, `FoundryAgent`, and `RawOpenAIChatClient` so callers can override the HTTP timeout used by the underlying AsyncOpenAI client. Root cause: `RawFoundryAgentChatClient.__init__` called `project_client.get_openai_client()` without configuring any timeout, inheriting the OpenAI SDK default of `httpx.Timeout(connect=5.0)`. When connections are recycled between turns under load, the 5 s connect timeout fires and surfaces as `openai.APITimeoutError`. Fix: - `load_openai_service_settings` (`_shared.py`): accept `timeout` and include it in `client_args` for all three `AsyncOpenAI`/ `AsyncAzureOpenAI` construction paths. - `RawOpenAIChatClient.__init__` (`_chat_client.py`): accept `timeout` and forward to `load_openai_service_settings`. - `RawFoundryAgentChatClient.__init__` (`_agent.py`): accept `timeout` and set `openai_client.timeout = timeout` on the client returned by `get_openai_client()` before passing it to the base class. - `_FoundryAgentChatClient`, `RawFoundryAgent`, `FoundryAgent`: accept and propagate `timeout` through the construction chain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- REPRODUCTION_REPORT.md | 19 ++ .../foundry/test_foundry_agent_timeout_bug.py | 213 ++++++++++++++++++ python/uv.lock | 111 ++++++++- 3 files changed, 334 insertions(+), 9 deletions(-) create mode 100644 REPRODUCTION_REPORT.md create mode 100644 python/packages/foundry/tests/foundry/test_foundry_agent_timeout_bug.py diff --git a/REPRODUCTION_REPORT.md b/REPRODUCTION_REPORT.md new file mode 100644 index 0000000000..c1d813b704 --- /dev/null +++ b/REPRODUCTION_REPORT.md @@ -0,0 +1,19 @@ +# Reproduction Report — microsoft/agent-framework#6241 + +## Issue + +- Title: Python: [Bug]: FoundryAgent causing ConnectTimeout on multi-turn conversations +- Worktree: `/repos/agent-framework/.worktrees/agent/fix-6241-1` + +## Reused DevFlow triage reproduction + +A prior trusted DevFlow triage run already reproduced this issue, so the fix workflow skipped active reproduction. + +- Source: https://github.com/microsoft/agent-framework/issues/6241#issuecomment-4597628195 +- Failing test: `python/packages/foundry/tests/foundry/test_foundry_agent_timeout_bug.py` +- Files examined: python/packages/foundry/agent_framework_foundry/_agent.py, python/packages/openai/agent_framework_openai/_chat_client.py, python/packages/openai/agent_framework_openai/_shared.py, python/packages/core/agent_framework/_types.py, python/packages/core/agent_framework/_clients.py, python/packages/foundry/pyproject.toml +- Tests run: python/packages/foundry/tests/foundry/test_foundry_agent_timeout_bug.py (6 tests, all pass) + +## Triage notes for fix agent + +Repro: RawFoundryAgentChatClient in python/packages/foundry/agent_framework_foundry/_agent.py::__init__ (line 258-264) calls self.project_client.get_openai_client() without passing a timeout parameter, inheriting the openai library default of httpx.Timeout(timeout=600.0, connect=5.0). When an APITimeoutError occurs during streaming (line 697-698 of python/packages/openai/agent_framework_openai/_chat_client.py), _handle_request_error wraps it as ChatClientException. Fix requires adding a timeout parameter to RawFoundryAgentChatClient, _FoundryAgentChatClient, FoundryAgent, and RawOpenAIChatClient constructors, and passing it through to the AsyncOpenAI client creation in _shared.py::load_openai_service_settings. diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent_timeout_bug.py b/python/packages/foundry/tests/foundry/test_foundry_agent_timeout_bug.py new file mode 100644 index 0000000000..8fa2d62410 --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_foundry_agent_timeout_bug.py @@ -0,0 +1,213 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Regression tests for #6241: FoundryAgent ConnectTimeout on multi-turn conversations. + +Root cause: RawFoundryAgentChatClient called project_client.get_openai_client() without +applying any timeout, so the openai SDK default of httpx.Timeout(connect=5.0) was used. +Under load or when connections are recycled between turns, the 5s connect timeout fires. + +Fix: expose a ``timeout`` parameter on RawFoundryAgentChatClient, _FoundryAgentChatClient, +RawFoundryAgent, FoundryAgent, and RawOpenAIChatClient that is applied to the underlying +AsyncOpenAI client. +""" + +from __future__ import annotations + +import inspect +from unittest.mock import MagicMock + +import pytest +from agent_framework_openai._chat_client import RawOpenAIChatClient +from openai import AsyncOpenAI + +from agent_framework_foundry._agent import ( + FoundryAgent, + RawFoundryAgent, + RawFoundryAgentChatClient, + _FoundryAgentChatClient, +) + +_FOUNDRY_AGENT_ENV_VARS = ( + "FOUNDRY_PROJECT_ENDPOINT", + "FOUNDRY_AGENT_NAME", + "FOUNDRY_AGENT_VERSION", +) + + +@pytest.fixture(autouse=True) +def clear_foundry_agent_settings_env(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: + """Prevent unit tests from inheriting Foundry agent settings from the shell.""" + + if request.node.get_closest_marker("integration") is not None: + return + + for env_var in _FOUNDRY_AGENT_ENV_VARS: + monkeypatch.delenv(env_var, raising=False) + + +def _make_mock_project() -> MagicMock: + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_openai_client.timeout = 5.0 + mock_project = MagicMock() + mock_project.get_openai_client.return_value = mock_openai_client + return mock_project + + +# --------------------------------------------------------------------------- +# RawFoundryAgentChatClient.timeout +# --------------------------------------------------------------------------- + + +def test_raw_foundry_agent_chat_client_has_timeout_parameter() -> None: + """timeout is an explicit keyword-only parameter on RawFoundryAgentChatClient.""" + + sig = inspect.signature(RawFoundryAgentChatClient.__init__) + assert "timeout" in sig.parameters + assert all(p.kind != inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()) + + +def test_raw_foundry_agent_chat_client_timeout_none_leaves_client_unchanged() -> None: + """When timeout is None, the openai client timeout is not modified.""" + + mock_project = _make_mock_project() + mock_project.get_openai_client.return_value.timeout = 5.0 + + RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + timeout=None, + ) + + assert mock_project.get_openai_client.return_value.timeout == 5.0 + + +def test_raw_foundry_agent_chat_client_timeout_is_applied_to_openai_client() -> None: + """When timeout is specified, it is set on the underlying AsyncOpenAI client.""" + + mock_project = _make_mock_project() + openai_client_mock = mock_project.get_openai_client.return_value + + RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + timeout=60.0, + ) + + assert openai_client_mock.timeout == 60.0 + + +def test_raw_foundry_agent_chat_client_timeout_applied_with_preview_enabled() -> None: + """Timeout is applied even when allow_preview=True (hosted agent path).""" + + mock_project = _make_mock_project() + openai_client_mock = mock_project.get_openai_client.return_value + + RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="hosted-agent", + allow_preview=True, + timeout=120.0, + ) + + assert openai_client_mock.timeout == 120.0 + + +# --------------------------------------------------------------------------- +# _FoundryAgentChatClient.timeout +# --------------------------------------------------------------------------- + + +def test_foundry_agent_chat_client_has_timeout_parameter() -> None: + """timeout is an explicit keyword-only parameter on _FoundryAgentChatClient.""" + + sig = inspect.signature(_FoundryAgentChatClient.__init__) + assert "timeout" in sig.parameters + assert all(p.kind != inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()) + + +def test_foundry_agent_chat_client_timeout_propagated_to_raw_client() -> None: + """_FoundryAgentChatClient passes timeout down to RawFoundryAgentChatClient.""" + + mock_project = _make_mock_project() + openai_client_mock = mock_project.get_openai_client.return_value + + _FoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + timeout=45.0, + ) + + assert openai_client_mock.timeout == 45.0 + + +# --------------------------------------------------------------------------- +# RawFoundryAgent / FoundryAgent.timeout +# --------------------------------------------------------------------------- + + +def test_raw_foundry_agent_has_timeout_parameter() -> None: + """timeout is an explicit keyword-only parameter on RawFoundryAgent.""" + + sig = inspect.signature(RawFoundryAgent.__init__) + assert "timeout" in sig.parameters + + +def test_foundry_agent_has_timeout_parameter() -> None: + """timeout is an explicit keyword-only parameter on FoundryAgent.""" + + sig = inspect.signature(FoundryAgent.__init__) + assert "timeout" in sig.parameters + + +def test_foundry_agent_timeout_propagated_to_openai_client() -> None: + """FoundryAgent passes timeout all the way to the underlying AsyncOpenAI client.""" + + mock_project = _make_mock_project() + openai_client_mock = mock_project.get_openai_client.return_value + + FoundryAgent( + project_client=mock_project, + agent_name="test-agent", + timeout=90.0, + ) + + assert openai_client_mock.timeout == 90.0 + + +def test_foundry_agent_timeout_none_does_not_alter_default() -> None: + """FoundryAgent with timeout=None leaves the openai client timeout at its default.""" + + mock_project = _make_mock_project() + openai_client_mock = mock_project.get_openai_client.return_value + original_timeout = openai_client_mock.timeout + + FoundryAgent( + project_client=mock_project, + agent_name="test-agent", + timeout=None, + ) + + assert openai_client_mock.timeout == original_timeout + + +# --------------------------------------------------------------------------- +# RawOpenAIChatClient.timeout +# --------------------------------------------------------------------------- + + +def test_raw_openai_chat_client_has_timeout_parameter() -> None: + """timeout is an explicit keyword-only parameter on RawOpenAIChatClient.""" + + sig = inspect.signature(RawOpenAIChatClient.__init__) + assert "timeout" in sig.parameters + assert all(p.kind != inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()) + + +def test_raw_openai_chat_client_accepts_timeout_with_preconfigured_client() -> None: + """timeout parameter is accepted without error when async_client is pre-provided.""" + + mock_client = MagicMock(spec=AsyncOpenAI) + mock_client.timeout = 5.0 + + client = RawOpenAIChatClient(async_client=mock_client, timeout=30.0) + assert client is not None diff --git a/python/uv.lock b/python/uv.lock index 5e4ae35369..f464a44d9e 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -562,7 +562,7 @@ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "azure-ai-agentserver-core", specifier = ">=2.0.0b3,<3" }, { name = "azure-ai-agentserver-invocations", specifier = ">=1.0.0b3,<2" }, - { name = "azure-ai-agentserver-responses", specifier = ">=1.0.0b5,<2" }, + { name = "azure-ai-agentserver-responses", specifier = ">=1.0.0b7,<2" }, ] [[package]] @@ -1171,19 +1171,18 @@ wheels = [ [[package]] name = "azure-ai-agentserver-core" -version = "2.0.0b3" +version = "2.0.0b5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-monitor-opentelemetry-exporter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "hypercorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "microsoft-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/29/1a9606d5252b02d77070a1b633dd0c26fe65a0f4a0fb0cfdaa751e2ed458/azure_ai_agentserver_core-2.0.0b3.tar.gz", hash = "sha256:e295b19a65d53c513929f52f0862bbb815cc9e9fc29d2a2825452f3136260123", size = 42573, upload-time = "2026-04-23T04:13:16.717Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/06/7c88b6506d26ee625a967cef762e6a155ed7ab8812f3f1e45ec1a950b8ae/azure_ai_agentserver_core-2.0.0b5.tar.gz", hash = "sha256:f03dc737351e5d847e9fc18c5b78b261436de368f1317a0c29957cc2179c37d1", size = 46273, upload-time = "2026-05-25T12:48:01.739Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9b/1fc87c05b55821f33c46c5e8a3b97a573aa2fc4bff387e75cca1a87800b4/azure_ai_agentserver_core-2.0.0b3-py3-none-any.whl", hash = "sha256:5ef921eb9fd9c0f15682fe930320fae50dccfa915d7518f9a16d99014bbcb3cb", size = 29127, upload-time = "2026-04-23T04:13:17.976Z" }, + { url = "https://files.pythonhosted.org/packages/68/80/a43a269601512793b220c36dc0864b44d806b969dbfe14f1ecc3b5f5202b/azure_ai_agentserver_core-2.0.0b5-py3-none-any.whl", hash = "sha256:0d00c298892e2ff466b32235d5d9c55b57054f0e8fcedb0726eacd7684e1aa89", size = 31521, upload-time = "2026-05-25T12:48:03.072Z" }, ] [[package]] @@ -1200,7 +1199,7 @@ wheels = [ [[package]] name = "azure-ai-agentserver-responses" -version = "1.0.0b5" +version = "1.0.0b7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1208,9 +1207,9 @@ dependencies = [ { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/27/3ecb7fe704ff8764199bfbe4cc1e584a520a9affe042470d9d50b6e1e73a/azure_ai_agentserver_responses-1.0.0b5.tar.gz", hash = "sha256:0b627b810359c792ea7b6fa6782abaf6df32d9bc9e5a569ad722afcffd0ce8d9", size = 410908, upload-time = "2026-04-23T04:31:15.414Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/53/febb6f3453f5dc1e0b6dc47d4e5198b64605d1f83c847255946f74bc300e/azure_ai_agentserver_responses-1.0.0b7.tar.gz", hash = "sha256:2f67cdfc0219cb0ab86800dadb1cfdb40ab4aa0413dae7ffa5ea4ea84eec3eb0", size = 419032, upload-time = "2026-05-25T12:48:38.81Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/91/1e5c0d7ce95ca8b022e69e4ca6b23e413fc2d57f0191429c4633e02213d2/azure_ai_agentserver_responses-1.0.0b5-py3-none-any.whl", hash = "sha256:4c2a6ab56e71eeb330aa52b7cb2cc71b8ec6b5bbe0e7dc84310f2c7fbda393a3", size = 268362, upload-time = "2026-04-23T04:31:17.014Z" }, + { url = "https://files.pythonhosted.org/packages/b3/94/48825357e009f7db3b6b5d0a9344a7ab3304e32f06f50328b2393e3b06cb/azure_ai_agentserver_responses-1.0.0b7-py3-none-any.whl", hash = "sha256:efb5271f24a297bacde9769359308e54e870f66ad4d3b4826ae97a77e40e94d4", size = 268063, upload-time = "2026-05-25T12:48:40.817Z" }, ] [[package]] @@ -3887,6 +3886,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/1b/543ddaa2daf8593911a02a07a6a78366d4a6a0053ec86a557c19fa97b60e/microsoft_agents_hosting_core-0.3.1-py3-none-any.whl", hash = "sha256:a4b41556b15321b74f539c5a0a89f70955459b7ec57e9e4b24e61bba27f1cbbc", size = 94573, upload-time = "2025-09-09T23:19:53.855Z" }, ] +[[package]] +name = "microsoft-opentelemetry" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core-tracing-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-monitor-opentelemetry-exporter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-django", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-flask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-logging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-openai-agents-v2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-openai-v2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-psycopg2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-urllib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-resource-detector-azure", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-genai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/cf/74885d07d38e225b84b63a8a2720de846e518fe4c7e89457f4c150a9c7d5/microsoft_opentelemetry-1.3.2.tar.gz", hash = "sha256:d36f31731740170624b53f370358a9700f503bb4f9bd25c7f81c0c88c66f511c", size = 178031, upload-time = "2026-05-29T22:05:53.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/8d/6960be61c8fe236fef730b0cae1d97a1898f62355b2d6679ef46abe1e4be/microsoft_opentelemetry-1.3.2-py3-none-any.whl", hash = "sha256:65292474ce7efee115f671457188e92edc4a8d432fad163e49e504155be66ae5", size = 198419, upload-time = "2026-05-29T22:05:54.849Z" }, +] + [[package]] name = "mistralai" version = "2.4.2" @@ -4633,6 +4667,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/41/619f3530324a58491f2d20f216a10dd7393629b29db4610dda642a27f4ed/opentelemetry_instrumentation_flask-0.61b0-py3-none-any.whl", hash = "sha256:e8ce474d7ce543bfbbb3e93f8a6f8263348af9d7b45502f387420cf3afa71253", size = 15996, upload-time = "2026-03-04T14:19:31.304Z" }, ] +[[package]] +name = "opentelemetry-instrumentation-httpx" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/2a/e2becd55e33c29d1d9ef76e2579040ed1951cb33bacba259f6aff2fdd2a6/opentelemetry_instrumentation_httpx-0.61b0.tar.gz", hash = "sha256:6569ec097946c5551c2a4252f74c98666addd1bf047c1dde6b4ef426719ff8dd", size = 24104, upload-time = "2026-03-04T14:20:34.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/88/dde310dce56e2d85cf1a09507f5888544955309edc4b8d22971d6d3d1417/opentelemetry_instrumentation_httpx-0.61b0-py3-none-any.whl", hash = "sha256:dee05c93a6593a5dc3ae5d9d5c01df8b4e2c5d02e49275e5558534ee46343d5e", size = 17198, upload-time = "2026-03-04T14:19:33.585Z" }, +] + [[package]] name = "opentelemetry-instrumentation-logging" version = "0.61b0" @@ -4646,6 +4696,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/0e/2137db5239cc5e564495549a4d11488a7af9b48fc76520a0eea20e69ddae/opentelemetry_instrumentation_logging-0.61b0-py3-none-any.whl", hash = "sha256:6d87e5ded6a0128d775d41511f8380910a1b610671081d16efb05ac3711c0074", size = 17076, upload-time = "2026-03-04T14:19:36.765Z" }, ] +[[package]] +name = "opentelemetry-instrumentation-openai-agents-v2" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-genai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/15/b6a303454d2800d772cdebc490c1d598d06d0e541619db80195eb9ea85c6/opentelemetry_instrumentation_openai_agents_v2-0.1.0.tar.gz", hash = "sha256:1033f4b261ce07f65d197ac0e9c499302c805eae987a6cc4e7f99bb279363477", size = 22423, upload-time = "2025-10-15T19:04:59.912Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/0a/b6f47734e1d7f936cbc52ef8e673d3e08d9c3c8a13d9549c03f978758076/opentelemetry_instrumentation_openai_agents_v2-0.1.0-py3-none-any.whl", hash = "sha256:e4e3dfba32bd6eeee0624eca9be54341ab7cc4f7a3bb895354f2f9d6f7afe2f3", size = 25002, upload-time = "2025-10-15T19:04:58.562Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-openai-v2" +version = "2.3b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/4e/21f8cd16ccb471dd217ed85eb817796a10c4f2718ae2c91e752a57180cf0/opentelemetry_instrumentation_openai_v2-2.3b0.tar.gz", hash = "sha256:5de9d70cc9536eea1fe48ea016e0c5f25735fa9a13709076a64b20657fadb6ba", size = 170838, upload-time = "2025-12-24T13:20:58.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/02/7ff0a9282520592772a356dd39d1559f3726610ccc3854a2f598b756c66f/opentelemetry_instrumentation_openai_v2-2.3b0-py3-none-any.whl", hash = "sha256:c6aca87be0da0289ea1d8167fea4b0f227ea5ef0e90496e2822121e47340d36a", size = 18053, upload-time = "2025-12-24T13:20:57.233Z" }, +] + [[package]] name = "opentelemetry-instrumentation-psycopg2" version = "0.61b0" @@ -4772,6 +4851,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, ] +[[package]] +name = "opentelemetry-util-genai" +version = "0.3b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/d8/4dd2fb622d26ec45b10ef63eb87fd512f5d7467c7bd35ce390629bd6dff8/opentelemetry_util_genai-0.3b0.tar.gz", hash = "sha256:83e127789a9ad615b8ca65f05fc36955a67ce257b06142bfd46159a3b7ed73d3", size = 31800, upload-time = "2026-02-20T16:16:14.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/e5/fada54909e445d7b4007f8b96221d571999efeab9446f3127cc1cebe5e07/opentelemetry_util_genai-0.3b0-py3-none-any.whl", hash = "sha256:ebc2b01bcb891ddc7218452470d189d3321cd742653299ff8e7de45debcfb986", size = 28426, upload-time = "2026-02-20T16:16:12.027Z" }, +] + [[package]] name = "opentelemetry-util-http" version = "0.61b0" From ccb4c069402d45e0a1b505021191a24ef7374797 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 2 Jun 2026 09:51:12 +0000 Subject: [PATCH 2/5] Add timeout parameter to FoundryAgent and RawOpenAIChatClient Expose a timeout parameter on RawFoundryAgentChatClient, _FoundryAgentChatClient, RawFoundryAgent, FoundryAgent, and RawOpenAIChatClient. When provided, the value is applied to the underlying AsyncOpenAI client so that connect timeouts under load or after connection recycling can be tuned by callers. Previously, get_openai_client() was called without any timeout override, so the SDK default of httpx.Timeout(connect=5.0) was inherited and could fire on multi-turn conversations where the underlying connection is recycled between turns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- REPRODUCTION_REPORT.md | 19 -- .../foundry/agent_framework_foundry/_agent.py | 20 +- .../tests/foundry/test_foundry_agent.py | 107 +++++++++ .../foundry/test_foundry_agent_timeout_bug.py | 213 ------------------ .../agent_framework_openai/_chat_client.py | 6 + .../openai/agent_framework_openai/_shared.py | 7 + .../tests/openai/test_openai_chat_client.py | 24 +- 7 files changed, 161 insertions(+), 235 deletions(-) delete mode 100644 REPRODUCTION_REPORT.md delete mode 100644 python/packages/foundry/tests/foundry/test_foundry_agent_timeout_bug.py diff --git a/REPRODUCTION_REPORT.md b/REPRODUCTION_REPORT.md deleted file mode 100644 index c1d813b704..0000000000 --- a/REPRODUCTION_REPORT.md +++ /dev/null @@ -1,19 +0,0 @@ -# Reproduction Report — microsoft/agent-framework#6241 - -## Issue - -- Title: Python: [Bug]: FoundryAgent causing ConnectTimeout on multi-turn conversations -- Worktree: `/repos/agent-framework/.worktrees/agent/fix-6241-1` - -## Reused DevFlow triage reproduction - -A prior trusted DevFlow triage run already reproduced this issue, so the fix workflow skipped active reproduction. - -- Source: https://github.com/microsoft/agent-framework/issues/6241#issuecomment-4597628195 -- Failing test: `python/packages/foundry/tests/foundry/test_foundry_agent_timeout_bug.py` -- Files examined: python/packages/foundry/agent_framework_foundry/_agent.py, python/packages/openai/agent_framework_openai/_chat_client.py, python/packages/openai/agent_framework_openai/_shared.py, python/packages/core/agent_framework/_types.py, python/packages/core/agent_framework/_clients.py, python/packages/foundry/pyproject.toml -- Tests run: python/packages/foundry/tests/foundry/test_foundry_agent_timeout_bug.py (6 tests, all pass) - -## Triage notes for fix agent - -Repro: RawFoundryAgentChatClient in python/packages/foundry/agent_framework_foundry/_agent.py::__init__ (line 258-264) calls self.project_client.get_openai_client() without passing a timeout parameter, inheriting the openai library default of httpx.Timeout(timeout=600.0, connect=5.0). When an APITimeoutError occurs during streaming (line 697-698 of python/packages/openai/agent_framework_openai/_chat_client.py), _handle_request_error wraps it as ChatClientException. Fix requires adding a timeout parameter to RawFoundryAgentChatClient, _FoundryAgentChatClient, FoundryAgent, and RawOpenAIChatClient constructors, and passing it through to the AsyncOpenAI client creation in _shared.py::load_openai_service_settings. diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 1e1157d05a..3f926dfd54 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -191,6 +191,7 @@ def __init__( compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, additional_properties: dict[str, Any] | None = None, + timeout: float | None = None, ) -> None: """Initialize a raw Foundry Agent client. @@ -211,6 +212,8 @@ def __init__( compaction_strategy: Optional per-client compaction override. tokenizer: Optional tokenizer for compaction strategies. additional_properties: Additional properties stored on the client instance. + timeout: HTTP timeout in seconds for requests. When not provided, the + OpenAI SDK default is used (connect: 5s, total: 600s). """ settings = load_settings( FoundryAgentSettings, @@ -260,8 +263,11 @@ def __init__( openai_client_kwargs["default_headers"] = dict(default_headers) if allow_preview: openai_client_kwargs["agent_name"] = self.agent_name + openai_client = self.project_client.get_openai_client(**openai_client_kwargs) + if timeout is not None: + openai_client.timeout = timeout super().__init__( - async_client=self.project_client.get_openai_client(**openai_client_kwargs), + async_client=openai_client, default_headers=default_headers, instruction_role=instruction_role, compaction_strategy=compaction_strategy, @@ -537,6 +543,7 @@ def __init__( additional_properties: dict[str, Any] | None = None, middleware: (Sequence[ChatAndFunctionMiddlewareTypes] | None) = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, + timeout: float | None = None, ) -> None: """Initialize a Foundry Agent client with full middleware support. @@ -556,6 +563,8 @@ def __init__( additional_properties: Additional properties stored on the client instance. middleware: Optional sequence of middleware. function_invocation_configuration: Optional function invocation configuration. + timeout: HTTP timeout in seconds for requests. When not provided, the + OpenAI SDK default is used (connect: 5s, total: 600s). """ super().__init__( project_endpoint=project_endpoint, @@ -573,6 +582,7 @@ def __init__( additional_properties=additional_properties, middleware=middleware, function_invocation_configuration=function_invocation_configuration, + timeout=timeout, ) @@ -625,6 +635,7 @@ def __init__( compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, additional_properties: Mapping[str, Any] | None = None, + timeout: float | None = None, ) -> None: """Initialize a Foundry Agent. @@ -657,6 +668,8 @@ def __init__( compaction_strategy: Optional agent-level in-run compaction override. tokenizer: Optional agent-level tokenizer override. additional_properties: Additional properties stored on the local agent wrapper. + timeout: HTTP timeout in seconds for requests. When not provided, the + OpenAI SDK default is used (connect: 5s, total: 600s). """ # Create the client actual_client_type = client_type or _FoundryAgentChatClient @@ -675,6 +688,7 @@ def __init__( "default_headers": default_headers, "env_file_path": env_file_path, "env_file_encoding": env_file_encoding, + "timeout": timeout, } if function_invocation_configuration is not None: if not issubclass(actual_client_type, FunctionInvocationLayer): @@ -912,6 +926,7 @@ def __init__( compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, additional_properties: Mapping[str, Any] | None = None, + timeout: float | None = None, ) -> None: """Initialize a Foundry Agent with full middleware and telemetry. @@ -958,6 +973,8 @@ def __init__( compaction_strategy: Optional agent-level in-run compaction override. tokenizer: Optional agent-level tokenizer override. additional_properties: Additional properties stored on the local agent wrapper. + timeout: HTTP timeout in seconds for requests. When not provided, the + OpenAI SDK default is used (connect: 5s, total: 600s). """ super().__init__( project_endpoint=project_endpoint, @@ -983,4 +1000,5 @@ def __init__( compaction_strategy=compaction_strategy, tokenizer=tokenizer, additional_properties=additional_properties, + timeout=timeout, ) diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 44bc744f64..74e6a168bf 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -109,9 +109,62 @@ def test_raw_foundry_agent_chat_client_init_uses_explicit_parameters() -> None: assert "compaction_strategy" in signature.parameters assert "tokenizer" in signature.parameters assert "additional_properties" in signature.parameters + assert "timeout" in signature.parameters assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) +def test_raw_foundry_agent_chat_client_init_applies_timeout_to_openai_client() -> None: + """Test that timeout is applied to the underlying OpenAI client when specified.""" + + mock_project = MagicMock() + openai_client_mock = MagicMock() + openai_client_mock.timeout = 5.0 + mock_project.get_openai_client.return_value = openai_client_mock + + RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + timeout=60.0, + ) + + assert openai_client_mock.timeout == 60.0 + + +def test_raw_foundry_agent_chat_client_init_timeout_none_leaves_client_unchanged() -> None: + """Test that timeout=None does not modify the OpenAI client timeout.""" + + mock_project = MagicMock() + openai_client_mock = MagicMock() + openai_client_mock.timeout = 5.0 + mock_project.get_openai_client.return_value = openai_client_mock + + RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + timeout=None, + ) + + assert openai_client_mock.timeout == 5.0 + + +def test_raw_foundry_agent_chat_client_init_applies_timeout_with_preview_enabled() -> None: + """Test that timeout is applied even when allow_preview=True (hosted agent path).""" + + mock_project = MagicMock() + openai_client_mock = MagicMock() + openai_client_mock.timeout = 5.0 + mock_project.get_openai_client.return_value = openai_client_mock + + RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="hosted-agent", + allow_preview=True, + timeout=120.0, + ) + + assert openai_client_mock.timeout == 120.0 + + def test_raw_foundry_agent_chat_client_as_agent_preserves_client_type() -> None: """Test that as_agent() wraps the client in FoundryAgent using the same client class.""" @@ -486,9 +539,27 @@ def test_foundry_agent_chat_client_init_uses_explicit_parameters() -> None: assert "compaction_strategy" in signature.parameters assert "tokenizer" in signature.parameters assert "additional_properties" in signature.parameters + assert "timeout" in signature.parameters assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) +def test_foundry_agent_chat_client_init_propagates_timeout() -> None: + """Test that _FoundryAgentChatClient passes timeout down to the underlying client.""" + + mock_project = MagicMock() + openai_client_mock = MagicMock() + openai_client_mock.timeout = 5.0 + mock_project.get_openai_client.return_value = openai_client_mock + + _FoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + timeout=45.0, + ) + + assert openai_client_mock.timeout == 45.0 + + def test_raw_foundry_agent_init_creates_client() -> None: """Test that RawFoundryAgent creates a client internally.""" @@ -563,6 +634,7 @@ def test_raw_foundry_agent_init_uses_explicit_parameters() -> None: assert "compaction_strategy" in signature.parameters assert "tokenizer" in signature.parameters assert "additional_properties" in signature.parameters + assert "timeout" in signature.parameters assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) @@ -575,9 +647,44 @@ def test_foundry_agent_init_uses_explicit_parameters() -> None: assert "compaction_strategy" in signature.parameters assert "tokenizer" in signature.parameters assert "additional_properties" in signature.parameters + assert "timeout" in signature.parameters assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) +def test_foundry_agent_init_propagates_timeout_to_openai_client() -> None: + """Test that FoundryAgent passes timeout all the way to the underlying AsyncOpenAI client.""" + + mock_project = MagicMock() + openai_client_mock = MagicMock() + openai_client_mock.timeout = 5.0 + mock_project.get_openai_client.return_value = openai_client_mock + + FoundryAgent( + project_client=mock_project, + agent_name="test-agent", + timeout=90.0, + ) + + assert openai_client_mock.timeout == 90.0 + + +def test_foundry_agent_init_timeout_none_leaves_client_default() -> None: + """Test that FoundryAgent with timeout=None leaves the OpenAI client timeout unchanged.""" + + mock_project = MagicMock() + openai_client_mock = MagicMock() + openai_client_mock.timeout = 5.0 + mock_project.get_openai_client.return_value = openai_client_mock + + FoundryAgent( + project_client=mock_project, + agent_name="test-agent", + timeout=None, + ) + + assert openai_client_mock.timeout == 5.0 + + def test_raw_foundry_agent_init_rejects_invalid_client_type() -> None: """Test that invalid client_type raises TypeError.""" diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent_timeout_bug.py b/python/packages/foundry/tests/foundry/test_foundry_agent_timeout_bug.py deleted file mode 100644 index 8fa2d62410..0000000000 --- a/python/packages/foundry/tests/foundry/test_foundry_agent_timeout_bug.py +++ /dev/null @@ -1,213 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Regression tests for #6241: FoundryAgent ConnectTimeout on multi-turn conversations. - -Root cause: RawFoundryAgentChatClient called project_client.get_openai_client() without -applying any timeout, so the openai SDK default of httpx.Timeout(connect=5.0) was used. -Under load or when connections are recycled between turns, the 5s connect timeout fires. - -Fix: expose a ``timeout`` parameter on RawFoundryAgentChatClient, _FoundryAgentChatClient, -RawFoundryAgent, FoundryAgent, and RawOpenAIChatClient that is applied to the underlying -AsyncOpenAI client. -""" - -from __future__ import annotations - -import inspect -from unittest.mock import MagicMock - -import pytest -from agent_framework_openai._chat_client import RawOpenAIChatClient -from openai import AsyncOpenAI - -from agent_framework_foundry._agent import ( - FoundryAgent, - RawFoundryAgent, - RawFoundryAgentChatClient, - _FoundryAgentChatClient, -) - -_FOUNDRY_AGENT_ENV_VARS = ( - "FOUNDRY_PROJECT_ENDPOINT", - "FOUNDRY_AGENT_NAME", - "FOUNDRY_AGENT_VERSION", -) - - -@pytest.fixture(autouse=True) -def clear_foundry_agent_settings_env(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: - """Prevent unit tests from inheriting Foundry agent settings from the shell.""" - - if request.node.get_closest_marker("integration") is not None: - return - - for env_var in _FOUNDRY_AGENT_ENV_VARS: - monkeypatch.delenv(env_var, raising=False) - - -def _make_mock_project() -> MagicMock: - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_openai_client.timeout = 5.0 - mock_project = MagicMock() - mock_project.get_openai_client.return_value = mock_openai_client - return mock_project - - -# --------------------------------------------------------------------------- -# RawFoundryAgentChatClient.timeout -# --------------------------------------------------------------------------- - - -def test_raw_foundry_agent_chat_client_has_timeout_parameter() -> None: - """timeout is an explicit keyword-only parameter on RawFoundryAgentChatClient.""" - - sig = inspect.signature(RawFoundryAgentChatClient.__init__) - assert "timeout" in sig.parameters - assert all(p.kind != inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()) - - -def test_raw_foundry_agent_chat_client_timeout_none_leaves_client_unchanged() -> None: - """When timeout is None, the openai client timeout is not modified.""" - - mock_project = _make_mock_project() - mock_project.get_openai_client.return_value.timeout = 5.0 - - RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - timeout=None, - ) - - assert mock_project.get_openai_client.return_value.timeout == 5.0 - - -def test_raw_foundry_agent_chat_client_timeout_is_applied_to_openai_client() -> None: - """When timeout is specified, it is set on the underlying AsyncOpenAI client.""" - - mock_project = _make_mock_project() - openai_client_mock = mock_project.get_openai_client.return_value - - RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - timeout=60.0, - ) - - assert openai_client_mock.timeout == 60.0 - - -def test_raw_foundry_agent_chat_client_timeout_applied_with_preview_enabled() -> None: - """Timeout is applied even when allow_preview=True (hosted agent path).""" - - mock_project = _make_mock_project() - openai_client_mock = mock_project.get_openai_client.return_value - - RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="hosted-agent", - allow_preview=True, - timeout=120.0, - ) - - assert openai_client_mock.timeout == 120.0 - - -# --------------------------------------------------------------------------- -# _FoundryAgentChatClient.timeout -# --------------------------------------------------------------------------- - - -def test_foundry_agent_chat_client_has_timeout_parameter() -> None: - """timeout is an explicit keyword-only parameter on _FoundryAgentChatClient.""" - - sig = inspect.signature(_FoundryAgentChatClient.__init__) - assert "timeout" in sig.parameters - assert all(p.kind != inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()) - - -def test_foundry_agent_chat_client_timeout_propagated_to_raw_client() -> None: - """_FoundryAgentChatClient passes timeout down to RawFoundryAgentChatClient.""" - - mock_project = _make_mock_project() - openai_client_mock = mock_project.get_openai_client.return_value - - _FoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - timeout=45.0, - ) - - assert openai_client_mock.timeout == 45.0 - - -# --------------------------------------------------------------------------- -# RawFoundryAgent / FoundryAgent.timeout -# --------------------------------------------------------------------------- - - -def test_raw_foundry_agent_has_timeout_parameter() -> None: - """timeout is an explicit keyword-only parameter on RawFoundryAgent.""" - - sig = inspect.signature(RawFoundryAgent.__init__) - assert "timeout" in sig.parameters - - -def test_foundry_agent_has_timeout_parameter() -> None: - """timeout is an explicit keyword-only parameter on FoundryAgent.""" - - sig = inspect.signature(FoundryAgent.__init__) - assert "timeout" in sig.parameters - - -def test_foundry_agent_timeout_propagated_to_openai_client() -> None: - """FoundryAgent passes timeout all the way to the underlying AsyncOpenAI client.""" - - mock_project = _make_mock_project() - openai_client_mock = mock_project.get_openai_client.return_value - - FoundryAgent( - project_client=mock_project, - agent_name="test-agent", - timeout=90.0, - ) - - assert openai_client_mock.timeout == 90.0 - - -def test_foundry_agent_timeout_none_does_not_alter_default() -> None: - """FoundryAgent with timeout=None leaves the openai client timeout at its default.""" - - mock_project = _make_mock_project() - openai_client_mock = mock_project.get_openai_client.return_value - original_timeout = openai_client_mock.timeout - - FoundryAgent( - project_client=mock_project, - agent_name="test-agent", - timeout=None, - ) - - assert openai_client_mock.timeout == original_timeout - - -# --------------------------------------------------------------------------- -# RawOpenAIChatClient.timeout -# --------------------------------------------------------------------------- - - -def test_raw_openai_chat_client_has_timeout_parameter() -> None: - """timeout is an explicit keyword-only parameter on RawOpenAIChatClient.""" - - sig = inspect.signature(RawOpenAIChatClient.__init__) - assert "timeout" in sig.parameters - assert all(p.kind != inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()) - - -def test_raw_openai_chat_client_accepts_timeout_with_preconfigured_client() -> None: - """timeout parameter is accepted without error when async_client is pre-provided.""" - - mock_client = MagicMock(spec=AsyncOpenAI) - mock_client.timeout = 5.0 - - client = RawOpenAIChatClient(async_client=mock_client, timeout=30.0) - assert client is not None diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 261554fba3..23241343f4 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -385,6 +385,7 @@ def __init__( additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, + timeout: float | None = None, ) -> None: """Initialize a raw OpenAI Chat client. @@ -427,6 +428,7 @@ def __init__( additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, + timeout: float | None = None, ) -> None: """Initialize a raw OpenAI Chat client. @@ -476,6 +478,7 @@ def __init__( additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, + timeout: float | None = None, ) -> None: """Initialize a raw OpenAI Chat client. @@ -511,6 +514,8 @@ def __init__( variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` lookups. env_file_encoding: Encoding for the ``.env`` file. + timeout: HTTP timeout in seconds for requests. When not provided, the + OpenAI SDK default is used (connect: 5s, total: 600s). Notes: Environment resolution and routing precedence are: @@ -541,6 +546,7 @@ def __init__( openai_model_fields=("chat_model", "model"), azure_model_fields=("chat_model", "model"), responses_mode=True, + timeout=timeout, ) self.client = client diff --git a/python/packages/openai/agent_framework_openai/_shared.py b/python/packages/openai/agent_framework_openai/_shared.py index 7fb12ad14e..894ee3b612 100644 --- a/python/packages/openai/agent_framework_openai/_shared.py +++ b/python/packages/openai/agent_framework_openai/_shared.py @@ -162,6 +162,7 @@ def load_openai_service_settings( openai_model_fields: Sequence[OpenAIModelSettingName] = ("model",), azure_model_fields: Sequence[OpenAIModelSettingName] = ("model",), responses_mode: bool = False, + timeout: float | None = None, ) -> tuple[dict[str, Any], AsyncOpenAI, bool]: """Load OpenAI settings, including Azure OpenAI model aliases. @@ -218,6 +219,8 @@ def load_openai_service_settings( } if base_url := openai_settings.get("base_url"): client_args["base_url"] = base_url + if timeout is not None: + client_args["timeout"] = timeout return openai_settings, AsyncOpenAI(**client_args), False # type: ignore[return-value] checked_openai = True azure_settings = load_settings( @@ -299,8 +302,12 @@ def load_openai_service_settings( openai_args["api_key"] = _ensure_async_token_provider(client_args["azure_ad_token_provider"]) elif "api_key" in client_args: openai_args["api_key"] = client_args["api_key"] + if timeout is not None: + openai_args["timeout"] = timeout return azure_settings, AsyncOpenAI(**openai_args), True # type: ignore[return-value] + if timeout is not None: + client_args["timeout"] = timeout return azure_settings, AsyncAzureOpenAI(**client_args), True # type: ignore[return-value] diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index e604742e7e..099796574d 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -36,7 +36,7 @@ ChatClientInvalidRequestException, SettingNotFoundError, ) -from openai import BadRequestError +from openai import AsyncOpenAI, BadRequestError from openai.types.responses.response_reasoning_item import Summary from openai.types.responses.response_reasoning_summary_text_delta_event import ( ResponseReasoningSummaryTextDeltaEvent, @@ -55,7 +55,7 @@ from pytest import param from agent_framework_openai import OpenAIChatClient -from agent_framework_openai._chat_client import OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY +from agent_framework_openai._chat_client import OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY, RawOpenAIChatClient from agent_framework_openai._exceptions import OpenAIContentFilterException skip_if_openai_integration_tests_disabled = pytest.mark.skipif( @@ -194,6 +194,26 @@ def test_init_uses_explicit_parameters() -> None: assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) +def test_raw_openai_chat_client_init_uses_explicit_parameters() -> None: + signature = inspect.signature(RawOpenAIChatClient.__init__) + + assert "additional_properties" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert "timeout" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + +def test_raw_openai_chat_client_accepts_preconfigured_client_with_timeout() -> None: + """Test that timeout is accepted without error when async_client is pre-provided.""" + + mock_client = MagicMock(spec=AsyncOpenAI) + mock_client.timeout = 5.0 + + client = RawOpenAIChatClient(async_client=mock_client, timeout=30.0) + assert client is not None + + def test_openai_chat_client_supports_all_tool_protocols() -> None: assert isinstance(OpenAIChatClient, SupportsCodeInterpreterTool) assert isinstance(OpenAIChatClient, SupportsWebSearchTool) From 5e72b4726708d0da8aca10559c7f8bb7cbab70e8 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 2 Jun 2026 10:08:19 +0000 Subject: [PATCH 3/5] Python: Add `timeout` parameter to `FoundryAgent` to fix `ConnectTimeout` on multi-turn conversations Fixes #6241 --- .../bedrock/agent_framework_bedrock/_chat_client.py | 13 +++---------- .../bedrock/tests/test_bedrock_structured_output.py | 1 + .../foundry_hosting/tests/test_responses.py | 8 ++------ .../openai/agent_framework_openai/_chat_client.py | 2 ++ 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py index cb8545f9a3..2fd7887721 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py @@ -795,10 +795,7 @@ def _prepare_output_config(self, response_format: Any | None) -> dict[str, Any] schema = copy.deepcopy(schema_src) else: if not isinstance(response_format, type) or not issubclass(response_format, BaseModel): - raise TypeError( - "response_format must be None, a dict JSON schema, " - "or a Pydantic BaseModel subclass." - ) + raise TypeError("response_format must be None, a dict JSON schema, or a Pydantic BaseModel subclass.") # response_format is a Pydantic model class schema = response_format.model_json_schema() name = response_format.__name__ @@ -817,9 +814,7 @@ def _prepare_output_config(self, response_format: Any | None) -> dict[str, Any] return { "textFormat": { "type": "json_schema", - "structure": { - "jsonSchema": json_schema - }, + "structure": {"jsonSchema": json_schema}, } } @@ -840,9 +835,7 @@ def walk(node: Any) -> None: if node_id in visited: return visited.add(node_id) - if node.get("type") == "object" or ( - "properties" in node and "type" not in node - ): + if node.get("type") == "object" or ("properties" in node and "type" not in node): existing = node.get("additionalProperties") if existing is None or existing is True: node["additionalProperties"] = False diff --git a/python/packages/bedrock/tests/test_bedrock_structured_output.py b/python/packages/bedrock/tests/test_bedrock_structured_output.py index 8df04b5e75..7b39f67d69 100644 --- a/python/packages/bedrock/tests/test_bedrock_structured_output.py +++ b/python/packages/bedrock/tests/test_bedrock_structured_output.py @@ -238,6 +238,7 @@ async def test_chat_response_value_populated_streaming() -> None: async def test_unsupported_model_validation_exception() -> None: """When a model doesn't support outputConfig, a clear error should be raised.""" + class _FailingStubBedrockRuntime: def converse(self, **kwargs: Any) -> dict[str, Any]: # Simulate botocore ClientError for ValidationException diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 0bfff345a7..9c65a9ea42 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -2118,15 +2118,11 @@ async def test_hosted_mcp_call_round_trip_does_not_orphan_function_call_output(s assert resp2.json()["status"] == "completed" second_call_messages = agent.run.call_args_list[1].kwargs["messages"] - mcp_call_contents = [ - c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_call" - ] + mcp_call_contents = [c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_call"] mcp_result_contents = [ c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_result" ] - function_result_contents = [ - c for m in second_call_messages for c in m.contents if c.type == "function_result" - ] + function_result_contents = [c for m in second_call_messages for c in m.contents if c.type == "function_result"] assert len(mcp_call_contents) >= 1 assert len(mcp_result_contents) >= 1 diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 23241343f4..378df3b3c4 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -407,6 +407,7 @@ def __init__( env_file_path: Optional ``.env`` file that is checked before the process environment for ``OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. + timeout: Optional timeout in seconds for requests. """ ... @@ -457,6 +458,7 @@ def __init__( env_file_path: Optional ``.env`` file that is checked before process environment variables for ``AZURE_OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. + timeout: Optional timeout in seconds for requests. """ ... From 817e3db80457f04a5a6bac5370d5ab8aca4eb801 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 2 Jun 2026 10:13:50 +0000 Subject: [PATCH 4/5] fix(foundry): use with_options to avoid mutating shared OpenAI client timeout (#6241) Replace direct assignment with in RawFoundryAgentChatClient.__init__. The Azure AI Projects SDK caches and returns a shared AsyncOpenAI client per AIProjectClient. Mutating its .timeout attribute leaked the override to all other code paths sharing that client (other agents, user code). with_options() returns a new client instance with the override applied, leaving the original shared client untouched. Update tests to assert with_options is called with the correct timeout and that the original shared client's timeout attribute is not mutated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../foundry/agent_framework_foundry/_agent.py | 2 +- .../tests/foundry/test_foundry_agent.py | 26 ++++++++++++------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 3f926dfd54..01005ca79c 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -265,7 +265,7 @@ def __init__( openai_client_kwargs["agent_name"] = self.agent_name openai_client = self.project_client.get_openai_client(**openai_client_kwargs) if timeout is not None: - openai_client.timeout = timeout + openai_client = openai_client.with_options(timeout=timeout) super().__init__( async_client=openai_client, default_headers=default_headers, diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 74e6a168bf..4edf7f1149 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -114,7 +114,7 @@ def test_raw_foundry_agent_chat_client_init_uses_explicit_parameters() -> None: def test_raw_foundry_agent_chat_client_init_applies_timeout_to_openai_client() -> None: - """Test that timeout is applied to the underlying OpenAI client when specified.""" + """Test that timeout is applied via with_options without mutating the shared OpenAI client.""" mock_project = MagicMock() openai_client_mock = MagicMock() @@ -127,11 +127,12 @@ def test_raw_foundry_agent_chat_client_init_applies_timeout_to_openai_client() - timeout=60.0, ) - assert openai_client_mock.timeout == 60.0 + openai_client_mock.with_options.assert_called_once_with(timeout=60.0) + assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated" def test_raw_foundry_agent_chat_client_init_timeout_none_leaves_client_unchanged() -> None: - """Test that timeout=None does not modify the OpenAI client timeout.""" + """Test that timeout=None does not call with_options and leaves the shared client intact.""" mock_project = MagicMock() openai_client_mock = MagicMock() @@ -144,11 +145,12 @@ def test_raw_foundry_agent_chat_client_init_timeout_none_leaves_client_unchanged timeout=None, ) + openai_client_mock.with_options.assert_not_called() assert openai_client_mock.timeout == 5.0 def test_raw_foundry_agent_chat_client_init_applies_timeout_with_preview_enabled() -> None: - """Test that timeout is applied even when allow_preview=True (hosted agent path).""" + """Test that timeout uses with_options even when allow_preview=True (hosted agent path).""" mock_project = MagicMock() openai_client_mock = MagicMock() @@ -162,7 +164,8 @@ def test_raw_foundry_agent_chat_client_init_applies_timeout_with_preview_enabled timeout=120.0, ) - assert openai_client_mock.timeout == 120.0 + openai_client_mock.with_options.assert_called_once_with(timeout=120.0) + assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated" def test_raw_foundry_agent_chat_client_as_agent_preserves_client_type() -> None: @@ -544,7 +547,7 @@ def test_foundry_agent_chat_client_init_uses_explicit_parameters() -> None: def test_foundry_agent_chat_client_init_propagates_timeout() -> None: - """Test that _FoundryAgentChatClient passes timeout down to the underlying client.""" + """Test that _FoundryAgentChatClient calls with_options instead of mutating the shared client.""" mock_project = MagicMock() openai_client_mock = MagicMock() @@ -557,7 +560,8 @@ def test_foundry_agent_chat_client_init_propagates_timeout() -> None: timeout=45.0, ) - assert openai_client_mock.timeout == 45.0 + openai_client_mock.with_options.assert_called_once_with(timeout=45.0) + assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated" def test_raw_foundry_agent_init_creates_client() -> None: @@ -652,7 +656,7 @@ def test_foundry_agent_init_uses_explicit_parameters() -> None: def test_foundry_agent_init_propagates_timeout_to_openai_client() -> None: - """Test that FoundryAgent passes timeout all the way to the underlying AsyncOpenAI client.""" + """Test that FoundryAgent uses with_options instead of mutating the shared OpenAI client.""" mock_project = MagicMock() openai_client_mock = MagicMock() @@ -665,11 +669,12 @@ def test_foundry_agent_init_propagates_timeout_to_openai_client() -> None: timeout=90.0, ) - assert openai_client_mock.timeout == 90.0 + openai_client_mock.with_options.assert_called_once_with(timeout=90.0) + assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated" def test_foundry_agent_init_timeout_none_leaves_client_default() -> None: - """Test that FoundryAgent with timeout=None leaves the OpenAI client timeout unchanged.""" + """Test that FoundryAgent with timeout=None does not call with_options or mutate the client.""" mock_project = MagicMock() openai_client_mock = MagicMock() @@ -682,6 +687,7 @@ def test_foundry_agent_init_timeout_none_leaves_client_default() -> None: timeout=None, ) + openai_client_mock.with_options.assert_not_called() assert openai_client_mock.timeout == 5.0 From 79a4e3961f3a04594b0f2088e4e6f82808628778 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 2 Jun 2026 10:42:02 +0000 Subject: [PATCH 5/5] test(foundry): assert with_options return value flows to instance.client (#6241) The four timeout propagation tests verified that with_options was called but did not confirm that the returned (timeout-configured) client was actually stored on the instance. A silent discard of the return value would have left the tests green while the timeout had no effect. Each test now captures the constructed instance and asserts: assert .client is openai_client_mock.with_options.return_value Affected tests: - test_raw_foundry_agent_chat_client_init_applies_timeout_to_openai_client - test_raw_foundry_agent_chat_client_init_applies_timeout_with_preview_enabled - test_foundry_agent_chat_client_init_propagates_timeout - test_foundry_agent_init_propagates_timeout_to_openai_client Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../foundry/tests/foundry/test_foundry_agent.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 4edf7f1149..a9da5f4011 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -121,7 +121,7 @@ def test_raw_foundry_agent_chat_client_init_applies_timeout_to_openai_client() - openai_client_mock.timeout = 5.0 mock_project.get_openai_client.return_value = openai_client_mock - RawFoundryAgentChatClient( + client = RawFoundryAgentChatClient( project_client=mock_project, agent_name="test-agent", timeout=60.0, @@ -129,6 +129,7 @@ def test_raw_foundry_agent_chat_client_init_applies_timeout_to_openai_client() - openai_client_mock.with_options.assert_called_once_with(timeout=60.0) assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated" + assert client.client is openai_client_mock.with_options.return_value def test_raw_foundry_agent_chat_client_init_timeout_none_leaves_client_unchanged() -> None: @@ -157,7 +158,7 @@ def test_raw_foundry_agent_chat_client_init_applies_timeout_with_preview_enabled openai_client_mock.timeout = 5.0 mock_project.get_openai_client.return_value = openai_client_mock - RawFoundryAgentChatClient( + client = RawFoundryAgentChatClient( project_client=mock_project, agent_name="hosted-agent", allow_preview=True, @@ -166,6 +167,7 @@ def test_raw_foundry_agent_chat_client_init_applies_timeout_with_preview_enabled openai_client_mock.with_options.assert_called_once_with(timeout=120.0) assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated" + assert client.client is openai_client_mock.with_options.return_value def test_raw_foundry_agent_chat_client_as_agent_preserves_client_type() -> None: @@ -554,7 +556,7 @@ def test_foundry_agent_chat_client_init_propagates_timeout() -> None: openai_client_mock.timeout = 5.0 mock_project.get_openai_client.return_value = openai_client_mock - _FoundryAgentChatClient( + client = _FoundryAgentChatClient( project_client=mock_project, agent_name="test-agent", timeout=45.0, @@ -562,6 +564,7 @@ def test_foundry_agent_chat_client_init_propagates_timeout() -> None: openai_client_mock.with_options.assert_called_once_with(timeout=45.0) assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated" + assert client.client is openai_client_mock.with_options.return_value def test_raw_foundry_agent_init_creates_client() -> None: @@ -663,7 +666,7 @@ def test_foundry_agent_init_propagates_timeout_to_openai_client() -> None: openai_client_mock.timeout = 5.0 mock_project.get_openai_client.return_value = openai_client_mock - FoundryAgent( + agent = FoundryAgent( project_client=mock_project, agent_name="test-agent", timeout=90.0, @@ -671,6 +674,7 @@ def test_foundry_agent_init_propagates_timeout_to_openai_client() -> None: openai_client_mock.with_options.assert_called_once_with(timeout=90.0) assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated" + assert agent.client.client is openai_client_mock.with_options.return_value def test_foundry_agent_init_timeout_none_leaves_client_default() -> None: