Python: Foundry hosted agent V2 (#5379)

* Python: Wrapper + Samples 1st (#5177)

* Experiment

* Update dependency and add non streaming

* Add more samples

* Rename samples

* Add invocations

* Comments 1

* Comments 2

* Comments 3

* Improve README

* Add local shell sample

* WIP: Add eval and memory samples

* Update user agent prefix

* Update user agent prefix doc

* Update dependency (#5215)

* Add tests and more content types (#5235)

* Add tests

* fix tests and sample

* Fix formatting

* Remove function approval contents

* Python: Refine samples and upgrade packages (#5261)

* Refine samples and upgrade pacakges

* Upgrade to a new package that fixes a bug

* Update model env var

* Move samples (#5281)

* Python: Upgrade agentserver packages (#5284)

* Upgrade agentserver packages

* Fix new types

* Python: Add special handling for workflows (#5298)

* Add special handling for workflows

* Address comments

* Improve samples (#5372)

* Python: Add more types (#5378)

* Add more type supports

* Upgrade packages

* Remove TODOs in README

* Fix README

* Comments and mypy

* User agent scoped

* Fix README

* Fix pre commit

* Fix pre commit 2

* Fix pre commit 3

* Fix pre commit 4

* Fix pre commit 5

* Fix pre commit 6

* Add azure-monitor-opentelemetry to dev deps

Fixes Samples & Markdown CI failure. The PR's new transitive dep on
azure-monitor-opentelemetry-exporter (via azure-ai-agentserver-core) makes
pyright resolve the azure.monitor.opentelemetry namespace, flipping the
check_md_code_blocks diagnostic for `configure_azure_monitor` from
reportMissingImports (filtered) to reportAttributeAccessIssue (not filtered).
Installing the umbrella azure-monitor-opentelemetry package in dev makes
pyright resolve the symbol correctly, matching the install guidance the
observability README already gives users.

---------

Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
This commit is contained in:
Tao Chen
2026-04-20 22:21:27 -07:00
committed by GitHub
Unverified
parent 07f4c8a8d6
commit ce8b6305d8
87 changed files with 3597 additions and 1197 deletions
@@ -4,6 +4,9 @@ from __future__ import annotations
import logging
import os
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, Final
from . import __version__ as version_info
@@ -26,6 +29,35 @@ USER_AGENT_KEY: Final[str] = "User-Agent"
HTTP_USER_AGENT: Final[str] = "agent-framework-python"
AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}" # type: ignore[has-type]
_user_agent_prefixes: ContextVar[tuple[str, ...]] = ContextVar("_user_agent_prefixes", default=())
@contextmanager
def user_agent_prefix(prefix: str) -> Generator[None]:
"""Context manager that adds a prefix to the user agent string for the current scope.
This is useful for upstream layers that want to identify themselves in telemetry
for the duration of a request without permanently mutating global state.
Args:
prefix: The prefix to add (e.g. "foundry-hosting").
"""
current = _user_agent_prefixes.get()
token = _user_agent_prefixes.set((*current, prefix)) if prefix and prefix not in current else None
try:
yield
finally:
if token is not None:
_user_agent_prefixes.reset(token)
def _get_user_agent() -> str:
"""Return the full user agent string including any context-scoped prefixes."""
prefixes = _user_agent_prefixes.get()
if not prefixes:
return AGENT_FRAMEWORK_USER_AGENT
return f"{'/'.join(prefixes)}/{AGENT_FRAMEWORK_USER_AGENT}"
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
"""Prepend "agent-framework" to the User-Agent in the headers.
@@ -57,12 +89,9 @@ def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None)
"""
if not IS_TELEMETRY_ENABLED:
return headers or {}
user_agent = _get_user_agent()
if not headers:
return {USER_AGENT_KEY: AGENT_FRAMEWORK_USER_AGENT}
headers[USER_AGENT_KEY] = (
f"{AGENT_FRAMEWORK_USER_AGENT} {headers[USER_AGENT_KEY]}"
if USER_AGENT_KEY in headers
else AGENT_FRAMEWORK_USER_AGENT
)
return {USER_AGENT_KEY: user_agent}
headers[USER_AGENT_KEY] = f"{user_agent} {headers[USER_AGENT_KEY]}" if USER_AGENT_KEY in headers else user_agent
return headers
@@ -8,6 +8,7 @@ from agent_framework import (
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR,
prepend_agent_framework_to_user_agent,
)
from agent_framework._telemetry import user_agent_prefix
# region Test constants
@@ -96,3 +97,56 @@ def test_modifies_original_dict():
assert result is headers # Same object
assert "User-Agent" in headers
# region Test user_agent_prefix context manager
def test_user_agent_prefix_adds_prefix():
"""Test that the context manager adds a prefix within its scope."""
with user_agent_prefix("test-host"):
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"].startswith("test-host/")
assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"]
# Prefix is removed after exiting the context
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
def test_user_agent_prefix_ignores_duplicates():
"""Test that duplicate prefixes are not added within nested scopes."""
with user_agent_prefix("test-host"), user_agent_prefix("test-host"):
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"].count("test-host") == 1
def test_user_agent_prefix_ignores_empty():
"""Test that empty strings are not added as prefixes."""
with user_agent_prefix(""):
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
def test_user_agent_prefix_restores_on_exit():
"""Test that prefixes are fully restored after the context manager exits."""
with user_agent_prefix("test-host"):
pass
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
def test_user_agent_prefix_nesting():
"""Test that nested context managers compose prefixes correctly."""
with user_agent_prefix("outer"):
with user_agent_prefix("inner"):
result = prepend_agent_framework_to_user_agent()
assert "outer" in result["User-Agent"]
assert "inner" in result["User-Agent"]
# Inner prefix removed
result = prepend_agent_framework_to_user_agent()
assert "outer" in result["User-Agent"]
assert "inner" not in result["User-Agent"]
# Both removed
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
@@ -0,0 +1,3 @@
# Foundry Hosting
This package provides the integration of Agent Framework agents and workflows with the Foundry Agent Server, which can be hosted on Foundry infrastructure.
@@ -0,0 +1,13 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._invocations import InvocationsHostServer
from ._responses import ResponsesHostServer
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = ["InvocationsHostServer", "ResponsesHostServer"]
@@ -0,0 +1,80 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework import AgentSession, BaseAgent, SupportsAgentRun
from agent_framework._telemetry import user_agent_prefix
from azure.ai.agentserver.invocations import InvocationAgentServerHost
from starlette.requests import Request
from starlette.responses import JSONResponse, Response, StreamingResponse
from typing_extensions import Any, AsyncGenerator
class InvocationsHostServer(InvocationAgentServerHost):
"""An invocations server host for an agent."""
USER_AGENT_PREFIX = "foundry-hosting"
def __init__(
self,
agent: BaseAgent,
*,
openapi_spec: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Initialize an InvocationsHostServer.
Args:
agent: The agent to handle responses for.
openapi_spec: The OpenAPI specification for the server.
**kwargs: Additional keyword arguments.
This host will expect the request to be a JSON body with a "message" field.
The response from the host will be a JSON object with a "response" field containing
the agent's response and a "session_id" field containing the session ID.
"""
super().__init__(openapi_spec=openapi_spec, **kwargs)
if not isinstance(agent, SupportsAgentRun):
raise TypeError("Agent must support the SupportsAgentRun interface")
self._agent = agent
self._sessions: dict[str, AgentSession] = {}
self.invoke_handler(self._handle_invoke) # pyright: ignore[reportUnknownMemberType]
async def _handle_invoke(self, request: Request) -> Response:
"""Invoke the agent with the given request."""
with user_agent_prefix(self.USER_AGENT_PREFIX):
return await self._handle_invoke_inner(request)
async def _handle_invoke_inner(self, request: Request) -> Response:
"""Core invoke handler logic."""
data = await request.json()
session_id: str = request.state.session_id
stream = data.get("stream", False)
user_message = data.get("message", None)
if user_message is None:
error = "Missing 'message' in request"
if stream:
return StreamingResponse(content=error, status_code=400)
return Response(content=error, status_code=400)
session = self._sessions.setdefault(session_id, AgentSession(session_id=session_id))
if stream:
async def stream_response() -> AsyncGenerator[str]:
async for update in self._agent.run(user_message, session=session, stream=True):
if update.text:
yield update.text
return StreamingResponse(
stream_response(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
response = await self._agent.run([user_message], session=session, stream=stream)
return JSONResponse({
"response": response.text,
"session_id": session_id,
})
@@ -0,0 +1,983 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import json
import logging
import os
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
from typing import cast
from agent_framework import (
ChatOptions,
Content,
ContextProvider,
FileCheckpointStorage,
HistoryProvider,
Message,
RawAgent,
SupportsAgentRun,
WorkflowAgent,
)
from agent_framework._telemetry import user_agent_prefix
from azure.ai.agentserver.responses import (
ResponseContext,
ResponseEventStream,
ResponseProviderProtocol,
ResponsesServerOptions,
)
from azure.ai.agentserver.responses.hosting import ResponsesAgentServerHost
from azure.ai.agentserver.responses.models import (
ComputerScreenshotContent,
CreateResponse,
FunctionCallOutputItemParam,
FunctionShellAction,
FunctionShellCallOutputContent,
FunctionShellCallOutputExitOutcome,
LocalEnvironmentResource,
MessageContent,
MessageContentInputFileContent,
MessageContentInputImageContent,
MessageContentInputTextContent,
MessageContentOutputTextContent,
MessageContentReasoningTextContent,
MessageContentRefusalContent,
OAuthConsentRequestOutputItem,
OutputItem,
OutputItemApplyPatchToolCall,
OutputItemApplyPatchToolCallOutput,
OutputItemCodeInterpreterToolCall,
OutputItemComputerToolCall,
OutputItemComputerToolCallOutputResource,
OutputItemCustomToolCall,
OutputItemCustomToolCallOutput,
OutputItemFileSearchToolCall,
OutputItemFunctionShellCall,
OutputItemFunctionShellCallOutput,
OutputItemFunctionToolCall,
OutputItemImageGenToolCall,
OutputItemLocalShellToolCall,
OutputItemLocalShellToolCallOutput,
OutputItemMcpApprovalRequest,
OutputItemMcpApprovalResponseResource,
OutputItemMcpToolCall,
OutputItemMessage,
OutputItemOutputMessage,
OutputItemReasoningItem,
OutputItemWebSearchToolCall,
OutputMessageContent,
OutputMessageContentOutputTextContent,
OutputMessageContentRefusalContent,
ResponseStreamEvent,
StructuredOutputsOutputItem,
SummaryTextContent,
TextContent,
)
from azure.ai.agentserver.responses.streaming._builders import (
OutputItemFunctionCallBuilder,
OutputItemMcpCallBuilder,
OutputItemMessageBuilder,
OutputItemReasoningItemBuilder,
ReasoningSummaryPartBuilder,
TextContentBuilder,
)
from typing_extensions import Any
logger = logging.getLogger(__name__)
class ResponsesHostServer(ResponsesAgentServerHost):
"""A responses server host for an agent."""
USER_AGENT_PREFIX = "foundry-hosting"
# TODO(@taochen): Allow a different checkpoint storage that stores checkpoints externally
CHECKPOINT_STORAGE_PATH = "/.checkpoints"
def __init__(
self,
agent: SupportsAgentRun,
*,
prefix: str = "",
options: ResponsesServerOptions | None = None,
store: ResponseProviderProtocol | None = None,
**kwargs: Any,
) -> None:
"""Initialize a ResponsesHostServer.
Args:
agent: The agent to handle responses for.
prefix: The URL prefix for the server.
options: Optional server options.
store: Optional response store.
**kwargs: Additional keyword arguments.
Note:
1. The agent must not have a history provider with `load_messages=True`,
because history is managed by the hosting infrastructure.
2. The agent must not have any context providers that maintain context
in memory, because the hosting environment may get deactivated between
requests, and any in-memory context would be lost.
"""
super().__init__(prefix=prefix, options=options, store=store, **kwargs)
for provider in getattr(agent, "context_providers", []):
if isinstance(provider, HistoryProvider) and provider.load_messages:
raise RuntimeError(
"There shouldn't be a history provider with `load_messages=True` already present. "
"History is managed by the hosting infrastructure."
)
provider = cast(ContextProvider, provider)
logger.warning(
"Context provider %s is present. If it maintains context in memory, "
"the context may be lost between requests. Use with caution.",
provider.source_id,
)
self._is_workflow_agent = False
self._checkpoint_storage_path = None
if isinstance(agent, WorkflowAgent):
if agent.workflow._runner_context.has_checkpointing(): # pyright: ignore[reportPrivateUsage]
raise RuntimeError(
"There should not be a checkpoint storage already present in the workflow agent. "
"The hosting infrastructure will manage checkpoints instead."
)
self._checkpoint_storage_path = (
self.CHECKPOINT_STORAGE_PATH
if self.config.is_hosted
else os.path.join(os.getcwd(), self.CHECKPOINT_STORAGE_PATH.lstrip("/"))
)
self._is_workflow_agent = True
self._agent = agent
self.response_handler(self._handler) # 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
async def _handler(
self,
request: CreateResponse,
context: ResponseContext,
cancellation_signal: asyncio.Event,
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
"""Handle the creation of a response."""
with user_agent_prefix(self.USER_AGENT_PREFIX):
async for event in self._handle_inner(request, context, cancellation_signal):
yield event
async def _handle_inner(
self,
request: CreateResponse,
context: ResponseContext,
cancellation_signal: asyncio.Event,
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
"""Core handler logic."""
if self._is_workflow_agent:
# Workflow agents are handled differently because they require checkpoint restoration
async for event in self._handle_workflow_agent(request, context, cancellation_signal):
yield event
return
input_text = await context.get_input_text()
history = await context.get_history()
messages: list[str | Content | Message] = [*_to_messages(history), input_text]
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 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)
for message in response.messages:
for content in message.contents:
async for item in _to_outputs(response_event_stream, content):
yield item
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:
for content in update.contents:
for event in tracker.handle(content):
yield event
if tracker.needs_async:
async for item in _to_outputs(response_event_stream, content):
yield item
tracker.needs_async = False
# Close any remaining active builder
for event in tracker.close():
yield event
yield response_event_stream.emit_completed()
async def _handle_workflow_agent(
self,
request: CreateResponse,
context: ResponseContext,
cancellation_signal: asyncio.Event,
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
"""Handle the creation of a response for a workflow agent.
Why this is required:
The sandbox may be deactivated after some period of inactivity, and only data managed
by the hosting infrastructure or files will be preserved upon deactivation.
"""
input_text = await context.get_input_text()
is_streaming_request = self._is_streaming_request(request)
_, are_options_set = _to_chat_options(request)
if are_options_set:
logger.warning("Workflow agent doesn't support runtime options. They will be ignored.")
if request.previous_response_id is not None and context.conversation_id is not None:
raise RuntimeError("Previous response ID cannot be used in conjunction with conversation ID.")
context_id = request.previous_response_id or context.conversation_id
# The following should never happen due to the checks above.
# This is for type safety and defensive programming.
if self._checkpoint_storage_path is None:
raise RuntimeError("Checkpoint storage path is not configured for workflow agent.")
if not isinstance(self._agent, WorkflowAgent):
raise RuntimeError("Agent is not a workflow agent.")
# Restore from the latest checkpoint if available, otherwise start with an empty history
if context_id is not None:
checkpoint_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id))
latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=self._agent.workflow.name)
if latest_checkpoint is not None:
if not is_streaming_request:
_ = await self._agent.run(
stream=False,
checkpoint_id=latest_checkpoint.checkpoint_id,
checkpoint_storage=checkpoint_storage,
)
else:
# Consume the streaming or the invocation will result in a no-op
async for _ in self._agent.run(
stream=True,
checkpoint_id=latest_checkpoint.checkpoint_id,
checkpoint_storage=checkpoint_storage,
):
pass
# Now run the agent with the latest input
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 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
checkpoint_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id))
yield response_event_stream.emit_created()
yield response_event_stream.emit_in_progress()
if not is_streaming_request:
# Run the agent in non-streaming mode
response = await self._agent.run(input_text, stream=False, checkpoint_storage=checkpoint_storage)
for message in response.messages:
for content in message.contents:
async for item in _to_outputs(response_event_stream, content):
yield item
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
yield response_event_stream.emit_completed()
return
# Run the agent in streaming mode
response_stream = self._agent.run(input_text, 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:
for content in update.contents:
for event in tracker.handle(content):
yield event
if tracker.needs_async:
async for item in _to_outputs(response_event_stream, content):
yield item
tracker.needs_async = False
# Close any remaining active builder
for event in tracker.close():
yield event
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:
"""Delete all checkpoints except the latest one.
We only need the last checkpoint for each invocation.
"""
latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=workflow_name)
if latest_checkpoint is not None:
all_checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow_name)
for checkpoint in all_checkpoints:
if checkpoint.checkpoint_id != latest_checkpoint.checkpoint_id:
await checkpoint_storage.delete(checkpoint.checkpoint_id)
# region Active Builder State
class _OutputItemTracker:
"""Tracks the current active output item builder during streaming.
Handles lazy creation, delta emission, and closing of streaming builders
for text messages, reasoning, function calls, and MCP calls.
"""
_DELTA_TYPES = frozenset({"text", "text_reasoning", "function_call", "mcp_server_tool_call"})
def __init__(self, stream: ResponseEventStream) -> None:
self._stream = stream
self._active_type: str | None = None
self._active_id: str | None = None
# Accumulated delta text for the current active builder
self._accumulated: list[str] = []
# Builder state — only one is active at a time
self._message_item: OutputItemMessageBuilder | None = None
self._text_content: TextContentBuilder | None = None
self._reasoning_item: OutputItemReasoningItemBuilder | None = None
self._summary_part: ReasoningSummaryPartBuilder | None = None
self._fc_builder: OutputItemFunctionCallBuilder | None = None
self._mcp_builder: OutputItemMcpCallBuilder | None = None
self.needs_async = False
def handle(self, content: Content) -> Generator[ResponseStreamEvent]:
"""Process a content item, yielding sync events.
Sets ``needs_async = True`` if the caller must also drain an
async ``_to_outputs`` call for this content.
"""
if content.type == "text" and content.text is not None:
if self._active_type != "text":
yield from self._close()
yield from self._open_message()
self._accumulated.append(content.text)
if self._text_content is not None:
yield self._text_content.emit_delta(content.text)
elif content.type == "text_reasoning" and content.text is not None:
if self._active_type != "text_reasoning":
yield from self._close()
yield from self._open_reasoning()
self._accumulated.append(content.text)
if self._summary_part is not None:
yield self._summary_part.emit_text_delta(content.text)
elif content.type == "function_call" and content.call_id is not None:
if self._active_type != "function_call" or self._active_id != content.call_id:
yield from self._close()
yield from self._open_function_call(content)
args_str = _arguments_to_str(content.arguments)
self._accumulated.append(args_str)
if self._fc_builder is not None:
yield self._fc_builder.emit_arguments_delta(args_str)
elif content.type == "mcp_server_tool_call" and content.tool_name:
key = f"{content.server_name or 'default'}::{content.tool_name}"
if self._active_type != "mcp_server_tool_call" or self._active_id != key:
yield from self._close()
yield from self._open_mcp_call(content)
args_str = _arguments_to_str(content.arguments)
self._accumulated.append(args_str)
if self._mcp_builder is not None:
yield self._mcp_builder.emit_arguments_delta(args_str)
else:
yield from self._close()
self.needs_async = True
def close(self) -> Generator[ResponseStreamEvent]:
"""Close any remaining active builder."""
yield from self._close()
# -- Private open/close helpers --
def _open_message(self) -> Generator[ResponseStreamEvent]:
self._message_item = self._stream.add_output_item_message()
self._text_content = self._message_item.add_text_content()
self._active_type = "text"
self._active_id = None
yield self._message_item.emit_added()
yield self._text_content.emit_added()
def _open_reasoning(self) -> Generator[ResponseStreamEvent]:
self._reasoning_item = self._stream.add_output_item_reasoning_item()
self._summary_part = self._reasoning_item.add_summary_part()
self._active_type = "text_reasoning"
self._active_id = None
yield self._reasoning_item.emit_added()
yield self._summary_part.emit_added()
def _open_function_call(self, content: Content) -> Generator[ResponseStreamEvent]:
self._fc_builder = self._stream.add_output_item_function_call(
name=content.name or "",
call_id=content.call_id or "",
)
self._active_type = "function_call"
self._active_id = content.call_id
yield self._fc_builder.emit_added()
def _open_mcp_call(self, content: Content) -> Generator[ResponseStreamEvent]:
self._mcp_builder = self._stream.add_output_item_mcp_call(
server_label=content.server_name or "default",
name=content.tool_name or "",
)
self._active_type = "mcp_server_tool_call"
self._active_id = f"{content.server_name or 'default'}::{content.tool_name}"
yield self._mcp_builder.emit_added()
def _close(self) -> Generator[ResponseStreamEvent]:
accumulated = "".join(self._accumulated)
if self._active_type == "text" and self._text_content and self._message_item:
yield self._text_content.emit_text_done(accumulated)
yield self._text_content.emit_done()
yield self._message_item.emit_done()
self._text_content = None
self._message_item = None
elif self._active_type == "text_reasoning" and self._summary_part and self._reasoning_item:
yield self._summary_part.emit_text_done(accumulated)
yield self._summary_part.emit_done()
yield self._reasoning_item.emit_done()
self._summary_part = None
self._reasoning_item = None
elif self._active_type == "function_call" and self._fc_builder:
yield self._fc_builder.emit_arguments_done(accumulated)
yield self._fc_builder.emit_done()
self._fc_builder = None
elif self._active_type == "mcp_server_tool_call" and self._mcp_builder:
yield self._mcp_builder.emit_arguments_done(accumulated)
yield self._mcp_builder.emit_completed()
yield self._mcp_builder.emit_done()
self._mcp_builder = None
self._active_type = None
self._active_id = None
self._accumulated.clear()
# endregion
# region Option Conversion
def _to_chat_options(request: CreateResponse) -> tuple[ChatOptions, bool]:
"""Converts a CreateResponse request to ChatOptions.
Args:
request (CreateResponse): The request to convert.
Returns:
ChatOptions: The converted ChatOptions.
bool: Whether any options were set.
"""
chat_options = ChatOptions()
are_options_set = False
if request.temperature is not None:
chat_options["temperature"] = request.temperature
are_options_set = True
if request.top_p is not None:
chat_options["top_p"] = request.top_p
are_options_set = True
if request.max_output_tokens is not None:
chat_options["max_tokens"] = request.max_output_tokens
are_options_set = True
if request.parallel_tool_calls is not None:
chat_options["allow_multiple_tool_calls"] = request.parallel_tool_calls
are_options_set = True
return chat_options, are_options_set
# endregion
# region Input Message Conversion
def _to_messages(history: Sequence[OutputItem]) -> list[Message]:
"""Converts a sequence of OutputItem objects to a list of Message objects.
Args:
history (Sequence[OutputItem]): The sequence of OutputItem objects to convert.
Returns:
list[Message]: The list of Message objects.
"""
messages: list[Message] = []
for item in history:
messages.append(_to_message(item))
return messages
def _to_message(item: OutputItem) -> Message:
"""Converts an OutputItem to a Message.
Args:
item (OutputItem): The OutputItem to convert.
Returns:
Message: The converted Message.
Raises:
ValueError: If the OutputItem type is not supported.
"""
if item.type == "output_message":
output_msg = cast(OutputItemOutputMessage, item)
return Message(
role=output_msg.role, contents=[_convert_output_message_content(part) for part in output_msg.content]
)
if item.type == "message":
msg = cast(OutputItemMessage, item)
return Message(role=msg.role, contents=[_convert_message_content(part) for part in msg.content])
if item.type == "function_call":
fc = cast(OutputItemFunctionToolCall, item)
return Message(
role="assistant",
contents=[Content.from_function_call(fc.call_id, fc.name, arguments=fc.arguments)],
)
if item.type == "function_call_output":
fco = cast(FunctionCallOutputItemParam, item)
output = fco.output if isinstance(fco.output, str) else str(fco.output)
return Message(
role="tool",
contents=[Content.from_function_result(fco.call_id, result=output)],
)
if item.type == "reasoning":
reasoning = cast(OutputItemReasoningItem, item)
contents: list[Content] = []
if reasoning.summary:
for summary in reasoning.summary:
contents.append(Content.from_text(summary.text))
return Message(role="assistant", contents=contents)
if item.type == "mcp_call":
mcp = cast(OutputItemMcpToolCall, item)
return Message(
role="assistant",
contents=[
Content.from_mcp_server_tool_call(
mcp.id,
mcp.name,
server_name=mcp.server_label,
arguments=mcp.arguments,
)
],
)
if item.type == "mcp_approval_request":
mcp_req = cast(OutputItemMcpApprovalRequest, item)
mcp_call_content = Content.from_mcp_server_tool_call(
mcp_req.id,
mcp_req.name,
server_name=mcp_req.server_label,
arguments=mcp_req.arguments,
)
return Message(
role="assistant",
contents=[Content.from_function_approval_request(mcp_req.id, mcp_call_content)],
)
if item.type == "mcp_approval_response":
mcp_resp = cast(OutputItemMcpApprovalResponseResource, item)
# Build a placeholder function_call Content since the original call details are not available
placeholder_content = Content.from_function_call(mcp_resp.approval_request_id, "mcp_approval")
return Message(
role="user",
contents=[Content.from_function_approval_response(mcp_resp.approve, mcp_resp.id, placeholder_content)],
)
if item.type == "code_interpreter_call":
ci = cast(OutputItemCodeInterpreterToolCall, item)
return Message(
role="assistant",
contents=[Content.from_code_interpreter_tool_call(call_id=ci.id)],
)
if item.type == "image_generation_call":
ig = cast(OutputItemImageGenToolCall, item)
return Message(
role="assistant",
contents=[Content.from_image_generation_tool_call(image_id=ig.id)],
)
if item.type == "shell_call":
sc = cast(OutputItemFunctionShellCall, item)
return Message(
role="assistant",
contents=[
Content.from_shell_tool_call(
call_id=sc.call_id,
commands=sc.action.commands,
status=str(sc.status),
)
],
)
if item.type == "shell_call_output":
sco = cast(OutputItemFunctionShellCallOutput, item)
outputs = [
Content.from_shell_command_output(
stdout=out.stdout or "",
stderr=out.stderr or "",
exit_code=getattr(out.outcome, "exit_code", None) if hasattr(out, "outcome") else None,
)
for out in (sco.output or [])
]
return Message(
role="tool",
contents=[
Content.from_shell_tool_result(
call_id=sco.call_id,
outputs=outputs,
max_output_length=sco.max_output_length,
)
],
)
if item.type == "local_shell_call":
lsc = cast(OutputItemLocalShellToolCall, item)
commands = lsc.action.command if hasattr(lsc.action, "command") and lsc.action.command else []
return Message(
role="assistant",
contents=[
Content.from_shell_tool_call(
call_id=lsc.call_id,
commands=commands,
status=str(lsc.status),
)
],
)
if item.type == "local_shell_call_output":
lsco = cast(OutputItemLocalShellToolCallOutput, item)
return Message(
role="tool",
contents=[
Content.from_shell_tool_result(
call_id=lsco.id,
outputs=[Content.from_shell_command_output(stdout=lsco.output)],
)
],
)
if item.type == "file_search_call":
fs = cast(OutputItemFileSearchToolCall, item)
return Message(
role="assistant",
contents=[
Content.from_function_call(
fs.id,
"file_search",
arguments=json.dumps({"queries": fs.queries}),
)
],
)
if item.type == "web_search_call":
ws = cast(OutputItemWebSearchToolCall, item)
return Message(
role="assistant",
contents=[Content.from_function_call(ws.id, "web_search")],
)
if item.type == "computer_call":
cc = cast(OutputItemComputerToolCall, item)
return Message(
role="assistant",
contents=[
Content.from_function_call(
cc.call_id,
"computer_use",
arguments=str(cc.action),
)
],
)
if item.type == "computer_call_output":
cco = cast(OutputItemComputerToolCallOutputResource, item)
return Message(
role="tool",
contents=[Content.from_function_result(cco.call_id, result=str(cco.output))],
)
if item.type == "custom_tool_call":
ct = cast(OutputItemCustomToolCall, item)
return Message(
role="assistant",
contents=[Content.from_function_call(ct.call_id, ct.name, arguments=ct.input)],
)
if item.type == "custom_tool_call_output":
cto = cast(OutputItemCustomToolCallOutput, item)
output = cto.output if isinstance(cto.output, str) else str(cto.output)
return Message(
role="tool",
contents=[Content.from_function_result(cto.call_id, result=output)],
)
if item.type == "apply_patch_call":
ap = cast(OutputItemApplyPatchToolCall, item)
return Message(
role="assistant",
contents=[
Content.from_function_call(
ap.call_id,
"apply_patch",
arguments=str(ap.operation),
)
],
)
if item.type == "apply_patch_call_output":
apo = cast(OutputItemApplyPatchToolCallOutput, item)
return Message(
role="tool",
contents=[Content.from_function_result(apo.call_id, result=apo.output or "")],
)
if item.type == "oauth_consent_request":
oauth = cast(OAuthConsentRequestOutputItem, item)
return Message(
role="assistant",
contents=[Content.from_oauth_consent_request(oauth.consent_link)],
)
if item.type == "structured_outputs":
so = cast(StructuredOutputsOutputItem, item)
text = json.dumps(so.output) if not isinstance(so.output, str) else so.output
return Message(role="assistant", contents=[Content.from_text(text)])
raise ValueError(f"Unsupported OutputItem type: {item.type}")
def _convert_output_message_content(content: OutputMessageContent) -> Content:
"""Converts an OutputMessageContent to a Content object.
Args:
content (OutputMessageContent): The OutputMessageContent to convert.
Returns:
Content: The converted Content object.
Raises:
ValueError: If the OutputMessageContent type is not supported.
"""
if content.type == "output_text":
text_content = cast(OutputMessageContentOutputTextContent, content)
return Content.from_text(text_content.text)
if content.type == "refusal":
refusal_content = cast(OutputMessageContentRefusalContent, content)
return Content.from_text(refusal_content.refusal)
raise ValueError(f"Unsupported OutputMessageContent type: {content.type}")
def _convert_message_content(content: MessageContent) -> Content:
"""Converts a MessageContent to a Content object.
Args:
content (MessageContent): The MessageContent to convert.
Returns:
Content: The converted Content object.
Raises:
ValueError: If the MessageContent type is not supported.
"""
if content.type == "input_text":
input_text = cast(MessageContentInputTextContent, content)
return Content.from_text(input_text.text)
if content.type == "output_text":
output_text = cast(MessageContentOutputTextContent, content)
return Content.from_text(output_text.text)
if content.type == "text":
text = cast(TextContent, content)
return Content.from_text(text.text)
if content.type == "summary_text":
summary = cast(SummaryTextContent, content)
return Content.from_text(summary.text)
if content.type == "refusal":
refusal = cast(MessageContentRefusalContent, content)
return Content.from_text(refusal.refusal)
if content.type == "reasoning_text":
reasoning = cast(MessageContentReasoningTextContent, content)
return Content.from_text_reasoning(text=reasoning.text)
if content.type == "input_image":
image = cast(MessageContentInputImageContent, content)
if image.image_url:
return Content.from_uri(image.image_url)
if image.file_id:
return Content.from_hosted_file(image.file_id)
if content.type == "input_file":
file = cast(MessageContentInputFileContent, content)
if file.file_url:
return Content.from_uri(file.file_url)
if file.file_id:
return Content.from_hosted_file(file.file_id, name=file.filename)
if content.type == "computer_screenshot":
screenshot = cast(ComputerScreenshotContent, content)
return Content.from_uri(screenshot.image_url)
raise ValueError(f"Unsupported MessageContent type: {content.type}")
# endregion
# region Output Item Conversion
def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str:
"""Convert arguments to a JSON string.
Args:
arguments: The arguments to convert, can be a string, mapping, or None.
Returns:
The arguments as a JSON string.
"""
if arguments is None:
return ""
if isinstance(arguments, str):
return arguments
return json.dumps(arguments)
async def _to_outputs(stream: ResponseEventStream, content: Content) -> AsyncIterator[ResponseStreamEvent]:
"""Converts a Content object to an async sequence of ResponseStreamEvent objects.
Args:
stream: The ResponseEventStream to use for building events.
content: The Content to convert.
Yields:
ResponseStreamEvent: The converted event objects.
Raises:
ValueError: If the Content type is not supported.
"""
if content.type == "text" and content.text is not None:
async for event in stream.aoutput_item_message(content.text):
yield event
elif content.type == "text_reasoning" and content.text is not None:
async for event in stream.aoutput_item_reasoning_item(content.text):
yield event
elif content.type == "function_call":
async for event in stream.aoutput_item_function_call(
content.name, # type: ignore[arg-type]
content.call_id, # type: ignore[arg-type]
_arguments_to_str(content.arguments),
):
yield event
elif content.type == "function_result":
async for event in stream.aoutput_item_function_call_output(
content.call_id, # type: ignore[arg-type]
str(content.result or ""),
):
yield event
elif content.type == "image_generation_tool_result" and content.outputs is not None:
async for event in stream.aoutput_item_image_gen_call(str(content.outputs)):
yield event
elif content.type == "mcp_server_tool_call":
mcp_call = stream.add_output_item_mcp_call(
server_label=content.server_name or "default",
name=content.tool_name or "",
)
yield mcp_call.emit_added()
async for event in mcp_call.aarguments(_arguments_to_str(content.arguments)):
yield event
yield mcp_call.emit_completed()
yield mcp_call.emit_done()
elif content.type == "mcp_server_tool_result":
output = (
content.output
if isinstance(content.output, str)
else str(content.output)
if content.output is not None
else ""
)
async for event in stream.aoutput_item_custom_tool_call_output(content.call_id or "", output):
yield event
elif content.type == "shell_tool_call":
action = FunctionShellAction(commands=content.commands or [], timeout_ms=0, max_output_length=0)
async for event in stream.aoutput_item_function_shell_call(
content.call_id or "",
action,
LocalEnvironmentResource(),
status=content.status or "completed",
):
yield event
elif content.type == "shell_tool_result":
output_items: list[FunctionShellCallOutputContent] = []
if content.outputs:
for out in content.outputs:
exit_code = getattr(out, "exit_code", None)
output_items.append(
FunctionShellCallOutputContent(
stdout=getattr(out, "stdout", "") or "",
stderr=getattr(out, "stderr", "") or "",
outcome=FunctionShellCallOutputExitOutcome(exit_code=exit_code if exit_code is not None else 0),
)
)
async for event in stream.aoutput_item_function_shell_call_output(
content.call_id or "",
output_items,
status=content.status or "completed",
max_output_length=content.max_output_length,
):
yield event
else:
# Log a warning for unsupported content types instead of raising an error to avoid breaking the response stream.
logger.warning(f"Content type '{content.type}' is not supported yet. This is usually safe to ignore.")
# endregion
@@ -0,0 +1,99 @@
[project]
name = "agent-framework-foundry-hosting"
description = "Foundry Hosting integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260420"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0,<2",
"azure-ai-agentserver-core==2.0.0b2",
"azure-ai-agentserver-responses==1.0.0b4",
"azure-ai-agentserver-invocations==1.0.0b2",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_foundry_hosting"]
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_foundry_hosting"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_foundry_hosting"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_foundry_hosting --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,917 @@
# Copyright (c) Microsoft. All rights reserved.
"""HTTP round-trip tests for ResponsesHostServer.
These tests exercise the full HTTP pipeline using httpx.AsyncClient with
ASGITransport — no real server process is started. Requests go through
the Starlette routing stack, the Responses API middleware, and arrive at
the registered _handle_create handler.
"""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
Content,
HistoryProvider,
Message,
RawAgent,
ResponseStream,
)
from azure.ai.agentserver.responses import InMemoryResponseProvider
from typing_extensions import Any
from agent_framework_foundry_hosting import ResponsesHostServer
from agent_framework_foundry_hosting._responses import _to_message # pyright: ignore[reportPrivateUsage]
# region Helpers
def _make_agent(
*,
response: AgentResponse | None = None,
stream_updates: list[AgentResponseUpdate] | None = None,
) -> MagicMock:
"""Create a mock agent implementing SupportsAgentRun."""
agent = MagicMock(spec=RawAgent)
agent.id = "test-agent"
agent.name = "Test Agent"
agent.description = "A mock agent for testing"
agent.context_providers = []
if response is not None:
async def run_non_streaming(*args: Any, **kwargs: Any) -> AgentResponse:
return response
agent.run = AsyncMock(side_effect=run_non_streaming)
if stream_updates is not None:
async def _stream_gen() -> AsyncIterator[AgentResponseUpdate]:
for update in stream_updates:
yield update
def run_streaming(*args: Any, **kwargs: Any) -> Any:
if kwargs.get("stream"):
return ResponseStream(_stream_gen()) # type: ignore
raise NotImplementedError("Only streaming is configured on this mock")
agent.run = MagicMock(side_effect=run_streaming)
return agent
def _make_server(agent: MagicMock, **kwargs: Any) -> ResponsesHostServer:
"""Create a ResponsesHostServer with an in-memory store."""
return ResponsesHostServer(agent, store=InMemoryResponseProvider(), **kwargs)
async def _post(
server: ResponsesHostServer,
*,
input_text: str = "Hello",
model: str = "test-model",
stream: bool = False,
temperature: float | None = None,
top_p: float | None = None,
max_output_tokens: int | None = None,
parallel_tool_calls: bool | None = None,
) -> httpx.Response:
"""Send a POST /responses request through the ASGI transport."""
payload: dict[str, Any] = {"model": model, "input": input_text, "stream": stream}
if temperature is not None:
payload["temperature"] = temperature
if top_p is not None:
payload["top_p"] = top_p
if max_output_tokens is not None:
payload["max_output_tokens"] = max_output_tokens
if parallel_tool_calls is not None:
payload["parallel_tool_calls"] = parallel_tool_calls
transport = httpx.ASGITransport(app=server)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.post("/responses", json=payload)
def _parse_sse_events(body: str) -> list[dict[str, Any]]:
"""Parse SSE text into a list of event dicts with 'event' and 'data' keys."""
events: list[dict[str, Any]] = []
current_event: str | None = None
current_data_lines: list[str] = []
for line in body.split("\n"):
if line.startswith("event: "):
current_event = line[len("event: ") :]
elif line.startswith("data: "):
current_data_lines.append(line[len("data: ") :])
elif line.strip() == "" and current_event is not None:
data_str = "\n".join(current_data_lines)
try:
data = json.loads(data_str)
except json.JSONDecodeError:
data = data_str
events.append({"event": current_event, "data": data})
current_event = None
current_data_lines = []
return events
def _sse_event_types(events: list[dict[str, Any]]) -> list[str]:
"""Extract event type strings from parsed SSE events."""
return [e["event"] for e in events]
# endregion
# region Initialization
class TestResponsesHostServerInit:
def test_init_basic(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
server = _make_server(agent)
assert server is not None
def test_init_rejects_history_provider_with_load_messages(self) -> None:
hp = HistoryProvider(source_id="test", load_messages=True)
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
agent.context_providers = [hp]
with pytest.raises(RuntimeError, match="history provider"):
ResponsesHostServer(agent)
# endregion
# region Health Check
class TestHealthCheck:
async def test_readiness(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
server = _make_server(agent)
transport = httpx.ASGITransport(app=server)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/readiness")
assert resp.status_code == 200
# endregion
# region Non-streaming
class TestNonStreaming:
async def test_basic_text_response(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Hello!")])])
)
server = _make_server(agent)
resp = await _post(server, input_text="Hi", stream=False)
assert resp.status_code == 200
assert "application/json" in resp.headers["content-type"]
body = resp.json()
assert body["object"] == "response"
assert body["status"] == "completed"
assert len(body["output"]) > 0
# Find the message output item with our text
text_found = False
for item in body["output"]:
assert item["type"] == "message"
for part in item.get("content", []):
if part.get("type") == "output_text" and part.get("text") == "Hello!":
text_found = True
assert text_found, f"Expected 'Hello!' in output, got: {body['output']}"
async def test_function_call_and_result(self) -> None:
agent = _make_agent(
response=AgentResponse(
messages=[
Message(
role="assistant",
contents=[Content.from_function_call("call_1", "get_weather", arguments='{"loc": "NYC"}')],
),
Message(role="tool", contents=[Content.from_function_result("call_1", result="sunny")]),
Message(role="assistant", contents=[Content.from_text("The weather is sunny!")]),
]
)
)
server = _make_server(agent)
resp = await _post(server, stream=False)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
types = [item["type"] for item in body["output"]]
assert "function_call" in types
assert "function_call_output" in types
assert "message" in types
async def test_reasoning_content(self) -> None:
agent = _make_agent(
response=AgentResponse(
messages=[
Message(
role="assistant",
contents=[
Content.from_text_reasoning(text="Let me think..."),
Content.from_text("The answer is 42"),
],
),
]
)
)
server = _make_server(agent)
resp = await _post(server, stream=False)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
types = [item["type"] for item in body["output"]]
assert "reasoning" in types
assert "message" in types
async def test_empty_response(self) -> None:
agent = _make_agent(response=AgentResponse(messages=[]))
server = _make_server(agent)
resp = await _post(server, stream=False)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
async def test_chat_options_forwarded(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])])
)
server = _make_server(agent)
resp = await _post(server, stream=False, temperature=0.5, top_p=0.9, max_output_tokens=1024)
assert resp.status_code == 200
agent.run.assert_awaited_once()
call_kwargs = agent.run.call_args.kwargs
assert call_kwargs["stream"] is False
options = call_kwargs["options"]
assert options["temperature"] == 0.5
assert options["top_p"] == 0.9
assert options["max_tokens"] == 1024
# endregion
# region Streaming
class TestStreaming:
async def test_basic_text_streaming(self) -> None:
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(contents=[Content.from_text("Hello ")], role="assistant"),
AgentResponseUpdate(contents=[Content.from_text("world!")], role="assistant"),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
assert "text/event-stream" in resp.headers["content-type"]
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[1] == "response.in_progress"
assert types[-1] == "response.completed"
assert "response.output_text.delta" in types
assert types.count("response.output_text.delta") == 2
assert "response.output_text.done" in types
# Verify the accumulated text in the done event
done_events = [e for e in events if e["event"] == "response.output_text.done"]
assert len(done_events) == 1
assert done_events[0]["data"]["text"] == "Hello world!"
async def test_function_call_streaming(self) -> None:
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(
contents=[Content.from_function_call("call_1", "search", arguments='{"q":')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_call("call_1", "search", arguments=' "hello"}')],
role="assistant",
),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[-1] == "response.completed"
assert types.count("response.function_call_arguments.delta") == 2
assert "response.function_call_arguments.done" in types
# Verify accumulated arguments
args_done = [e for e in events if e["event"] == "response.function_call_arguments.done"]
assert len(args_done) == 1
assert args_done[0]["data"]["arguments"] == '{"q": "hello"}'
async def test_alternating_text_and_function_call(self) -> None:
agent = _make_agent(
stream_updates=[
# Text deltas
AgentResponseUpdate(contents=[Content.from_text("Let me ")], role="assistant"),
AgentResponseUpdate(contents=[Content.from_text("search...")], role="assistant"),
# Function call argument deltas
AgentResponseUpdate(
contents=[Content.from_function_call("call_1", "search", arguments='{"q":')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_call("call_1", "search", arguments=' "x"}')],
role="assistant",
),
# More text deltas
AgentResponseUpdate(contents=[Content.from_text("Found ")], role="assistant"),
AgentResponseUpdate(contents=[Content.from_text("it!")], role="assistant"),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[-1] == "response.completed"
# 4 text deltas + 2 function call argument deltas
assert types.count("response.output_text.delta") == 4
assert types.count("response.function_call_arguments.delta") == 2
# 3 distinct output items (text, fc, text)
assert types.count("response.output_item.added") == 3
assert types.count("response.output_item.done") == 3
# Verify accumulated content
text_done = [e for e in events if e["event"] == "response.output_text.done"]
assert len(text_done) == 2
assert text_done[0]["data"]["text"] == "Let me search..."
assert text_done[1]["data"]["text"] == "Found it!"
args_done = [e for e in events if e["event"] == "response.function_call_arguments.done"]
assert len(args_done) == 1
assert args_done[0]["data"]["arguments"] == '{"q": "x"}'
async def test_reasoning_then_text_streaming(self) -> None:
agent = _make_agent(
stream_updates=[
# Reasoning deltas
AgentResponseUpdate(contents=[Content.from_text_reasoning(text="Let me ")], role="assistant"),
AgentResponseUpdate(contents=[Content.from_text_reasoning(text="think...")], role="assistant"),
# Text deltas
AgentResponseUpdate(contents=[Content.from_text("The answer ")], role="assistant"),
AgentResponseUpdate(contents=[Content.from_text("is 42")], role="assistant"),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[-1] == "response.completed"
# Reasoning + text = 2 output items
assert types.count("response.output_item.added") == 2
assert types.count("response.output_item.done") == 2
assert types.count("response.output_text.delta") == 2
# Verify accumulated text
text_done = [e for e in events if e["event"] == "response.output_text.done"]
assert len(text_done) == 1
assert text_done[0]["data"]["text"] == "The answer is 42"
async def test_empty_streaming(self) -> None:
agent = _make_agent(stream_updates=[])
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types == ["response.created", "response.in_progress", "response.completed"]
async def test_mixed_contents_in_single_update(self) -> None:
"""Text and function call in one update switches builder mid-update."""
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(
contents=[
Content.from_text("Let me search"),
Content.from_function_call("call_1", "search", arguments='{"q": "test"}'),
],
role="assistant",
),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert "response.output_text.delta" in types
assert "response.output_text.done" in types
assert "response.function_call_arguments.delta" in types
assert "response.function_call_arguments.done" in types
async def test_different_function_call_ids_produce_separate_items(self) -> None:
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(
contents=[Content.from_function_call("call_1", "func_a", arguments='{"x":1}')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_call("call_2", "func_b", arguments='{"y":2}')],
role="assistant",
),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
# Two separate function call items
assert types.count("response.output_item.added") == 2
assert types.count("response.function_call_arguments.done") == 2
async def test_mcp_tool_call_streaming(self) -> None:
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(
contents=[
Content(
type="mcp_server_tool_call",
server_name="my_server",
tool_name="search",
arguments='{"query":',
)
],
role="assistant",
),
AgentResponseUpdate(
contents=[
Content(
type="mcp_server_tool_call",
server_name="my_server",
tool_name="search",
arguments=' "test"}',
)
],
role="assistant",
),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[-1] == "response.completed"
assert "response.output_item.added" in types
assert "response.output_item.done" in types
# endregion
# region _to_message conversion
class TestToMessage:
"""Tests for _to_message covering all supported OutputItem types."""
def test_output_message(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemOutputMessage, OutputMessageContentOutputTextContent
item = OutputItemOutputMessage({
"type": "output_message",
"role": "assistant",
"content": [OutputMessageContentOutputTextContent({"type": "output_text", "text": "hello"})],
"status": "completed",
"id": "msg-1",
})
msg = _to_message(item)
assert msg.role == "assistant"
assert len(msg.contents) == 1
assert msg.contents[0].type == "text"
assert msg.contents[0].text == "hello"
def test_message(self) -> None:
from azure.ai.agentserver.responses.models import MessageContentInputTextContent, OutputItemMessage
item = OutputItemMessage({
"type": "message",
"role": "user",
"content": [MessageContentInputTextContent({"type": "input_text", "text": "hi"})],
})
msg = _to_message(item)
assert msg.role == "user"
assert len(msg.contents) == 1
assert msg.contents[0].text == "hi"
def test_function_call(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemFunctionToolCall
item = OutputItemFunctionToolCall({
"type": "function_call",
"call_id": "call_1",
"name": "get_weather",
"arguments": '{"city": "NYC"}',
"status": "completed",
"id": "fc-1",
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "function_call"
assert msg.contents[0].call_id == "call_1"
assert msg.contents[0].name == "get_weather"
def test_function_call_output(self) -> None:
from azure.ai.agentserver.responses.models import FunctionCallOutputItemParam
item = FunctionCallOutputItemParam({"type": "function_call_output", "call_id": "call_1", "output": "sunny"})
msg = _to_message(item) # type: ignore[arg-type]
assert msg.role == "tool"
assert msg.contents[0].type == "function_result"
assert msg.contents[0].call_id == "call_1"
assert msg.contents[0].result == "sunny"
def test_reasoning(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemReasoningItem, SummaryTextContent
item = OutputItemReasoningItem({
"type": "reasoning",
"id": "r-1",
"summary": [SummaryTextContent({"type": "summary_text", "text": "thinking hard"})],
})
msg = _to_message(item)
assert msg.role == "assistant"
assert len(msg.contents) == 1
assert msg.contents[0].text == "thinking hard"
def test_reasoning_no_summary(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemReasoningItem
item = OutputItemReasoningItem({"type": "reasoning", "id": "r-2"})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents == []
def test_mcp_call(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemMcpToolCall
item = OutputItemMcpToolCall({
"type": "mcp_call",
"id": "mcp-1",
"server_label": "my_server",
"name": "search",
"arguments": '{"q": "test"}',
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "mcp_server_tool_call"
assert msg.contents[0].server_name == "my_server"
assert msg.contents[0].tool_name == "search"
def test_mcp_approval_request(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemMcpApprovalRequest
item = OutputItemMcpApprovalRequest({
"type": "mcp_approval_request",
"id": "apr-1",
"server_label": "srv",
"name": "dangerous_tool",
"arguments": "{}",
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "function_approval_request"
def test_mcp_approval_response(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemMcpApprovalResponseResource
item = OutputItemMcpApprovalResponseResource({
"type": "mcp_approval_response",
"id": "resp-1",
"approval_request_id": "apr-1",
"approve": True,
})
msg = _to_message(item)
assert msg.role == "user"
assert msg.contents[0].type == "function_approval_response"
assert msg.contents[0].approved is True
def test_code_interpreter_call(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemCodeInterpreterToolCall
item = OutputItemCodeInterpreterToolCall({
"type": "code_interpreter_call",
"id": "ci-1",
"status": "completed",
"container_id": "c-1",
"code": "print('hi')",
"outputs": [],
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "code_interpreter_tool_call"
def test_image_generation_call(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemImageGenToolCall
item = OutputItemImageGenToolCall({"type": "image_generation_call", "id": "ig-1", "status": "completed"})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "image_generation_tool_call"
def test_shell_call(self) -> None:
from azure.ai.agentserver.responses.models import (
FunctionShellAction,
FunctionShellCallEnvironment,
OutputItemFunctionShellCall,
)
item = OutputItemFunctionShellCall({
"type": "shell_call",
"id": "sc-1",
"call_id": "call_sc",
"action": FunctionShellAction({"commands": ["ls", "-la"], "timeout_ms": 5000, "max_output_length": 1024}),
"status": "completed",
"environment": FunctionShellCallEnvironment({"type": "local"}),
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "shell_tool_call"
assert msg.contents[0].commands == ["ls", "-la"]
assert msg.contents[0].call_id == "call_sc"
def test_shell_call_output(self) -> None:
from azure.ai.agentserver.responses.models import (
FunctionShellCallOutputContent,
FunctionShellCallOutputExitOutcome,
OutputItemFunctionShellCallOutput,
)
item = OutputItemFunctionShellCallOutput({
"type": "shell_call_output",
"id": "sco-1",
"call_id": "call_sc",
"status": "completed",
"output": [
FunctionShellCallOutputContent({
"stdout": "file.txt",
"stderr": "",
"outcome": FunctionShellCallOutputExitOutcome({"exit_code": 0}),
})
],
"max_output_length": 1024,
})
msg = _to_message(item)
assert msg.role == "tool"
assert msg.contents[0].type == "shell_tool_result"
assert msg.contents[0].call_id == "call_sc"
def test_local_shell_call(self) -> None:
from azure.ai.agentserver.responses.models import LocalShellExecAction, OutputItemLocalShellToolCall
item = OutputItemLocalShellToolCall({
"type": "local_shell_call",
"id": "lsc-1",
"call_id": "call_lsc",
"action": LocalShellExecAction({"type": "exec", "command": ["echo", "hello"], "env": {}}),
"status": "completed",
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "shell_tool_call"
assert msg.contents[0].commands == ["echo", "hello"]
def test_local_shell_call_output(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemLocalShellToolCallOutput
item = OutputItemLocalShellToolCallOutput({
"type": "local_shell_call_output",
"id": "lsco-1",
"output": "hello\n",
})
msg = _to_message(item)
assert msg.role == "tool"
assert msg.contents[0].type == "shell_tool_result"
def test_file_search_call(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemFileSearchToolCall
item = OutputItemFileSearchToolCall({
"type": "file_search_call",
"id": "fs-1",
"status": "completed",
"queries": ["what is AI"],
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "function_call"
assert msg.contents[0].name == "file_search"
assert '"what is AI"' in (msg.contents[0].arguments or "")
def test_web_search_call(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemWebSearchToolCall, WebSearchActionSearch
item = OutputItemWebSearchToolCall({
"type": "web_search_call",
"id": "ws-1",
"status": "completed",
"action": WebSearchActionSearch({"type": "search", "query": "test"}),
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "function_call"
assert msg.contents[0].name == "web_search"
def test_computer_call(self) -> None:
from azure.ai.agentserver.responses.models import ComputerAction, OutputItemComputerToolCall
item = OutputItemComputerToolCall({
"type": "computer_call",
"id": "cc-1",
"call_id": "call_cc",
"action": ComputerAction({"type": "click"}),
"pending_safety_checks": [],
"status": "completed",
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "function_call"
assert msg.contents[0].name == "computer_use"
def test_computer_call_output(self) -> None:
from azure.ai.agentserver.responses.models import (
ComputerScreenshotImage,
OutputItemComputerToolCallOutputResource,
)
item = OutputItemComputerToolCallOutputResource({
"type": "computer_call_output",
"call_id": "call_cc",
"output": ComputerScreenshotImage({
"type": "computer_screenshot",
"image_url": "data:image/png;base64,abc",
}),
})
msg = _to_message(item)
assert msg.role == "tool"
assert msg.contents[0].type == "function_result"
assert msg.contents[0].call_id == "call_cc"
def test_custom_tool_call(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemCustomToolCall
item = OutputItemCustomToolCall({
"type": "custom_tool_call",
"call_id": "call_ct",
"name": "my_tool",
"input": '{"key": "value"}',
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "function_call"
assert msg.contents[0].name == "my_tool"
assert msg.contents[0].arguments == '{"key": "value"}'
def test_custom_tool_call_output(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemCustomToolCallOutput
item = OutputItemCustomToolCallOutput({
"type": "custom_tool_call_output",
"call_id": "call_ct",
"output": "result text",
})
msg = _to_message(item)
assert msg.role == "tool"
assert msg.contents[0].type == "function_result"
assert msg.contents[0].result == "result text"
def test_apply_patch_call(self) -> None:
from azure.ai.agentserver.responses.models import ApplyPatchUpdateFileOperation, OutputItemApplyPatchToolCall
item = OutputItemApplyPatchToolCall({
"type": "apply_patch_call",
"id": "ap-1",
"call_id": "call_ap",
"status": "completed",
"operation": ApplyPatchUpdateFileOperation({
"type": "update_file",
"path": "file.py",
"diff": "+ new line",
}),
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "function_call"
assert msg.contents[0].name == "apply_patch"
def test_apply_patch_call_output(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemApplyPatchToolCallOutput
item = OutputItemApplyPatchToolCallOutput({
"type": "apply_patch_call_output",
"id": "apo-1",
"call_id": "call_ap",
"status": "completed",
"output": "patch applied",
})
msg = _to_message(item)
assert msg.role == "tool"
assert msg.contents[0].type == "function_result"
assert msg.contents[0].result == "patch applied"
def test_oauth_consent_request(self) -> None:
from azure.ai.agentserver.responses.models import OAuthConsentRequestOutputItem
item = OAuthConsentRequestOutputItem({
"type": "oauth_consent_request",
"id": "oauth-1",
"consent_link": "https://example.com/consent",
"server_label": "my_server",
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "oauth_consent_request"
assert msg.contents[0].consent_link == "https://example.com/consent"
def test_structured_outputs_dict(self) -> None:
from azure.ai.agentserver.responses.models import StructuredOutputsOutputItem
item = StructuredOutputsOutputItem({"type": "structured_outputs", "id": "so-1", "output": {"answer": 42}})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "text"
assert json.loads(msg.contents[0].text or "") == {"answer": 42}
def test_structured_outputs_string(self) -> None:
from azure.ai.agentserver.responses.models import StructuredOutputsOutputItem
item = StructuredOutputsOutputItem({"type": "structured_outputs", "id": "so-2", "output": "plain text"})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].text == "plain text"
def test_unsupported_type_raises(self) -> None:
from azure.ai.agentserver.responses.models import OutputItem
item = OutputItem({"type": "some_unknown_type"})
with pytest.raises(ValueError, match="Unsupported OutputItem type: some_unknown_type"):
_to_message(item)
# endregion