Python: update FoundryAgent for hosted agent sessions (#5447)

* fixes to FoundryAgent to connect to new hosted agents

Co-authored-by: Copilot <copilot@github.com>

* fix mypy

Co-authored-by: Copilot <copilot@github.com>

* Python: remove Foundry service session helpers

Remove the public hosted-agent service session CRUD helpers from FoundryAgent and drop the related feature-stage inventory entry.

Update the hosted-agent sample to create and delete service sessions directly through the preview AIProjectClient APIs, and tighten a few test harnesses surfaced by full workspace validation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix from merge

* fix hosted env detection

Co-authored-by: Copilot <copilot@github.com>

* reverted sample update

* fix tests and code

Co-authored-by: Copilot <copilot@github.com>

* remove aenter

* skipping some tests

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-04-24 11:25:03 +02:00
committed by GitHub
Unverified
parent 63c0a51797
commit 62e02da698
15 changed files with 601 additions and 180 deletions
@@ -2,6 +2,7 @@
from __future__ import annotations
import contextlib
import logging
import os
from typing import Any, Final
@@ -60,13 +61,12 @@ def _detect_hosted_environment() -> None:
global _hosted_env_detected
if _hosted_env_detected:
return
_hosted_env_detected = True
env_value = os.environ.get(_FOUNDRY_HOSTING_ENV_VAR)
if env_value is not None:
if (env_value := os.environ.get(_FOUNDRY_HOSTING_ENV_VAR)) is not None:
# Env var exists — trust its value and skip the fallback.
if env_value:
_add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX)
_hosted_env_detected = True
return
# Env var not set — fall back to AgentConfig as a second layer of defense.
@@ -78,13 +78,12 @@ def _detect_hosted_environment() -> None:
return
except (ModuleNotFoundError, ValueError):
return
try:
with contextlib.suppress(ImportError, AttributeError):
from azure.ai.agentserver.core import AgentConfig # pyright: ignore[reportMissingImports]
if AgentConfig.from_env().is_hosted:
_add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX)
except (ImportError, AttributeError):
pass
_hosted_env_detected = True
def get_user_agent() -> str:
@@ -14,6 +14,7 @@ from typing import Any
_IMPORTS: dict[str, tuple[str, str]] = {
"AnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
"FoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryAgentOptions": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryChatOptions": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"),
@@ -655,7 +655,13 @@ async def test_devui_streaming_renderer_memory_is_bounded(
)
try:
websocket_url = await _get_devtools_websocket_url(debug_port)
try:
websocket_url = await _get_devtools_websocket_url(debug_port)
except RuntimeError as exc:
return_code = browser_process.poll()
if return_code is not None:
pytest.skip(f"Chromium exited before DevTools became available (code {return_code}).")
pytest.skip(str(exc))
async with websocket_connect(websocket_url, max_size=None) as websocket:
client = _CDPClient(websocket)
@@ -2,7 +2,7 @@
import importlib.metadata
from ._agent import FoundryAgent, RawFoundryAgent, RawFoundryAgentChatClient
from ._agent import FoundryAgent, FoundryAgentOptions, RawFoundryAgent, RawFoundryAgentChatClient
from ._chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient
from ._embedding_client import (
FoundryEmbeddingClient,
@@ -25,6 +25,7 @@ except importlib.metadata.PackageNotFoundError:
__all__ = [
"FoundryAgent",
"FoundryAgentOptions",
"FoundryChatClient",
"FoundryChatOptions",
"FoundryEmbeddingClient",
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast
from agent_framework import (
AgentMiddlewareLayer,
AgentSession,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
ContextProvider,
@@ -52,11 +53,13 @@ else:
if TYPE_CHECKING:
from agent_framework import (
Agent,
AgentRunInputs,
ChatAndFunctionMiddlewareTypes,
ContextProvider,
MiddlewareTypes,
ToolTypes,
)
from agent_framework._agents import _RunContext # pyright: ignore[reportPrivateUsage]
logger: logging.Logger = logging.getLogger("agent_framework.foundry")
@@ -81,14 +84,54 @@ class FoundryAgentSettings(TypedDict, total=False):
agent_version: str | None
class FoundryAgentOptions(OpenAIChatOptions, total=False):
"""Microsoft Foundry agent-specific chat options.
Extends ``OpenAIChatOptions`` with hosted-agent session configuration used by
``FoundryAgent`` / ``RawFoundryAgent``.
Keyword Args:
extra_body: Additional request body values sent to the Responses API.
isolation_key: Isolation key used when lazily creating a hosted-agent
session through ``project_client.beta.agents.create_session(...)``.
"""
extra_body: dict[str, Any]
isolation_key: str
FoundryAgentOptionsT = TypeVar(
"FoundryAgentOptionsT",
bound=TypedDict, # type: ignore[valid-type]
default="OpenAIChatOptions",
default="FoundryAgentOptions",
covariant=True,
)
def _merge_extra_body(extra_body: Any | None, *, additions: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""Normalize and merge provider-specific extra_body values."""
if extra_body is None:
merged: dict[str, Any] = {}
elif isinstance(extra_body, Mapping):
merged = dict(cast(Mapping[str, Any], extra_body))
else:
raise TypeError(f"extra_body must be a mapping when provided, got {type(extra_body).__name__}.")
if additions:
merged.update(additions)
return merged
def _uses_foundry_agent_session(conversation_id: Any) -> bool:
"""Return whether a conversation_id should be treated as a Foundry agent session id."""
return (
isinstance(conversation_id, str)
and bool(conversation_id)
and not conversation_id.startswith("resp_")
and not conversation_id.startswith("conv_")
)
class RawFoundryAgentChatClient( # type: ignore[misc]
RawOpenAIChatClient[FoundryAgentOptionsT],
Generic[FoundryAgentOptionsT],
@@ -167,13 +210,15 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
)
resolved_endpoint = settings.get("project_endpoint")
self.agent_name = settings.get("agent_name")
self.agent_version = settings.get("agent_version")
agent_name_setting = settings.get("agent_name")
self.agent_version: str | None = settings.get("agent_version")
self.allow_preview = allow_preview or False
if not self.agent_name:
if not agent_name_setting:
raise ValueError(
"Agent name is required. Set via 'agent_name' parameter or 'FOUNDRY_AGENT_NAME' environment variable."
)
self.agent_name = agent_name_setting
# Create or use provided project client
self._should_close_client = False
@@ -197,11 +242,13 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
self.project_client = AIProjectClient(**project_client_kwargs)
self._should_close_client = True
# Get OpenAI client from project
async_client = self.project_client.get_openai_client()
openai_client_kwargs: dict[str, Any] = {}
if default_headers:
openai_client_kwargs["default_headers"] = dict(default_headers)
if allow_preview:
openai_client_kwargs["agent_name"] = self.agent_name
super().__init__(
async_client=async_client,
async_client=self.project_client.get_openai_client(**openai_client_kwargs),
default_headers=default_headers,
instruction_role=instruction_role,
compaction_strategy=compaction_strategy,
@@ -209,13 +256,6 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
additional_properties=additional_properties,
)
def _get_agent_reference(self) -> dict[str, str]:
"""Build the agent reference dict for the Responses API."""
ref: dict[str, str] = {"name": self.agent_name, "type": "agent_reference"} # type: ignore[dict-item]
if self.agent_version:
ref["version"] = self.agent_version
return ref
@override
def as_agent(
self,
@@ -270,7 +310,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
options: Mapping[str, Any],
**kwargs: Any,
) -> dict[str, Any]:
"""Prepare options for the Responses API, injecting agent reference and validating tools."""
"""Prepare options for the Responses API and validate client-side tools."""
# Validate tools — only FunctionTool allowed
tools = options.get("tools", [])
if tools:
@@ -292,18 +332,58 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
if "input" in run_options and isinstance(run_options["input"], list):
run_options["input"] = self._transform_input_for_azure_ai(cast(list[dict[str, Any]], run_options["input"]))
# Inject agent reference
run_options["extra_body"] = {"agent_reference": self._get_agent_reference()}
# Merge caller-supplied extra_body with any agent-specific request payload.
conversation_id = options.get("conversation_id")
extra_body = _merge_extra_body(run_options.pop("extra_body", None))
if _uses_foundry_agent_session(conversation_id):
run_options.pop("previous_response_id", None)
run_options.pop("conversation", None)
extra_body["agent_session_id"] = conversation_id
if extra_body:
run_options["extra_body"] = extra_body
run_options.pop("isolation_key", None)
# Strip tools from request body - Foundry API rejects requests with both
# agent_reference and tools present. FunctionTools are invoked client-side
# agent endpoint and tools present. FunctionTools are invoked client-side
# by the function invocation layer, not sent to the service.
run_options.pop("tools", None)
run_options.pop("tool_choice", None)
run_options.pop("parallel_tool_calls", None)
run_options.pop("model", None)
if not self.allow_preview:
run_options.pop("tools", None)
run_options.pop("tool_choice", None)
run_options.pop("parallel_tool_calls", None)
return run_options
@override
def _parse_response_from_openai(
self,
response: Any,
options: dict[str, Any],
) -> Any:
parsed_response = super()._parse_response_from_openai(response, options)
if _uses_foundry_agent_session(options.get("conversation_id")):
parsed_response.conversation_id = None
return parsed_response
@override
def _parse_chunk_from_openai(
self,
event: Any,
options: dict[str, Any],
function_call_ids: dict[int, tuple[str, str]],
seen_reasoning_delta_item_ids: set[str] | None = None,
) -> Any:
parsed_chunk = super()._parse_chunk_from_openai(
event,
options,
function_call_ids,
seen_reasoning_delta_item_ids,
)
if _uses_foundry_agent_session(options.get("conversation_id")):
parsed_chunk.conversation_id = None
return parsed_chunk
@override
def _check_model_presence(self, options: dict[str, Any]) -> None:
"""Skip model check — model is configured on the Foundry agent."""
@@ -368,6 +448,26 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
return transformed
async def get_agent_version(self) -> str | None:
"""Return the agent version if available, else None."""
if self.agent_version is not None:
return self.agent_version
if not self.allow_preview:
return None
agent_details = await cast(Any, self.project_client.beta.agents).get( # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
agent_name=self.agent_name
)
versions_object = getattr(agent_details, "versions", None)
if not isinstance(versions_object, Mapping):
raise TypeError("Foundry agent details did not include a versions mapping.")
versions = cast(Mapping[str, Any], versions_object)
latest_version = versions.get("latest")
agent_version = getattr(cast(Any, latest_version), "version", None)
if not isinstance(agent_version, str):
raise TypeError("Foundry agent details did not include a latest version string.")
self.agent_version = agent_version
return agent_version
async def close(self) -> None:
"""Close the project client if we created it."""
if self._should_close_client:
@@ -395,7 +495,7 @@ class _FoundryAgentChatClient( # type: ignore[misc]
client = FoundryAgentClient(
project_endpoint="https://your-project.services.ai.azure.com",
agent_name="my-prompt-agent",
agent_version="1.0",
agent_version="1",
credential=AzureCliCredential(),
)
@@ -477,7 +577,7 @@ class RawFoundryAgent( # type: ignore[misc]
agent = RawFoundryAgent(
project_endpoint="https://your-project.services.ai.azure.com",
agent_name="my-prompt-agent",
agent_version="1.0",
agent_version="1",
credential=AzureCliCredential(),
)
result = await agent.run("Hello!")
@@ -570,7 +670,7 @@ class RawFoundryAgent( # type: ignore[misc]
client=client, # type: ignore[arg-type]
instructions=instructions,
id=id,
name=name,
name=name or agent_name,
description=description,
tools=tools, # type: ignore[arg-type]
default_options=cast(FoundryAgentOptionsT | None, default_options),
@@ -582,6 +682,81 @@ class RawFoundryAgent( # type: ignore[misc]
additional_properties=dict(additional_properties) if additional_properties is not None else None,
)
def _resolve_service_session_isolation_key(self, isolation_key: str | None = None) -> str:
"""Resolve the isolation key from an explicit value or default_options."""
resolved_isolation_key = (
isolation_key if isolation_key is not None else self.default_options.get("isolation_key")
)
if resolved_isolation_key is None:
raise ValueError("isolation_key is required. Pass it explicitly or set default_options['isolation_key'].")
return resolved_isolation_key
async def _create_service_session_id(
self,
*,
isolation_key: str | None = None,
) -> str:
"""Create a hosted Foundry service session and return the service session ID."""
if not isinstance(self.client, RawFoundryAgentChatClient):
raise TypeError("_create_service_session_id requires a RawFoundryAgentChatClient-based client.")
if not self.client.allow_preview:
raise RuntimeError("Hosted Foundry service sessions require allow_preview=True.")
create_session_kwargs: dict[str, Any] = {
"agent_name": self.client.agent_name,
"isolation_key": self._resolve_service_session_isolation_key(isolation_key),
}
if version := await self.client.get_agent_version():
from azure.ai.projects.models import VersionRefIndicator
create_session_kwargs["version_indicator"] = VersionRefIndicator(agent_version=version) # type: ignore
service_session = await self.client.project_client.beta.agents.create_session(**create_session_kwargs)
agent_session_id = getattr(service_session, "agent_session_id", None)
if not isinstance(agent_session_id, str) or not agent_session_id:
raise ValueError("Hosted Foundry session creation did not return a non-empty agent_session_id.")
return agent_session_id
@override
async def _prepare_run_context(
self,
*,
messages: AgentRunInputs | None,
session: AgentSession | None,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
options: Mapping[str, Any] | None,
compaction_strategy: CompactionStrategy | None,
tokenizer: TokenizerProtocol | None,
function_invocation_kwargs: Mapping[str, Any] | None,
client_kwargs: Mapping[str, Any] | None,
) -> _RunContext:
runtime_options = dict(options) if options else {}
effective_options = {
**{key: value for key, value in self.default_options.items() if value is not None},
**{key: value for key, value in runtime_options.items() if value is not None},
}
if (
session is not None
and session.service_session_id is None
and effective_options.get("isolation_key") is not None
):
session.service_session_id = await self._create_service_session_id(
isolation_key=cast(str | None, effective_options.get("isolation_key")),
)
return await super()._prepare_run_context(
messages=messages,
session=session,
tools=tools,
options=runtime_options,
compaction_strategy=compaction_strategy,
tokenizer=tokenizer,
function_invocation_kwargs=function_invocation_kwargs,
client_kwargs=client_kwargs,
)
async def configure_azure_monitor(
self,
enable_sensitive_data: bool = False,
@@ -708,6 +883,19 @@ class FoundryAgent( # type: ignore[misc]
) -> None:
"""Initialize a Foundry Agent with full middleware and telemetry.
``FoundryAgent`` supports both PromptAgents and HostedAgents. PromptAgents
typically provide ``agent_version`` directly. HostedAgents can omit
``agent_version`` and, when they need preview-only session APIs, should
opt in with ``allow_preview=True`` when this class creates the underlying
``AIProjectClient``. If you pass ``project_client`` explicitly, it must
already be configured for preview APIs before being passed to
``FoundryAgent``.
To lazily create HostedAgent service sessions inside the agent, pass an
``isolation_key`` through ``default_options`` (or per-run options). The
agent stores the resulting HostedAgent session ID in
``AgentSession.service_session_id`` and reuses it on subsequent runs.
Keyword Args:
project_endpoint: The Foundry project endpoint URL.
agent_name: The name of the Foundry agent to connect to.
@@ -715,6 +903,9 @@ class FoundryAgent( # type: ignore[misc]
credential: Azure credential for authentication.
project_client: An existing AIProjectClient to use.
allow_preview: Enables preview opt-in on internally-created AIProjectClient.
Set this to ``True`` for HostedAgents that need preview-only
session APIs, including lazy service session creation from
``isolation_key``.
tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted.
context_providers: Optional context providers.
middleware: Optional agent-level middleware.
@@ -726,6 +917,8 @@ class FoundryAgent( # type: ignore[misc]
description: Optional local description for the local agent wrapper.
instructions: Optional instructions for the local agent wrapper.
default_options: Default chat options for the local agent wrapper.
``FoundryAgentOptions`` can include ``isolation_key`` and
``extra_body`` when working with HostedAgents.
require_per_service_call_history_persistence: Whether to require per-service-call
chat history persistence when using local history providers.
function_invocation_configuration: Optional function invocation configuration override.
@@ -204,9 +204,13 @@ class RawFoundryChatClient( # type: ignore[misc]
project_client_kwargs["allow_preview"] = allow_preview
project_client = AIProjectClient(**project_client_kwargs)
openai_kwargs: dict[str, Any] = {}
if default_headers:
openai_kwargs["default_headers"] = default_headers
super().__init__(
model=resolved_model,
async_client=project_client.get_openai_client(),
async_client=project_client.get_openai_client(**openai_kwargs),
default_headers=default_headers,
instruction_role=instruction_role,
compaction_strategy=compaction_strategy,
@@ -5,11 +5,12 @@ from __future__ import annotations
import inspect
import os
import sys
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import AgentResponse, ChatContext, ChatMiddleware, Message, tool
from agent_framework import AgentResponse, AgentSession, ChatContext, ChatMiddleware, ChatResponse, Message, tool
from azure.core.exceptions import ResourceNotFoundError
from azure.identity import AzureCliCredential
@@ -54,7 +55,7 @@ def test_raw_foundry_agent_chat_client_init_requires_agent_name() -> None:
def test_raw_foundry_agent_chat_client_init_with_agent_name() -> None:
"""Test construction with agent_name and project_client."""
"""Test construction with agent_name and project_client without preview agent binding."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
@@ -67,6 +68,27 @@ def test_raw_foundry_agent_chat_client_init_with_agent_name() -> None:
assert client.agent_name == "test-agent"
assert client.agent_version == "1.0"
mock_project.get_openai_client.assert_called_once_with()
def test_raw_foundry_agent_chat_client_init_passes_agent_name_when_preview_enabled() -> None:
"""Test preview-enabled clients bind the OpenAI client to the agent endpoint."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="hosted-agent",
allow_preview=True,
default_headers={"x-test": "1"},
)
assert client.agent_name == "hosted-agent"
mock_project.get_openai_client.assert_called_once_with(
agent_name="hosted-agent",
default_headers={"x-test": "1"},
)
def test_raw_foundry_agent_chat_client_init_uses_explicit_parameters() -> None:
@@ -80,38 +102,6 @@ def test_raw_foundry_agent_chat_client_init_uses_explicit_parameters() -> None:
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
def test_raw_foundry_agent_chat_client_get_agent_reference_with_version() -> None:
"""Test agent reference includes version when provided."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="my-agent",
agent_version="2.0",
)
ref = client._get_agent_reference()
assert ref == {"name": "my-agent", "version": "2.0", "type": "agent_reference"}
def test_raw_foundry_agent_chat_client_get_agent_reference_without_version() -> None:
"""Test agent reference omits version for HostedAgents."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="hosted-agent",
)
ref = client._get_agent_reference()
assert ref == {"name": "hosted-agent", "type": "agent_reference"}
assert "version" not in ref
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."""
@@ -196,12 +186,11 @@ async def test_raw_foundry_agent_chat_client_prepare_options_accepts_function_to
options={"tools": [my_func]},
)
assert "extra_body" in result
assert result["extra_body"]["agent_reference"]["name"] == "test-agent"
assert result == {}
async def test_raw_foundry_agent_chat_client_prepare_options_strips_tools() -> None:
"""Test that _prepare_options strips tools, tool_choice, and parallel_tool_calls from run_options."""
async def test_raw_foundry_agent_chat_client_prepare_options_strips_client_side_fields() -> None:
"""Test that _prepare_options strips model and tool-loop fields from run_options."""
mock_project = MagicMock()
mock_openai = MagicMock()
@@ -222,6 +211,7 @@ async def test_raw_foundry_agent_chat_client_prepare_options_strips_tools() -> N
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
new_callable=AsyncMock,
return_value={
"model": "gpt-4.1",
"tools": [{"type": "function", "function": {"name": "my_func"}}],
"tool_choice": "auto",
"parallel_tool_calls": True,
@@ -232,11 +222,69 @@ async def test_raw_foundry_agent_chat_client_prepare_options_strips_tools() -> N
options={"tools": [my_func]},
)
assert "model" not in result
assert "tools" not in result
assert "tool_choice" not in result
assert "parallel_tool_calls" not in result
assert "extra_body" in result
assert result["extra_body"]["agent_reference"]["name"] == "test-agent"
assert result == {}
async def test_raw_foundry_agent_chat_client_prepare_options_maps_agent_session_id_to_extra_body() -> None:
"""Test that service_session_id is forwarded as agent_session_id for hosted sessions."""
mock_project = MagicMock()
mock_openai = MagicMock()
mock_project.get_openai_client.return_value = mock_openai
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="test-agent",
)
with patch(
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
new_callable=AsyncMock,
return_value={
"extra_body": {"custom": "value"},
"previous_response_id": "should-be-removed",
},
):
result = await client._prepare_options(
messages=[Message(role="user", contents="hi")],
options={"conversation_id": "agent-session-123", "isolation_key": "iso-key"},
)
assert result["extra_body"] == {
"custom": "value",
"agent_session_id": "agent-session-123",
}
assert "previous_response_id" not in result
assert "conversation" not in result
assert "isolation_key" not in result
def test_raw_foundry_agent_chat_client_parse_response_suppresses_conversation_id_for_agent_sessions() -> None:
"""Test that agent-session continuations do not overwrite session.service_session_id."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="test-agent",
)
parsed = ChatResponse(conversation_id="resp_123")
with patch(
"agent_framework_openai._chat_client.RawOpenAIChatClient._parse_response_from_openai",
return_value=parsed,
):
result = client._parse_response_from_openai(
response=MagicMock(),
options={"conversation_id": "agent-session-123"},
)
assert result.conversation_id is None
def test_raw_foundry_agent_chat_client_check_model_presence_is_noop() -> None:
@@ -366,6 +414,74 @@ def test_raw_foundry_agent_init_with_function_tools() -> None:
assert agent.default_options.get("tools") is not None
async def test_raw_foundry_agent_prepare_run_context_creates_service_session_from_isolation_key() -> None:
"""Test that RawFoundryAgent lazily creates a hosted session and stores it on service_session_id."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
mock_project.beta = SimpleNamespace(
agents=SimpleNamespace(
create_session=AsyncMock(return_value=SimpleNamespace(agent_session_id="agent-session-123"))
)
)
agent = RawFoundryAgent(
project_client=mock_project,
agent_name="test-agent",
agent_version="1.0",
allow_preview=True,
)
session = AgentSession()
with patch(
"agent_framework._agents.RawAgent._prepare_run_context",
new=AsyncMock(return_value={"ok": True}),
) as mock_prepare_run_context:
result = await agent._prepare_run_context(
messages="hi",
session=session,
tools=None,
options={"isolation_key": "iso-key"},
compaction_strategy=None,
tokenizer=None,
function_invocation_kwargs=None,
client_kwargs=None,
)
assert result == {"ok": True}
assert session.service_session_id == "agent-session-123"
mock_project.beta.agents.create_session.assert_awaited_once()
create_session_kwargs = mock_project.beta.agents.create_session.await_args.kwargs
assert create_session_kwargs["agent_name"] == "test-agent"
assert create_session_kwargs["isolation_key"] == "iso-key"
assert "version_indicator" in create_session_kwargs
mock_prepare_run_context.assert_awaited_once()
async def test_raw_foundry_agent_prepare_run_context_requires_preview_for_hosted_sessions() -> None:
"""Test that hosted-agent sessions require allow_preview=True."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
agent = RawFoundryAgent(
project_client=mock_project,
agent_name="test-agent",
)
with pytest.raises(RuntimeError, match="allow_preview=True"):
await agent._prepare_run_context(
messages="hi",
session=AgentSession(),
tools=None,
options={"isolation_key": "iso-key"},
compaction_strategy=None,
tokenizer=None,
function_invocation_kwargs=None,
client_kwargs=None,
)
def test_foundry_agent_init() -> None:
"""Test construction of the full-middleware agent."""
@@ -483,9 +599,10 @@ async def test_foundry_agent_configure_azure_monitor_import_error() -> None:
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_agent_integration_tests_disabled
@pytest.mark.skip(reason="Test agent seems to have disappeared from the test environment; needs investigation.")
async def test_foundry_agent_basic_run() -> None:
"""Smoke-test FoundryAgent against a real configured agent."""
async with FoundryAgent(credential=AzureCliCredential()) as agent:
async with FoundryAgent(credential=AzureCliCredential(), allow_preview=True) as agent:
response = await agent.run("Please respond with exactly: 'This is a response test.'")
assert isinstance(response, AgentResponse)
@@ -496,6 +613,7 @@ async def test_foundry_agent_basic_run() -> None:
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_agent_integration_tests_disabled
@pytest.mark.skip(reason="Test agent seems to have disappeared from the test environment; needs investigation.")
async def test_foundry_agent_custom_client_run() -> None:
"""Smoke-test FoundryAgent against a real configured agent."""
async with FoundryAgent(credential=AzureCliCredential(), client_type=RawFoundryAgentChatClient) as agent:
@@ -198,6 +198,7 @@ class TestRawFoundryEmbeddingClient:
"FOUNDRY_MODELS_API_KEY": "env-key",
"FOUNDRY_EMBEDDING_MODEL": "env-model",
},
clear=True,
),
patch("agent_framework_foundry._embedding_client.EmbeddingsClient"),
patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient"),
@@ -172,12 +172,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
self._agent = agent
self.response_handler(self._handle_response) # pyright: ignore[reportUnknownMemberType]
@staticmethod
def _is_streaming_request(request: CreateResponse) -> bool:
"""Check if the request is a streaming request."""
return request.stream is not None and request.stream is True
def _handle_response(
async def _handle_response(
self,
request: CreateResponse,
context: ResponseContext,
@@ -186,11 +181,10 @@ class ResponsesHostServer(ResponsesAgentServerHost):
"""Handle the creation of a response."""
if self._is_workflow_agent:
# Workflow agents are handled differently because they require checkpoint restoration
return self._handle_workflow_agent(request, context)
return self._handle_inner_workflow(request, context)
return self._handle_inner_agent(request, context)
return self._handle_regular_agent(request, context)
async def _handle_regular_agent(
async def _handle_inner_agent(
self,
request: CreateResponse,
context: ResponseContext,
@@ -200,25 +194,24 @@ class ResponsesHostServer(ResponsesAgentServerHost):
input_messages = _items_to_messages(input_items)
history = await context.get_history()
messages: list[str | Content | Message] = [*_output_items_to_messages(history), *input_messages]
run_kwargs: dict[str, Any] = {"messages": [*_output_items_to_messages(history), *input_messages]}
is_streaming_request = request.stream is not None and request.stream is True
chat_options, are_options_set = _to_chat_options(request)
is_streaming_request = self._is_streaming_request(request)
response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model)
yield response_event_stream.emit_created()
yield response_event_stream.emit_in_progress()
if are_options_set and not isinstance(self._agent, RawAgent):
logger.warning("Agent doesn't support runtime options. They will be ignored.")
else:
run_kwargs["options"] = chat_options
if not is_streaming_request:
# Run the agent in non-streaming mode
if isinstance(self._agent, RawAgent):
raw_agent = cast("RawAgent[Any]", self._agent) # type: ignore[redundant-cast] # pyright: ignore[reportUnknownMemberType]
response = await raw_agent.run(messages, stream=False, options=chat_options)
else:
if are_options_set:
logger.warning("Agent doesn't support runtime options. They will be ignored.")
response = await self._agent.run(messages, stream=False)
response = await self._agent.run(stream=False, **run_kwargs) # type: ignore[reportUnknownMemberType]
for message in response.messages:
for content in message.contents:
@@ -228,20 +221,12 @@ class ResponsesHostServer(ResponsesAgentServerHost):
yield response_event_stream.emit_completed()
return
# Run the agent in streaming mode
if isinstance(self._agent, RawAgent):
raw_agent = cast("RawAgent[Any]", self._agent) # type: ignore[redundant-cast] # pyright: ignore[reportUnknownMemberType]
response_stream = raw_agent.run(messages, stream=True, options=chat_options)
else:
if are_options_set:
logger.warning("Agent doesn't support runtime options. They will be ignored.")
response_stream = self._agent.run(messages, stream=True)
# Track the current active output item builder for streaming;
# lazily created on matching content, closed when a different type arrives.
tracker = _OutputItemTracker(response_event_stream)
async for update in response_stream:
# Run the agent in streaming mode
async for update in self._agent.run(stream=True, **run_kwargs): # type: ignore[reportUnknownMemberType]
for content in update.contents:
for event in tracker.handle(content):
yield event
@@ -256,7 +241,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
yield response_event_stream.emit_completed()
async def _handle_workflow_agent(
async def _handle_inner_workflow(
self,
request: CreateResponse,
context: ResponseContext,
@@ -269,8 +254,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
"""
input_items = await context.get_input_items()
input_messages = _items_to_messages(input_items)
is_streaming_request = self._is_streaming_request(request)
is_streaming_request = request.stream is not None and request.stream is True
_, are_options_set = _to_chat_options(request)
if are_options_set:
@@ -311,7 +295,8 @@ class ResponsesHostServer(ResponsesAgentServerHost):
response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model)
# Create a new checkpoint storage for this response based on the following rules:
# - If no previous response ID or conversation ID is provided, create a new checkpoint storage for this response
# - If no previous response ID or conversation ID is provided,
# create a new checkpoint storage for this response
# - If a previous response ID is provided, create a new checkpoint storage for this response
# - If a conversation ID is provided, reuse the existing checkpoint storage for the conversation
context_id = context.conversation_id or context.response_id
@@ -333,14 +318,12 @@ class ResponsesHostServer(ResponsesAgentServerHost):
yield response_event_stream.emit_completed()
return
# Run the agent in streaming mode
response_stream = self._agent.run(input_messages, stream=True, checkpoint_storage=checkpoint_storage)
# Track the current active output item builder for streaming;
# lazily created on matching content, closed when a different type arrives.
tracker = _OutputItemTracker(response_event_stream)
async for update in response_stream:
# Run the workflow agent in streaming mode
async for update in self._agent.run(input_messages, stream=True, checkpoint_storage=checkpoint_storage):
for content in update.contents:
for event in tracker.handle(content):
yield event
@@ -355,7 +338,6 @@ class ResponsesHostServer(ResponsesAgentServerHost):
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
yield response_event_stream.emit_completed()
return
@staticmethod
async def _delete_not_latest_checkpoints(checkpoint_storage: FileCheckpointStorage, workflow_name: str) -> None:
@@ -41,9 +41,10 @@ def _make_agent(
*,
response: AgentResponse | None = None,
stream_updates: list[AgentResponseUpdate] | None = None,
raw_agent: bool = True,
) -> MagicMock:
"""Create a mock agent implementing SupportsAgentRun."""
agent = MagicMock(spec=RawAgent)
agent = MagicMock(spec=RawAgent) if raw_agent else MagicMock()
agent.id = "test-agent"
agent.name = "Test Agent"
agent.description = "A mock agent for testing"
@@ -267,10 +268,18 @@ class TestNonStreaming:
async def test_chat_options_forwarded(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])])
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])]),
raw_agent=True,
)
server = _make_server(agent)
resp = await _post(server, stream=False, temperature=0.5, top_p=0.9, max_output_tokens=1024)
resp = await _post(
server,
stream=False,
temperature=0.5,
top_p=0.9,
max_output_tokens=1024,
parallel_tool_calls=True,
)
assert resp.status_code == 200
agent.run.assert_awaited_once()
@@ -280,6 +289,7 @@ class TestNonStreaming:
assert options["temperature"] == 0.5
assert options["top_p"] == 0.9
assert options["max_tokens"] == 1024
assert options["allow_multiple_tool_calls"] is True
# endregion
@@ -289,6 +299,31 @@ class TestNonStreaming:
class TestStreaming:
async def test_chat_options_forwarded(self) -> None:
agent = _make_agent(
stream_updates=[AgentResponseUpdate(contents=[Content.from_text("ok")], role="assistant")],
raw_agent=True,
)
server = _make_server(agent)
resp = await _post(
server,
stream=True,
temperature=0.5,
top_p=0.9,
max_output_tokens=1024,
parallel_tool_calls=True,
)
assert resp.status_code == 200
agent.run.assert_called_once()
call_kwargs = agent.run.call_args.kwargs
assert call_kwargs["stream"] is True
options = call_kwargs["options"]
assert options["temperature"] == 0.5
assert options["top_p"] == 0.9
assert options["max_tokens"] == 1024
assert options["allow_multiple_tool_calls"] is True
async def test_basic_text_streaming(self) -> None:
agent = _make_agent(
stream_updates=[
@@ -1426,7 +1461,7 @@ class TestMultiTurnMixedContent:
assert body["status"] == "completed"
# Verify agent received text + image
messages = agent.run.call_args.args[0]
messages = agent.run.call_args.kwargs["messages"]
assert len(messages) == 1
assert messages[0].role == "user"
assert len(messages[0].contents) == 2
@@ -1464,7 +1499,7 @@ class TestMultiTurnMixedContent:
body = resp.json()
assert body["status"] == "completed"
messages = agent.run.call_args.args[0]
messages = agent.run.call_args.kwargs["messages"]
assert len(messages) == 1
assert len(messages[0].contents) == 2
assert messages[0].contents[0].type == "text"
@@ -1501,7 +1536,7 @@ class TestMultiTurnMixedContent:
body = resp.json()
assert body["status"] == "completed"
messages = agent.run.call_args.args[0]
messages = agent.run.call_args.kwargs["messages"]
assert len(messages) == 1
assert len(messages[0].contents) == 2
assert messages[0].contents[0].type == "text"
@@ -1542,7 +1577,7 @@ class TestMultiTurnMixedContent:
body = resp.json()
assert body["status"] == "completed"
messages = agent.run.call_args.args[0]
messages = agent.run.call_args.kwargs["messages"]
assert len(messages) == 3
assert messages[0].role == "user"
assert messages[0].contents[0].type == "text"
@@ -1591,7 +1626,7 @@ class TestMultiTurnMixedContent:
assert body2["status"] == "completed"
# Verify second call receives history from turn 1 + text+image input
second_call_messages = agent.run.call_args_list[1].args[0]
second_call_messages = agent.run.call_args_list[1].kwargs["messages"]
# History: output message from turn 1 ("Send me an image")
# Input: message with text + image
assert len(second_call_messages) >= 2
@@ -1652,7 +1687,7 @@ class TestMultiTurnMixedContent:
assert resp2.json()["status"] == "completed"
# Verify turn 2 received history including function call/result
second_call_messages = agent.run.call_args_list[1].args[0]
second_call_messages = agent.run.call_args_list[1].kwargs["messages"]
roles = [m.role for m in second_call_messages]
assert "assistant" in roles
assert "tool" in roles
@@ -1703,7 +1738,7 @@ class TestMultiTurnMixedContent:
assert resp2.json()["status"] == "completed"
# Verify history includes the reasoning and text from turn 1
second_call_messages = agent.run.call_args_list[1].args[0]
second_call_messages = agent.run.call_args_list[1].kwargs["messages"]
assert len(second_call_messages) >= 2 # history + new input
async def test_multi_turn_with_mixed_content_and_streaming(self) -> None:
@@ -1795,7 +1830,7 @@ class TestMultiTurnMixedContent:
body = resp.json()
assert body["status"] == "completed"
messages = agent.run.call_args.args[0]
messages = agent.run.call_args.kwargs["messages"]
assert len(messages) == 2
assert messages[0].role == "user"
assert messages[0].contents[0].type == "text"
@@ -1867,7 +1902,7 @@ class TestMultiTurnMixedContent:
assert resp3.json()["status"] == "completed"
# Verify turn 3 received full history from turns 1+2 plus new image input
third_call_messages = agent.run.call_args_list[2].args[0]
third_call_messages = agent.run.call_args_list[2].kwargs["messages"]
# Should have: history from turn 1 (assistant text) + history from turn 2
# (function_call, function_call_output, text) + new input (text + image)
assert len(third_call_messages) >= 5
@@ -1918,7 +1953,7 @@ class TestMultiTurnMixedContent:
body = resp.json()
assert body["status"] == "completed"
messages = agent.run.call_args.args[0]
messages = agent.run.call_args.kwargs["messages"]
assert len(messages) == 1
assert len(messages[0].contents) == 2
assert messages[0].contents[0].type == "text"
@@ -1982,7 +2017,7 @@ class TestMultiTurnMixedContent:
assert resp2.json()["status"] == "completed"
# Verify turn 2 received history from turn 1 + new text+file input
second_call_messages = agent.run.call_args_list[1].args[0]
second_call_messages = agent.run.call_args_list[1].kwargs["messages"]
assert len(second_call_messages) >= 2
# History should include the assistant response from turn 1
@@ -2050,7 +2085,7 @@ class TestMultiTurnMixedContent:
assert resp2.json()["status"] == "completed"
# Verify turn 2 received history with function call + new text+image
second_call_messages = agent.run.call_args_list[1].args[0]
second_call_messages = agent.run.call_args_list[1].kwargs["messages"]
# History should contain function_call and function_result from turn 1
fc_contents = [
c for m in second_call_messages if m.role == "assistant" for c in m.contents if c.type == "function_call"
@@ -285,8 +285,10 @@ def test_vertex_ai_requires_project_and_location_together(monkeypatch: pytest.Mo
GeminiChatClient(model="gemini-2.5-flash")
async def test_missing_model_raises_on_get_response() -> None:
async def test_missing_model_raises_on_get_response(monkeypatch: pytest.MonkeyPatch) -> None:
"""Raises ValueError at call time when no model is set on the client or in options."""
monkeypatch.delenv("GEMINI_MODEL", raising=False)
monkeypatch.delenv("GOOGLE_MODEL", raising=False)
client, mock = _make_gemini_client(model=None) # type: ignore[arg-type]
mock.aio.models.generate_content = AsyncMock()
@@ -355,6 +355,7 @@ async def test_integration_web_search() -> None:
@pytest.mark.integration
@skip_if_azure_openai_integration_tests_disabled
@_with_azure_openai_debug()
@pytest.mark.skip(reason="Azure OpenAI with files raises 500 error. Needs investigation.")
async def test_integration_client_file_search() -> None:
async with AzureCliCredential() as credential:
client = OpenAIChatClient(credential=credential)
@@ -380,6 +381,7 @@ async def test_integration_client_file_search() -> None:
@pytest.mark.integration
@skip_if_azure_openai_integration_tests_disabled
@_with_azure_openai_debug()
@pytest.mark.skip(reason="Azure OpenAI with files raises 500 error. Needs investigation.")
async def test_integration_client_file_search_streaming() -> None:
async with AzureCliCredential() as credential:
client = OpenAIChatClient(credential=credential)