mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
07f4c8a8d6
commit
ce8b6305d8
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user