Python: [BREAKING] updated structure and samples (#875)

* updated structure and samples

* updated names and removed cross tests

* updated projects etc

* updated tests

* updated test

* test fixes

* removed devui for now

* updated all-tests task

* removed old style configs

* remove coverage from tests

* updated to unit tests with all-tests

* updated foundry everywhere

* fix azure ai tests

* fix merge tests

* fix mypy
This commit is contained in:
Eduard van Valkenburg
2025-09-25 09:02:53 +02:00
committed by GitHub
Unverified
parent 366a7f7d47
commit 9355329dfd
169 changed files with 1159 additions and 1761 deletions
-21
View File
@@ -1,21 +0,0 @@
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
-9
View File
@@ -1,9 +0,0 @@
# Get Started with Microsoft Agent Framework Foundry
Please install this package as the extra for `agent-framework`:
```bash
pip install agent-framework[foundry]
```
and see the [README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) for more information.
@@ -1,16 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._chat_client import FoundryChatClient, FoundrySettings
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"FoundryChatClient",
"FoundrySettings",
"__version__",
]
@@ -1,893 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import os
import sys
from collections.abc import AsyncIterable, MutableMapping, MutableSequence
from typing import Any, ClassVar, TypeVar
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
AIFunction,
BaseChatClient,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
ChatToolMode,
Contents,
DataContent,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
HostedCodeInterpreterTool,
HostedFileContent,
HostedFileSearchTool,
HostedMCPTool,
HostedVectorStoreContent,
HostedWebSearchTool,
Role,
TextContent,
ToolProtocol,
UriContent,
UsageContent,
UsageDetails,
get_logger,
use_function_invocation,
)
from agent_framework._pydantic import AFBaseSettings
from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException
from agent_framework.observability import use_observability
from azure.ai.agents.models import (
AgentsNamedToolChoice,
AgentsNamedToolChoiceType,
AgentsToolChoiceOptionMode,
AgentStreamEvent,
AsyncAgentEventHandler,
AsyncAgentRunStream,
AzureAISearchQueryType,
AzureAISearchTool,
BingCustomSearchTool,
BingGroundingTool,
CodeInterpreterToolDefinition,
FileSearchTool,
FunctionName,
FunctionToolOutput,
ListSortOrder,
McpTool,
MessageDeltaChunk,
MessageImageUrlParam,
MessageInputContentBlock,
MessageInputImageUrlBlock,
MessageInputTextBlock,
MessageRole,
RequiredFunctionToolCall,
RequiredMcpToolCall,
ResponseFormatJsonSchema,
ResponseFormatJsonSchemaType,
RunStatus,
RunStep,
RunStepDeltaChunk,
RunStepDeltaCodeInterpreterDetailItemObject,
RunStepDeltaCodeInterpreterImageOutput,
RunStepDeltaCodeInterpreterLogOutput,
SubmitToolApprovalAction,
SubmitToolOutputsAction,
ThreadMessageOptions,
ThreadRun,
ToolApproval,
ToolDefinition,
ToolOutput,
)
from azure.ai.projects.aio import AIProjectClient
from azure.ai.projects.models import ConnectionType
from azure.core.credentials_async import AsyncTokenCredential
from azure.core.exceptions import HttpResponseError
from pydantic import BaseModel, Field, PrivateAttr, ValidationError
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
logger = get_logger("agent_framework.foundry")
class FoundrySettings(AFBaseSettings):
"""Foundry model settings.
The settings are first loaded from environment variables with the prefix 'FOUNDRY_'.
If the environment variables are not found, the settings can be loaded from a .env file
with the encoding 'utf-8'. If the settings are not found in the .env file, the settings
are ignored; however, validation will fail alerting that the settings are missing.
Attributes:
project_endpoint: The Azure AI Foundry project endpoint URL.
(Env var FOUNDRY_PROJECT_ENDPOINT)
model_deployment_name: The name of the model deployment to use.
(Env var FOUNDRY_MODEL_DEPLOYMENT_NAME)
agent_name: Default name for automatically created agents.
(Env var FOUNDRY_AGENT_NAME)
Parameters:
env_file_path: If provided, the .env settings are read from this file path location.
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
"""
env_prefix: ClassVar[str] = "FOUNDRY_"
project_endpoint: str | None = None
model_deployment_name: str | None = None
agent_name: str | None = "UnnamedAgent"
TFoundryChatClient = TypeVar("TFoundryChatClient", bound="FoundryChatClient")
@use_function_invocation
@use_observability
class FoundryChatClient(BaseChatClient):
"""Azure AI Foundry Chat client."""
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.foundry" # type: ignore[reportIncompatibleVariableOverride, misc]
client: AIProjectClient = Field(...)
credential: AsyncTokenCredential | None = Field(...)
agent_id: str | None = Field(default=None)
agent_name: str | None = Field(default=None)
ai_model_id: str | None = Field(default=None)
thread_id: str | None = Field(default=None)
_should_delete_agent: bool = PrivateAttr(default=False) # Track whether we should delete the agent
_should_close_client: bool = PrivateAttr(default=False) # Track whether we should close client connection
def __init__(
self,
*,
client: AIProjectClient | None = None,
agent_id: str | None = None,
agent_name: str | None = None,
thread_id: str | None = None,
project_endpoint: str | None = None,
model_deployment_name: str | None = None,
async_credential: AsyncTokenCredential | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize a FoundryChatClient.
Args:
client: An existing AIProjectClient to use. If not provided, one will be created.
agent_id: The ID of an existing agent to use. If not provided and client is provided,
a new agent will be created (and deleted after the request). If neither client
nor agent_id is provided, both will be created and managed automatically.
agent_name: The name to use when creating new agents.
thread_id: Default thread ID to use for conversations. Can be overridden by
conversation_id property, when making a request.
project_endpoint: The Azure AI Foundry project endpoint URL. Used if client is not provided.
model_deployment_name: The model deployment name to use for agent creation.
async_credential: Azure async credential to use for authentication.
setup_tracing: Whether to setup tracing for the client. Defaults to True.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
**kwargs: Additional keyword arguments passed to the parent class.
"""
try:
foundry_settings = FoundrySettings(
project_endpoint=project_endpoint,
model_deployment_name=model_deployment_name,
agent_name=agent_name,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create Foundry settings.", ex) from ex
# If no client is provided, create one
should_close_client = False
if client is None:
if not foundry_settings.project_endpoint:
raise ServiceInitializationError(
"Foundry project endpoint is required. Set via 'project_endpoint' parameter "
"or 'FOUNDRY_PROJECT_ENDPOINT' environment variable."
)
if agent_id is None and not foundry_settings.model_deployment_name:
raise ServiceInitializationError(
"Foundry model deployment name is required. Set via 'model_deployment_name' parameter "
"or 'FOUNDRY_MODEL_DEPLOYMENT_NAME' environment variable."
)
# Use provided credential
if not async_credential:
raise ServiceInitializationError("Azure credential is required when client is not provided.")
client = AIProjectClient(
endpoint=foundry_settings.project_endpoint,
credential=async_credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
should_close_client = True
super().__init__(
client=client, # type: ignore[reportCallIssue]
credential=async_credential, # type: ignore[reportCallIssue]
agent_id=agent_id, # type: ignore[reportCallIssue]
thread_id=thread_id, # type: ignore[reportCallIssue]
agent_name=foundry_settings.agent_name, # type: ignore[reportCallIssue]
ai_model_id=foundry_settings.model_deployment_name, # type: ignore[reportCallIssue]
**kwargs,
)
self._should_close_client = should_close_client
async def setup_foundry_observability(self, enable_live_metrics: bool = False) -> None:
"""Call this method to setup tracing with Foundry.
This will take the connection string from the project client.
It will override any connection string that is set in the environment variables.
It will disable any OTLP endpoint that might have been set.
"""
from agent_framework.observability import setup_observability
setup_observability(
applicationinsights_connection_string=await self.client.telemetry.get_application_insights_connection_string(), # noqa: E501
enable_live_metrics=enable_live_metrics,
)
async def __aenter__(self) -> "Self":
"""Async context manager entry."""
return self
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
"""Async context manager exit - clean up any agents we created."""
await self.close()
async def close(self) -> None:
"""Close the client and clean up any agents we created."""
await self._cleanup_agent_if_needed()
await self._close_client_if_needed()
@classmethod
def from_dict(cls: type[TFoundryChatClient], settings: dict[str, Any]) -> TFoundryChatClient:
"""Initialize a FoundryChatClient from a dictionary of settings.
Args:
settings: A dictionary of settings for the service.
"""
return cls(
client=settings.get("client"),
agent_id=settings.get("agent_id"),
thread_id=settings.get("thread_id"),
project_endpoint=settings.get("project_endpoint"),
model_deployment_name=settings.get("model_deployment_name"),
agent_name=settings.get("agent_name"),
credential=settings.get("credential"),
env_file_path=settings.get("env_file_path"),
)
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> ChatResponse:
return await ChatResponse.from_chat_response_generator(
updates=self._inner_get_streaming_response(messages=messages, chat_options=chat_options, **kwargs)
)
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
# Extract necessary state from messages and options
run_options, required_action_results = await self._create_run_options(messages, chat_options, **kwargs)
# Get the thread ID
thread_id: str | None = (
chat_options.conversation_id
if chat_options.conversation_id is not None
else run_options.get("conversation_id", self.thread_id)
)
if thread_id is None and required_action_results is not None:
raise ValueError("No thread ID was provided, but chat messages includes tool results.")
# Determine which agent to use and create if needed
agent_id = await self._get_agent_id_or_create(run_options)
# Process and yield each update from the stream
async for update in self._process_stream(
*(await self._create_agent_stream(thread_id, agent_id, run_options, required_action_results))
):
yield update
async def _get_agent_id_or_create(self, run_options: dict[str, Any] | None = None) -> str:
"""Determine which agent to use and create if needed.
Returns:
str: The agent_id to use
"""
# If no agent_id is provided, create a temporary agent
if self.agent_id is None:
if not self.ai_model_id:
raise ServiceInitializationError("Model deployment name is required for agent creation.")
agent_name = self.agent_name
args = {"model": self.ai_model_id, "name": agent_name}
if run_options:
if "tools" in run_options:
args["tools"] = run_options["tools"]
if "instructions" in run_options:
args["instructions"] = run_options["instructions"]
if "response_format" in run_options:
args["response_format"] = run_options["response_format"]
created_agent = await self.client.agents.create_agent(**args) # type: ignore[arg-type]
self.agent_id = created_agent.id
self._should_delete_agent = True
return self.agent_id
async def _create_agent_stream(
self,
thread_id: str | None,
agent_id: str,
run_options: dict[str, Any],
required_action_results: list[FunctionResultContent | FunctionApprovalResponseContent] | None,
) -> tuple[AsyncAgentRunStream[AsyncAgentEventHandler[Any]] | AsyncAgentEventHandler[Any], str]:
"""Create the agent stream for processing.
Returns:
tuple: (stream, final_thread_id)
"""
# Get any active run for this thread
thread_run = await self._get_active_thread_run(thread_id)
stream: AsyncAgentRunStream[AsyncAgentEventHandler[Any]] | AsyncAgentEventHandler[Any]
handler: AsyncAgentEventHandler[Any] = AsyncAgentEventHandler()
tool_run_id, tool_outputs, tool_approvals = self._convert_required_action_to_tool_output(
required_action_results
)
if (
thread_run is not None
and tool_run_id is not None
and tool_run_id == thread_run.id
and (tool_outputs or tool_approvals)
): # type: ignore[reportUnknownMemberType]
# There's an active run and we have tool results to submit, so submit the results.
args: dict[str, Any] = {
"thread_id": thread_run.thread_id,
"run_id": tool_run_id,
"event_handler": handler,
}
if tool_outputs:
args["tool_outputs"] = tool_outputs
if tool_approvals:
args["tool_approvals"] = tool_approvals
await self.client.agents.runs.submit_tool_outputs_stream(**args) # type: ignore[reportUnknownMemberType]
# Pass the handler to the stream to continue processing
stream = handler # type: ignore
final_thread_id = thread_run.thread_id
else:
# Handle thread creation or cancellation
final_thread_id = await self._prepare_thread(thread_id, thread_run, run_options)
# Now create a new run and stream the results.
run_options.pop("conversation_id", None)
stream = await self.client.agents.runs.stream( # type: ignore[reportUnknownMemberType]
final_thread_id, agent_id=agent_id, **run_options
)
return stream, final_thread_id
async def _get_active_thread_run(self, thread_id: str | None) -> ThreadRun | None:
"""Get any active run for the given thread."""
if thread_id is None:
return None
async for run in self.client.agents.runs.list(thread_id=thread_id, limit=1, order=ListSortOrder.DESCENDING): # type: ignore[reportUnknownMemberType]
if run.status not in [
RunStatus.COMPLETED,
RunStatus.CANCELLED,
RunStatus.FAILED,
RunStatus.EXPIRED,
]:
return run
return None
async def _prepare_thread(
self, thread_id: str | None, thread_run: ThreadRun | None, run_options: dict[str, Any]
) -> str:
"""Prepare the thread for a new run, creating or cleaning up as needed."""
if thread_id is not None:
if thread_run is not None:
# There was an active run; we need to cancel it before starting a new run.
await self.client.agents.runs.cancel(thread_id, thread_run.id)
return thread_id
# No thread ID was provided, so create a new thread.
thread = await self.client.agents.threads.create(
tool_resources=run_options.get("tool_resources"), metadata=run_options.get("metadata")
)
thread_id = thread.id
# workaround for: https://github.com/Azure/azure-sdk-for-python/issues/42805
# this occurs when otel is enabled
# once fixed, in the function above, readd:
# `messages=run_options.pop("additional_messages")`
for msg in run_options.pop("additional_messages", []):
await self.client.agents.messages.create(
thread_id=thread_id, role=msg.role, content=msg.content, metadata=msg.metadata
)
# and remove until here.
return thread_id
async def _process_stream(
self, stream: AsyncAgentRunStream[AsyncAgentEventHandler[Any]] | AsyncAgentEventHandler[Any], thread_id: str
) -> AsyncIterable[ChatResponseUpdate]:
"""Process events from the stream iterator and yield ChatResponseUpdate objects."""
response_id: str | None = None
response_stream = await stream.__aenter__() if isinstance(stream, AsyncAgentRunStream) else stream # type: ignore[no-untyped-call]
try:
async for event_type, event_data, _ in response_stream: # type: ignore
match event_data:
case MessageDeltaChunk():
# only one event_type: AgentStreamEvent.THREAD_MESSAGE_DELTA
role = Role.USER if event_data.delta.role == MessageRole.USER else Role.ASSISTANT
yield ChatResponseUpdate(
role=role,
text=event_data.text,
conversation_id=thread_id,
message_id=response_id,
raw_representation=event_data,
response_id=response_id,
)
case ThreadRun():
# possible event_types:
# AgentStreamEvent.THREAD_RUN_CREATED
# AgentStreamEvent.THREAD_RUN_QUEUED
# AgentStreamEvent.THREAD_RUN_INCOMPLETE
# AgentStreamEvent.THREAD_RUN_IN_PROGRESS
# AgentStreamEvent.THREAD_RUN_REQUIRES_ACTION
# AgentStreamEvent.THREAD_RUN_COMPLETED
# AgentStreamEvent.THREAD_RUN_FAILED
# AgentStreamEvent.THREAD_RUN_CANCELLING
# AgentStreamEvent.THREAD_RUN_CANCELLED
# AgentStreamEvent.THREAD_RUN_EXPIRED
match event_type:
case AgentStreamEvent.THREAD_RUN_REQUIRES_ACTION:
if event_data.required_action and event_data.required_action.type in [
"submit_tool_outputs",
"submit_tool_approval",
]:
contents = self._create_function_call_contents(event_data, response_id)
if contents:
yield ChatResponseUpdate(
role=Role.ASSISTANT,
contents=contents,
conversation_id=thread_id,
message_id=response_id,
raw_representation=event_data,
response_id=response_id,
)
case AgentStreamEvent.THREAD_RUN_FAILED:
raise ServiceResponseException(event_data.last_error.message)
case _:
yield ChatResponseUpdate(
contents=[],
conversation_id=event_data.thread_id,
message_id=response_id,
raw_representation=event_data,
response_id=response_id,
role=Role.ASSISTANT,
ai_model_id=event_data.model,
)
case RunStep():
# possible event_types:
# AgentStreamEvent.THREAD_RUN_STEP_CREATED,
# AgentStreamEvent.THREAD_RUN_STEP_IN_PROGRESS,
# AgentStreamEvent.THREAD_RUN_STEP_COMPLETED,
# AgentStreamEvent.THREAD_RUN_STEP_FAILED,
# AgentStreamEvent.THREAD_RUN_STEP_CANCELLED,
# AgentStreamEvent.THREAD_RUN_STEP_EXPIRED,
match event_type:
case AgentStreamEvent.THREAD_RUN_STEP_CREATED:
response_id = event_data.run_id
case AgentStreamEvent.THREAD_RUN_COMPLETED | AgentStreamEvent.THREAD_RUN_STEP_COMPLETED:
if event_data.usage:
usage_content = UsageContent(
UsageDetails(
input_token_count=event_data.usage.prompt_tokens,
output_token_count=event_data.usage.completion_tokens,
total_token_count=event_data.usage.total_tokens,
)
)
yield ChatResponseUpdate(
role=Role.ASSISTANT,
contents=[usage_content],
conversation_id=thread_id,
message_id=response_id,
raw_representation=event_data,
response_id=response_id,
)
case _:
yield ChatResponseUpdate(
contents=[],
conversation_id=thread_id,
message_id=response_id,
raw_representation=event_data,
response_id=response_id,
role=Role.ASSISTANT,
)
case RunStepDeltaChunk(): # type: ignore
if (
event_data.delta.step_details is not None
and event_data.delta.step_details.type == "tool_calls"
and event_data.delta.step_details.tool_calls is not None # type: ignore[attr-defined]
):
for tool_call in event_data.delta.step_details.tool_calls: # type: ignore[attr-defined]
if tool_call.type == "code_interpreter" and isinstance(
tool_call.code_interpreter,
RunStepDeltaCodeInterpreterDetailItemObject,
):
contents = []
if tool_call.code_interpreter.input is not None:
logger.debug(f"Code Interpreter Input: {tool_call.code_interpreter.input}")
if tool_call.code_interpreter.outputs is not None:
for output in tool_call.code_interpreter.outputs:
if isinstance(output, RunStepDeltaCodeInterpreterLogOutput) and output.logs:
contents.append(TextContent(text=output.logs))
if (
isinstance(output, RunStepDeltaCodeInterpreterImageOutput)
and output.image is not None
and output.image.file_id is not None
):
contents.append(HostedFileContent(file_id=output.image.file_id))
yield ChatResponseUpdate(
role=Role.ASSISTANT,
contents=contents,
conversation_id=thread_id,
message_id=response_id,
raw_representation=tool_call.code_interpreter,
response_id=response_id,
)
case _: # ThreadMessage or string
# possible event_types for ThreadMessage:
# AgentStreamEvent.THREAD_MESSAGE_CREATED
# AgentStreamEvent.THREAD_MESSAGE_IN_PROGRESS
# AgentStreamEvent.THREAD_MESSAGE_COMPLETED
# AgentStreamEvent.THREAD_MESSAGE_INCOMPLETE
yield ChatResponseUpdate(
contents=[],
conversation_id=thread_id,
message_id=response_id,
raw_representation=event_data, # type: ignore
response_id=response_id,
role=Role.ASSISTANT,
)
except Exception as ex:
logger.error(f"Error processing stream: {ex}")
raise
finally:
if isinstance(stream, AsyncAgentRunStream):
await stream.__aexit__(None, None, None) # type: ignore[no-untyped-call]
def _create_function_call_contents(self, event_data: ThreadRun, response_id: str | None) -> list[Contents]:
"""Create function call contents from a tool action event."""
if isinstance(event_data, ThreadRun) and event_data.required_action is not None:
if isinstance(event_data.required_action, SubmitToolOutputsAction):
return [
FunctionCallContent(
call_id=f'["{response_id}", "{tool.id}"]',
name=tool.function.name,
arguments=tool.function.arguments,
)
for tool in event_data.required_action.submit_tool_outputs.tool_calls
if isinstance(tool, RequiredFunctionToolCall)
]
if isinstance(event_data.required_action, SubmitToolApprovalAction):
return [
FunctionApprovalRequestContent(
id=f'["{response_id}", "{tool.id}"]',
function_call=FunctionCallContent(
call_id=f'["{response_id}", "{tool.id}"]',
name=tool.name,
arguments=tool.arguments,
raw_representation=tool,
),
raw_representation=tool,
)
for tool in event_data.required_action.submit_tool_approval.tool_calls
if isinstance(tool, RequiredMcpToolCall)
]
return []
async def _close_client_if_needed(self) -> None:
"""Close client session if we created it."""
if self._should_close_client:
await self.client.close()
async def _cleanup_agent_if_needed(self) -> None:
"""Clean up the agent if we created it."""
if self._should_delete_agent and self.agent_id is not None:
await self.client.agents.delete_agent(self.agent_id)
self.agent_id = None
self._should_delete_agent = False
async def _create_run_options(
self,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions | None,
**kwargs: Any,
) -> tuple[dict[str, Any], list[FunctionResultContent | FunctionApprovalResponseContent] | None]:
run_options: dict[str, Any] = {**kwargs}
if chat_options is not None:
run_options["max_completion_tokens"] = chat_options.max_tokens
run_options["model"] = chat_options.ai_model_id
run_options["top_p"] = chat_options.top_p
run_options["temperature"] = chat_options.temperature
run_options["parallel_tool_calls"] = chat_options.allow_multiple_tool_calls
if chat_options.tool_choice is not None:
if chat_options.tool_choice != "none" and chat_options.tools:
tool_definitions = await self._prep_tools(chat_options.tools)
if tool_definitions:
run_options["tools"] = tool_definitions
if chat_options.tool_choice == "none":
run_options["tool_choice"] = AgentsToolChoiceOptionMode.NONE
elif chat_options.tool_choice == "auto":
run_options["tool_choice"] = AgentsToolChoiceOptionMode.AUTO
elif (
isinstance(chat_options.tool_choice, ChatToolMode)
and chat_options.tool_choice == "required"
and chat_options.tool_choice.required_function_name is not None
):
run_options["tool_choice"] = AgentsNamedToolChoice(
type=AgentsNamedToolChoiceType.FUNCTION,
function=FunctionName(name=chat_options.tool_choice.required_function_name),
)
if chat_options.response_format is not None:
run_options["response_format"] = ResponseFormatJsonSchemaType(
json_schema=ResponseFormatJsonSchema(
name=chat_options.response_format.__name__,
schema=chat_options.response_format.model_json_schema(),
)
)
instructions: list[str] = []
required_action_results: list[FunctionResultContent | FunctionApprovalResponseContent] | None = None
additional_messages: list[ThreadMessageOptions] | None = None
# System/developer messages are turned into instructions, since there is no such message roles in Foundry.
# All other messages are added 1:1, treating assistant messages as agent messages
# and everything else as user messages.
for chat_message in messages:
if chat_message.role.value in ["system", "developer"]:
for text_content in [content for content in chat_message.contents if isinstance(content, TextContent)]:
instructions.append(text_content.text)
continue
message_contents: list[MessageInputContentBlock] = []
for content in chat_message.contents:
if isinstance(content, TextContent):
message_contents.append(MessageInputTextBlock(text=content.text))
elif isinstance(content, (DataContent, UriContent)) and content.has_top_level_media_type("image"):
message_contents.append(MessageInputImageUrlBlock(image_url=MessageImageUrlParam(url=content.uri)))
elif isinstance(content, (FunctionResultContent, FunctionApprovalResponseContent)):
if required_action_results is None:
required_action_results = []
required_action_results.append(content)
elif isinstance(content.raw_representation, MessageInputContentBlock):
message_contents.append(content.raw_representation)
if len(message_contents) > 0:
if additional_messages is None:
additional_messages = []
additional_messages.append(
ThreadMessageOptions(
role=MessageRole.AGENT if chat_message.role == Role.ASSISTANT else MessageRole.USER,
content=message_contents,
)
)
if additional_messages is not None:
run_options["additional_messages"] = additional_messages
if len(instructions) > 0:
run_options["instructions"] = "".join(instructions)
return run_options, required_action_results
async def _prep_tools(
self, tools: list["ToolProtocol | MutableMapping[str, Any]"]
) -> list[ToolDefinition | dict[str, Any]]:
"""Prepare tool definitions for the run options."""
tool_definitions: list[ToolDefinition | dict[str, Any]] = []
for tool in tools:
match tool:
case AIFunction():
tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType]
case HostedWebSearchTool():
additional_props = tool.additional_properties or {}
config_args: dict[str, Any] = {}
if count := additional_props.get("count"):
config_args["count"] = count
if freshness := additional_props.get("freshness"):
config_args["freshness"] = freshness
if market := additional_props.get("market"):
config_args["market"] = market
if set_lang := additional_props.get("set_lang"):
config_args["set_lang"] = set_lang
# Bing Grounding
connection_id = additional_props.get("connection_id") or os.getenv("BING_CONNECTION_ID")
# Custom Bing Search
custom_connection_name = additional_props.get("custom_connection_name") or os.getenv(
"BING_CUSTOM_CONNECTION_NAME"
)
custom_configuration_name = additional_props.get("custom_instance_name") or os.getenv(
"BING_CUSTOM_INSTANCE_NAME"
)
bing_search: BingGroundingTool | BingCustomSearchTool | None = None
if connection_id and not custom_connection_name and not custom_configuration_name:
bing_search = BingGroundingTool(connection_id=connection_id, **config_args)
if custom_connection_name and custom_configuration_name:
try:
bing_custom_connection = await self.client.connections.get(name=custom_connection_name)
except HttpResponseError as err:
raise ServiceInitializationError(
f"Bing custom connection '{custom_connection_name}' not found in Foundry.", err
) from err
else:
bing_search = BingCustomSearchTool(
connection_id=bing_custom_connection.id,
instance_name=custom_configuration_name,
**config_args,
)
if not bing_search:
raise ServiceInitializationError(
"Bing search tool requires either a 'connection_id' for Bing Grounding "
"or both 'custom_connection_name' and 'custom_instance_name' for Custom Bing Search. "
"These can be provided via the tool's additional_properties or environment variables: "
"'BING_CONNECTION_ID', 'BING_CUSTOM_CONNECTION_NAME', 'BING_CUSTOM_INSTANCE_NAME'"
)
tool_definitions.extend(bing_search.definitions)
case HostedCodeInterpreterTool():
tool_definitions.append(CodeInterpreterToolDefinition())
case HostedMCPTool():
tool_definitions.extend(
McpTool(
server_label=tool.name.replace(" ", "_"),
server_url=str(tool.url),
allowed_tools=list(tool.allowed_tools) if tool.allowed_tools else [],
).definitions
)
case HostedFileSearchTool():
vector_stores = [inp for inp in tool.inputs or [] if isinstance(inp, HostedVectorStoreContent)]
if vector_stores:
file_search = FileSearchTool(vector_store_ids=[vs.vector_store_id for vs in vector_stores])
tool_definitions.extend(file_search.definitions)
else:
additional_props = tool.additional_properties or {}
index_name = additional_props.get("index_name") or os.getenv("AZURE_AI_SEARCH_INDEX_NAME")
if not index_name:
raise ServiceInitializationError(
"File search tool requires at least one vector store input, for file search in Foundry "
"or an 'index_name' to use Azure AI Search, "
"in additional_properties or environment variable 'AZURE_AI_SEARCH_INDEX_NAME'."
)
try:
azs_conn_id = await self.client.connections.get_default(ConnectionType.AZURE_AI_SEARCH)
except HttpResponseError as err:
raise ServiceInitializationError(
"No default Azure AI Search connection found in Foundry. "
"Please create one or provide vector store inputs for the file search tool.",
err,
) from err
else:
query_type_enum = AzureAISearchQueryType.SIMPLE
if query_type := additional_props.get("query_type"):
try:
query_type_enum = AzureAISearchQueryType(query_type)
except ValueError as ex:
raise ServiceInitializationError(
f"Invalid query_type '{query_type}' for Azure AI Search. "
f"Valid values are: {[qt.value for qt in AzureAISearchQueryType]}",
ex,
) from ex
ai_search = AzureAISearchTool(
index_connection_id=azs_conn_id.id,
index_name=index_name,
query_type=query_type_enum,
top_k=additional_props.get("top_k", 3),
filter=additional_props.get("filter", ""),
)
tool_definitions.extend(ai_search.definitions)
case dict():
tool_definitions.append(tool)
case _:
raise ServiceInitializationError(f"Unsupported tool type: {type(tool)}")
return tool_definitions
def _convert_required_action_to_tool_output(
self,
required_action_results: list[FunctionResultContent | FunctionApprovalResponseContent] | None,
) -> tuple[str | None, list[ToolOutput] | None, list[ToolApproval] | None]:
run_id: str | None = None
tool_outputs: list[ToolOutput] | None = None
tool_approvals: list[ToolApproval] | None = None
if required_action_results:
for content in required_action_results:
# When creating the FunctionCallContent/ApprovalRequestContent,
# we created it with a CallId == [runId, callId].
# We need to extract the run ID and ensure that the Output/Approval we send back to Azure
# is only the call ID.
run_and_call_ids: list[str] = (
json.loads(content.call_id)
if isinstance(content, FunctionResultContent)
else json.loads(content.id)
)
if (
not run_and_call_ids
or len(run_and_call_ids) != 2
or not run_and_call_ids[0]
or not run_and_call_ids[1]
or (run_id is not None and run_id != run_and_call_ids[0])
):
continue
run_id = run_and_call_ids[0]
call_id = run_and_call_ids[1]
if isinstance(content, FunctionResultContent):
if tool_outputs is None:
tool_outputs = []
result_contents: list[Any] = ( # type: ignore
content.result if isinstance(content.result, list) else [content.result] # type: ignore
)
results: list[Any] = []
for item in result_contents:
if isinstance(item, BaseModel):
results.append(item.model_dump_json())
else:
results.append(json.dumps(item))
if len(results) == 1:
tool_outputs.append(FunctionToolOutput(tool_call_id=call_id, output=results[0]))
else:
tool_outputs.append(FunctionToolOutput(tool_call_id=call_id, output=json.dumps(results)))
elif isinstance(content, FunctionApprovalResponseContent):
if tool_approvals is None:
tool_approvals = []
tool_approvals.append(ToolApproval(tool_call_id=call_id, approve=content.approved))
return run_id, tool_outputs, tool_approvals
def _update_agent_name(self, agent_name: str | None) -> None:
"""Update the agent name in the chat client.
Args:
agent_name: The new name for the agent.
"""
# This is a no-op in the base class, but can be overridden by subclasses
# to update the agent name in the client.
if agent_name and not self.agent_name:
self.agent_name = agent_name
def service_url(self) -> str:
"""Get the service URL for the chat client.
Returns:
The service URL for the chat client, or None if not set.
"""
return self.client._config.endpoint
-89
View File
@@ -1,89 +0,0 @@
[project]
name = "agent-framework-foundry"
description = "Azure AI Foundry integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "SK-Support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "0.1.0b1"
license-files = ["LICENSE"]
urls.homepage = "https://learn.microsoft.com/en-us/semantic-kernel/overview/"
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 :: 5 - Production/Stable",
"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",
"Framework :: Pydantic :: 2",
"Typing :: Typed",
]
dependencies = [
"agent-framework",
"azure-ai-projects >= 1.0.0b11",
"azure-ai-agents >= 1.2.0b4",
"aiohttp ~= 3.8",
]
[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
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extend = "../../pyproject.toml"
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"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_foundry"
test = "pytest --cov=agent_framework_foundry --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.9,<4.0"]
build-backend = "flit_core.buildapi"
-80
View File
@@ -1,80 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from pytest import fixture
@fixture
def exclude_list(request: Any) -> list[str]:
"""Fixture that returns a list of environment variables to exclude."""
return request.param if hasattr(request, "param") else []
@fixture
def override_env_param_dict(request: Any) -> dict[str, str]:
"""Fixture that returns a dict of environment variables to override."""
return request.param if hasattr(request, "param") else {}
@fixture()
def foundry_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore
"""Fixture to set environment variables for FoundrySettings."""
if exclude_list is None:
exclude_list = []
if override_env_param_dict is None:
override_env_param_dict = {}
env_vars = {
"FOUNDRY_PROJECT_ENDPOINT": "https://test-project.cognitiveservices.azure.com/",
"FOUNDRY_MODEL_DEPLOYMENT_NAME": "test-gpt-4o",
"FOUNDRY_AGENT_NAME": "TestAgent",
}
env_vars.update(override_env_param_dict) # type: ignore
for key, value in env_vars.items():
if key in exclude_list:
monkeypatch.delenv(key, raising=False) # type: ignore
continue
monkeypatch.setenv(key, value) # type: ignore
return env_vars
@fixture
def mock_ai_project_client() -> MagicMock:
"""Fixture that provides a mock AIProjectClient."""
mock_client = MagicMock()
# Mock agents property
mock_client.agents = MagicMock()
mock_client.agents.create_agent = AsyncMock()
mock_client.agents.delete_agent = AsyncMock()
# Mock agent creation response
mock_agent = MagicMock()
mock_agent.id = "test-agent-id"
mock_client.agents.create_agent.return_value = mock_agent
# Mock threads property
mock_client.agents.threads = MagicMock()
mock_client.agents.threads.create = AsyncMock()
mock_client.agents.messages.create = AsyncMock()
# Mock runs property
mock_client.agents.runs = MagicMock()
mock_client.agents.runs.list = AsyncMock()
mock_client.agents.runs.cancel = AsyncMock()
mock_client.agents.runs.stream = AsyncMock()
mock_client.agents.runs.submit_tool_outputs_stream = AsyncMock()
return mock_client
@fixture
def mock_azure_credential() -> MagicMock:
"""Fixture that provides a mock AsyncTokenCredential."""
return MagicMock()
@@ -1,28 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
def test_self_through_main():
try:
from agent_framework.foundry import __version__
except ImportError:
__version__ = None
assert __version__ is not None
def test_self():
try:
from agent_framework_foundry import __version__
except ImportError:
__version__ = None
assert __version__ is not None
def test_agent_framework():
try:
from agent_framework import __version__
except ImportError:
__version__ = None
assert __version__ is not None
@@ -1,883 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from typing import Annotated
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import (
AgentRunResponse,
AgentRunResponseUpdate,
AgentThread,
ChatAgent,
ChatClientProtocol,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
FunctionResultContent,
HostedCodeInterpreterTool,
MCPStreamableHTTPTool,
Role,
TextContent,
UriContent,
)
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.foundry import FoundryChatClient, FoundrySettings
from azure.ai.agents.models import (
RequiredFunctionToolCall,
SubmitToolOutputsAction,
ThreadRun,
)
from azure.core.credentials_async import AsyncTokenCredential
from azure.identity.aio import AzureCliCredential
from pydantic import Field, ValidationError
skip_if_foundry_integration_tests_disabled = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true"
or os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/"),
reason="No real FOUNDRY_PROJECT_ENDPOINT provided; skipping integration tests."
if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
else "Integration tests are disabled.",
)
def create_test_foundry_chat_client(
mock_ai_project_client: MagicMock,
agent_id: str | None = None,
thread_id: str | None = None,
foundry_settings: FoundrySettings | None = None,
should_delete_agent: bool = False,
) -> FoundryChatClient:
"""Helper function to create FoundryChatClient instances for testing, bypassing Pydantic validation."""
if foundry_settings is None:
foundry_settings = FoundrySettings(env_file_path="test.env")
return FoundryChatClient.model_construct(
client=mock_ai_project_client,
agent_id=agent_id,
thread_id=thread_id,
_should_delete_agent=should_delete_agent,
agent_name=foundry_settings.agent_name, # type: ignore[reportCallIssue]
ai_model_id=foundry_settings.model_deployment_name,
)
def test_foundry_settings_init(foundry_unit_test_env: dict[str, str]) -> None:
"""Test FoundrySettings initialization."""
settings = FoundrySettings()
assert settings.project_endpoint == foundry_unit_test_env["FOUNDRY_PROJECT_ENDPOINT"]
assert settings.model_deployment_name == foundry_unit_test_env["FOUNDRY_MODEL_DEPLOYMENT_NAME"]
assert settings.agent_name == foundry_unit_test_env["FOUNDRY_AGENT_NAME"]
def test_foundry_settings_init_with_explicit_values() -> None:
"""Test FoundrySettings initialization with explicit values."""
settings = FoundrySettings(
project_endpoint="https://custom-endpoint.com/",
model_deployment_name="custom-model",
agent_name="CustomAgent",
)
assert settings.project_endpoint == "https://custom-endpoint.com/"
assert settings.model_deployment_name == "custom-model"
assert settings.agent_name == "CustomAgent"
def test_foundry_chat_client_init_with_client(mock_ai_project_client: MagicMock) -> None:
"""Test FoundryChatClient initialization with existing client."""
chat_client = create_test_foundry_chat_client(
mock_ai_project_client, agent_id="existing-agent-id", thread_id="test-thread-id"
)
assert chat_client.client is mock_ai_project_client
assert chat_client.agent_id == "existing-agent-id"
assert chat_client.thread_id == "test-thread-id"
assert not chat_client._should_delete_agent # type: ignore
assert isinstance(chat_client, ChatClientProtocol)
def test_foundry_chat_client_init_auto_create_client(
foundry_unit_test_env: dict[str, str],
mock_ai_project_client: MagicMock,
) -> None:
"""Test FoundryChatClient initialization with auto-created client."""
foundry_settings = FoundrySettings(**foundry_unit_test_env) # type: ignore
chat_client = FoundryChatClient.model_construct(
client=mock_ai_project_client,
agent_id=None,
thread_id=None,
_should_delete_agent=False,
_foundry_settings=foundry_settings,
credential=None,
)
assert chat_client.client is mock_ai_project_client
assert chat_client.agent_id is None
assert not chat_client._should_delete_agent # type: ignore
def test_foundry_chat_client_init_missing_project_endpoint() -> None:
"""Test FoundryChatClient initialization when project_endpoint is missing and no client provided."""
# Mock FoundrySettings to return settings with None project_endpoint
with patch("agent_framework_foundry._chat_client.FoundrySettings") as mock_settings:
mock_settings_instance = MagicMock()
mock_settings_instance.project_endpoint = None # This should trigger the error
mock_settings_instance.model_deployment_name = "test-model"
mock_settings_instance.agent_name = "test-agent"
mock_settings.return_value = mock_settings_instance
with pytest.raises(ServiceInitializationError, match="project endpoint is required"):
FoundryChatClient(
client=None,
agent_id=None,
project_endpoint=None, # Missing endpoint
model_deployment_name="test-model",
async_credential=AsyncMock(spec=AsyncTokenCredential),
)
def test_foundry_chat_client_init_missing_model_deployment_for_agent_creation() -> None:
"""Test FoundryChatClient initialization when model deployment is missing for agent creation."""
# Mock FoundrySettings to return settings with None model_deployment_name
with patch("agent_framework_foundry._chat_client.FoundrySettings") as mock_settings:
mock_settings_instance = MagicMock()
mock_settings_instance.project_endpoint = "https://test.com"
mock_settings_instance.model_deployment_name = None # This should trigger the error
mock_settings_instance.agent_name = "test-agent"
mock_settings.return_value = mock_settings_instance
with pytest.raises(ServiceInitializationError, match="model deployment name is required"):
FoundryChatClient(
client=None,
agent_id=None, # No existing agent
project_endpoint="https://test.com",
model_deployment_name=None, # Missing for agent creation
async_credential=AsyncMock(spec=AsyncTokenCredential),
)
def test_foundry_chat_client_from_dict(mock_ai_project_client: MagicMock) -> None:
"""Test FoundryChatClient.from_dict method."""
settings = {
"client": mock_ai_project_client,
"agent_id": "test-agent-id",
"thread_id": "test-thread-id",
"project_endpoint": "https://test-endpoint.com/",
"model_deployment_name": "test-model",
"agent_name": "TestAgent",
}
foundry_settings = FoundrySettings(
project_endpoint=settings["project_endpoint"],
model_deployment_name=settings["model_deployment_name"],
agent_name=settings["agent_name"],
)
chat_client: FoundryChatClient = create_test_foundry_chat_client(
mock_ai_project_client,
agent_id=settings["agent_id"], # type: ignore
thread_id=settings["thread_id"], # type: ignore
foundry_settings=foundry_settings,
)
assert chat_client.client is mock_ai_project_client
assert chat_client.agent_id == "test-agent-id"
assert chat_client.thread_id == "test-thread-id"
def test_foundry_chat_client_init_missing_credential(foundry_unit_test_env: dict[str, str]) -> None:
"""Test FoundryChatClient.__init__ when async_credential is missing and no client provided."""
with pytest.raises(ServiceInitializationError, match="Azure credential is required when client is not provided"):
FoundryChatClient(
client=None,
agent_id="existing-agent",
project_endpoint=foundry_unit_test_env["FOUNDRY_PROJECT_ENDPOINT"],
model_deployment_name=foundry_unit_test_env["FOUNDRY_MODEL_DEPLOYMENT_NAME"],
async_credential=None, # Missing credential
)
def test_foundry_chat_client_init_validation_error(mock_azure_credential: MagicMock) -> None:
"""Test that ValidationError in FoundrySettings is properly handled."""
with patch("agent_framework_foundry._chat_client.FoundrySettings") as mock_settings:
# Create a proper ValidationError with empty errors list and model dict
mock_settings.side_effect = ValidationError.from_exception_data("FoundrySettings", [])
with pytest.raises(ServiceInitializationError, match="Failed to create Foundry settings"):
FoundryChatClient(
project_endpoint="https://test.com",
model_deployment_name="test-model",
async_credential=mock_azure_credential,
)
async def test_foundry_chat_client_get_agent_id_or_create_existing_agent(
mock_ai_project_client: MagicMock,
) -> None:
"""Test _get_agent_id_or_create when agent_id is already provided."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client, agent_id="existing-agent-id")
agent_id = await chat_client._get_agent_id_or_create() # type: ignore
assert agent_id == "existing-agent-id"
assert not chat_client._should_delete_agent # type: ignore
async def test_foundry_chat_client_get_agent_id_or_create_create_new(
mock_ai_project_client: MagicMock,
foundry_unit_test_env: dict[str, str],
) -> None:
"""Test _get_agent_id_or_create when creating a new agent."""
foundry_settings = FoundrySettings(
model_deployment_name=foundry_unit_test_env["FOUNDRY_MODEL_DEPLOYMENT_NAME"], agent_name="TestAgent"
)
chat_client = create_test_foundry_chat_client(mock_ai_project_client, foundry_settings=foundry_settings)
agent_id = await chat_client._get_agent_id_or_create() # type: ignore
assert agent_id == "test-agent-id"
assert chat_client._should_delete_agent # type: ignore
async def test_foundry_chat_client_tool_results_without_thread_error_via_public_api(
mock_ai_project_client: MagicMock,
) -> None:
"""Test that tool results without thread ID raise error through public API."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client, agent_id="test-agent")
# Create messages with tool results but no thread/conversation ID
messages = [
ChatMessage(role=Role.USER, text="Hello"),
ChatMessage(
role=Role.TOOL, contents=[FunctionResultContent(call_id='["run_123", "call_456"]', result="Result")]
),
]
# This should raise ValueError when called through public API
with pytest.raises(ValueError, match="No thread ID was provided, but chat messages includes tool results"):
async for _ in chat_client.get_streaming_response(messages):
pass
async def test_foundry_chat_client_thread_management_through_public_api(mock_ai_project_client: MagicMock) -> None:
"""Test thread creation and management through public API."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client, agent_id="test-agent")
mock_thread = MagicMock()
mock_thread.id = "new-thread-456"
mock_ai_project_client.agents.threads.create = AsyncMock(return_value=mock_thread)
mock_stream = AsyncMock()
mock_ai_project_client.agents.runs.stream = AsyncMock(return_value=mock_stream)
# Create an async iterator that yields nothing (empty stream)
async def empty_async_iter():
return
yield # Make this a generator (unreachable)
mock_stream.__aenter__ = AsyncMock(return_value=empty_async_iter())
mock_stream.__aexit__ = AsyncMock(return_value=None)
messages = [ChatMessage(role=Role.USER, text="Hello")]
# Call without existing thread - should create new one
response = chat_client.get_streaming_response(messages)
# Consume the generator to trigger the method execution
async for _ in response:
pass
# Verify thread creation was called
mock_ai_project_client.agents.threads.create.assert_called_once()
@pytest.mark.parametrize("exclude_list", [["FOUNDRY_MODEL_DEPLOYMENT_NAME"]], indirect=True)
async def test_foundry_chat_client_get_agent_id_or_create_missing_model(
mock_ai_project_client: MagicMock, foundry_unit_test_env: dict[str, str]
) -> None:
"""Test _get_agent_id_or_create when model_deployment_name is missing."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client)
with pytest.raises(ServiceInitializationError, match="Model deployment name is required"):
await chat_client._get_agent_id_or_create() # type: ignore
async def test_foundry_chat_client_cleanup_agent_if_needed_should_delete(
mock_ai_project_client: MagicMock,
) -> None:
"""Test _cleanup_agent_if_needed when agent should be deleted."""
chat_client = create_test_foundry_chat_client(
mock_ai_project_client, agent_id="agent-to-delete", should_delete_agent=True
)
await chat_client._cleanup_agent_if_needed() # type: ignore
# Verify agent deletion was called
mock_ai_project_client.agents.delete_agent.assert_called_once_with("agent-to-delete")
assert not chat_client._should_delete_agent # type: ignore
async def test_foundry_chat_client_cleanup_agent_if_needed_should_not_delete(
mock_ai_project_client: MagicMock,
) -> None:
"""Test _cleanup_agent_if_needed when agent should not be deleted."""
chat_client = create_test_foundry_chat_client(
mock_ai_project_client, agent_id="agent-to-keep", should_delete_agent=False
)
await chat_client._cleanup_agent_if_needed() # type: ignore
# Verify agent deletion was not called
mock_ai_project_client.agents.delete_agent.assert_not_called()
assert not chat_client._should_delete_agent # type: ignore
async def test_foundry_chat_client_cleanup_agent_if_needed_exception_handling(
mock_ai_project_client: MagicMock,
) -> None:
"""Test _cleanup_agent_if_needed propagates exceptions (it doesn't handle them)."""
chat_client = create_test_foundry_chat_client(
mock_ai_project_client, agent_id="agent-to-delete", should_delete_agent=True
)
mock_ai_project_client.agents.delete_agent.side_effect = Exception("Deletion failed")
with pytest.raises(Exception, match="Deletion failed"):
await chat_client._cleanup_agent_if_needed() # type: ignore
async def test_foundry_chat_client_aclose(mock_ai_project_client: MagicMock) -> None:
"""Test aclose method calls cleanup."""
chat_client = create_test_foundry_chat_client(
mock_ai_project_client, agent_id="agent-to-delete", should_delete_agent=True
)
await chat_client.close()
# Verify agent deletion was called
mock_ai_project_client.agents.delete_agent.assert_called_once_with("agent-to-delete")
async def test_foundry_chat_client_async_context_manager(mock_ai_project_client: MagicMock) -> None:
"""Test async context manager functionality."""
chat_client = create_test_foundry_chat_client(
mock_ai_project_client, agent_id="agent-to-delete", should_delete_agent=True
)
# Test context manager
async with chat_client:
pass # Just test that we can enter and exit
# Verify cleanup was called on exit
mock_ai_project_client.agents.delete_agent.assert_called_once_with("agent-to-delete")
async def test_foundry_chat_client_create_run_options_basic(mock_ai_project_client: MagicMock) -> None:
"""Test _create_run_options with basic ChatOptions."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client)
messages = [ChatMessage(role=Role.USER, text="Hello")]
chat_options = ChatOptions(max_tokens=100, temperature=0.7)
run_options, tool_results = await chat_client._create_run_options(messages, chat_options) # type: ignore
assert run_options is not None
assert tool_results is None
async def test_foundry_chat_client_create_run_options_no_chat_options(mock_ai_project_client: MagicMock) -> None:
"""Test _create_run_options with no ChatOptions."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client)
messages = [ChatMessage(role=Role.USER, text="Hello")]
run_options, tool_results = await chat_client._create_run_options(messages, None) # type: ignore
assert run_options is not None
assert tool_results is None
async def test_foundry_chat_client_create_run_options_with_image_content(mock_ai_project_client: MagicMock) -> None:
"""Test _create_run_options with image content."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client, agent_id="test-agent")
image_content = UriContent(uri="https://example.com/image.jpg", media_type="image/jpeg")
messages = [ChatMessage(role=Role.USER, contents=[image_content])]
run_options, _ = await chat_client._create_run_options(messages, None) # type: ignore
assert "additional_messages" in run_options
assert len(run_options["additional_messages"]) == 1
# Verify image was converted to MessageInputImageUrlBlock
message = run_options["additional_messages"][0]
assert len(message.content) == 1
def test_foundry_chat_client_convert_function_results_to_tool_output_none(mock_ai_project_client: MagicMock) -> None:
"""Test _convert_required_action_to_tool_output with None input."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client)
run_id, tool_outputs, tool_approvals = chat_client._convert_required_action_to_tool_output(None) # type: ignore
assert run_id is None
assert tool_outputs is None
assert tool_approvals is None
async def test_foundry_chat_client_close_client_when_should_close_true(mock_ai_project_client: MagicMock) -> None:
"""Test _close_client_if_needed closes client when should_close_client is True."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client)
chat_client._should_close_client = True # type: ignore
mock_ai_project_client.close = AsyncMock()
await chat_client._close_client_if_needed() # type: ignore
mock_ai_project_client.close.assert_called_once()
async def test_foundry_chat_client_close_client_when_should_close_false(mock_ai_project_client: MagicMock) -> None:
"""Test _close_client_if_needed does not close client when should_close_client is False."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client)
chat_client._should_close_client = False # type: ignore
await chat_client._close_client_if_needed() # type: ignore
mock_ai_project_client.close.assert_not_called()
def test_foundry_chat_client_update_agent_name_when_current_is_none(mock_ai_project_client: MagicMock) -> None:
"""Test _update_agent_name updates name when current agent_name is None."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client)
chat_client.agent_name = None # type: ignore
chat_client._update_agent_name("NewAgentName") # type: ignore
assert chat_client.agent_name == "NewAgentName"
def test_foundry_chat_client_update_agent_name_when_current_exists(mock_ai_project_client: MagicMock) -> None:
"""Test _update_agent_name does not update when current agent_name exists."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client)
chat_client.agent_name = "ExistingName" # type: ignore
chat_client._update_agent_name("NewAgentName") # type: ignore
assert chat_client.agent_name == "ExistingName"
def test_foundry_chat_client_update_agent_name_with_none_input(mock_ai_project_client: MagicMock) -> None:
"""Test _update_agent_name with None input."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client)
chat_client.agent_name = None # type: ignore
chat_client._update_agent_name(None) # type: ignore
assert chat_client.agent_name is None
async def test_foundry_chat_client_create_run_options_with_messages(mock_ai_project_client: MagicMock) -> None:
"""Test _create_run_options with different message types."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client)
# Test with system message (becomes instruction)
messages = [
ChatMessage(role=Role.SYSTEM, text="You are a helpful assistant"),
ChatMessage(role=Role.USER, text="Hello"),
]
run_options, _ = await chat_client._create_run_options(messages, None) # type: ignore
assert "instructions" in run_options
assert "You are a helpful assistant" in run_options["instructions"]
assert "additional_messages" in run_options
assert len(run_options["additional_messages"]) == 1 # Only user message
async def test_foundry_chat_client_inner_get_response(mock_ai_project_client: MagicMock) -> None:
"""Test _inner_get_response method."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client, agent_id="test-agent")
messages = [ChatMessage(role=Role.USER, text="Hello")]
chat_options = ChatOptions()
async def mock_streaming_response():
yield ChatResponseUpdate(role=Role.ASSISTANT, text="Hello back")
with (
patch.object(chat_client, "_inner_get_streaming_response", return_value=mock_streaming_response()),
patch("agent_framework.ChatResponse.from_chat_response_generator") as mock_from_generator,
):
mock_response = ChatResponse(role=Role.ASSISTANT, text="Hello back")
mock_from_generator.return_value = mock_response
result = await chat_client._inner_get_response(messages=messages, chat_options=chat_options) # type: ignore
assert result is mock_response
mock_from_generator.assert_called_once()
async def test_foundry_chat_client_get_agent_id_or_create_with_run_options(
mock_ai_project_client: MagicMock, foundry_unit_test_env: dict[str, str]
) -> None:
"""Test _get_agent_id_or_create with run_options containing tools and instructions."""
foundry_settings = FoundrySettings(
model_deployment_name=foundry_unit_test_env["FOUNDRY_MODEL_DEPLOYMENT_NAME"], agent_name="TestAgent"
)
chat_client = create_test_foundry_chat_client(mock_ai_project_client, foundry_settings=foundry_settings)
run_options = {
"tools": [{"type": "function", "function": {"name": "test_tool"}}],
"instructions": "Test instructions",
"response_format": {"type": "json_object"},
}
agent_id = await chat_client._get_agent_id_or_create(run_options) # type: ignore
assert agent_id == "test-agent-id"
# Verify create_agent was called with run_options parameters
mock_ai_project_client.agents.create_agent.assert_called_once()
call_args = mock_ai_project_client.agents.create_agent.call_args[1]
assert "tools" in call_args
assert "instructions" in call_args
assert "response_format" in call_args
async def test_foundry_chat_client_prepare_thread_cancels_active_run(mock_ai_project_client: MagicMock) -> None:
"""Test _prepare_thread cancels active thread run when provided."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client, agent_id="test-agent")
mock_thread_run = MagicMock()
mock_thread_run.id = "run_123"
mock_thread_run.thread_id = "test-thread"
run_options = {"additional_messages": []} # type: ignore
result = await chat_client._prepare_thread("test-thread", mock_thread_run, run_options) # type: ignore
assert result == "test-thread"
mock_ai_project_client.agents.runs.cancel.assert_called_once_with("test-thread", "run_123")
def test_foundry_chat_client_create_function_call_contents_basic(mock_ai_project_client: MagicMock) -> None:
"""Test _create_function_call_contents with basic function call."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client)
mock_tool_call = MagicMock(spec=RequiredFunctionToolCall)
mock_tool_call.id = "call_123"
mock_tool_call.function.name = "get_weather"
mock_tool_call.function.arguments = '{"location": "Seattle"}'
mock_submit_action = MagicMock(spec=SubmitToolOutputsAction)
mock_submit_action.submit_tool_outputs.tool_calls = [mock_tool_call]
mock_event_data = MagicMock(spec=ThreadRun)
mock_event_data.required_action = mock_submit_action
result = chat_client._create_function_call_contents(mock_event_data, "response_123") # type: ignore
assert len(result) == 1
assert isinstance(result[0], FunctionCallContent)
assert result[0].name == "get_weather"
assert result[0].call_id == '["response_123", "call_123"]'
def test_foundry_chat_client_create_function_call_contents_no_submit_action(mock_ai_project_client: MagicMock) -> None:
"""Test _create_function_call_contents when required_action is not SubmitToolOutputsAction."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client)
mock_event_data = MagicMock(spec=ThreadRun)
mock_event_data.required_action = MagicMock()
result = chat_client._create_function_call_contents(mock_event_data, "response_123") # type: ignore
assert result == []
def test_foundry_chat_client_create_function_call_contents_non_function_tool_call(
mock_ai_project_client: MagicMock,
) -> None:
"""Test _create_function_call_contents with non-function tool call."""
chat_client = create_test_foundry_chat_client(mock_ai_project_client)
mock_tool_call = MagicMock()
mock_submit_action = MagicMock(spec=SubmitToolOutputsAction)
mock_submit_action.submit_tool_outputs.tool_calls = [mock_tool_call]
mock_event_data = MagicMock(spec=ThreadRun)
mock_event_data.required_action = mock_submit_action
result = chat_client._create_function_call_contents(mock_event_data, "response_123") # type: ignore
assert result == []
def get_weather(
location: Annotated[str, Field(description="The location to get the weather for.")],
) -> str:
"""Get the weather for a given location."""
return f"The weather in {location} is sunny with a high of 25°C."
@skip_if_foundry_integration_tests_disabled
async def test_foundry_chat_client_get_response() -> None:
"""Test Foundry Chat Client response."""
async with FoundryChatClient(async_credential=AzureCliCredential()) as foundry_chat_client:
assert isinstance(foundry_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="The weather in Seattle is currently sunny with a high of 25°C. "
"It's a beautiful day for outdoor activities.",
)
)
messages.append(ChatMessage(role="user", text="What's the weather like today?"))
# Test that the client can be used to get a response
response = await foundry_chat_client.get_response(messages=messages)
assert response is not None
assert isinstance(response, ChatResponse)
assert any(word in response.text.lower() for word in ["sunny", "25"])
@skip_if_foundry_integration_tests_disabled
async def test_foundry_chat_client_get_response_tools() -> None:
"""Test Foundry Chat Client response with tools."""
async with FoundryChatClient(async_credential=AzureCliCredential()) as foundry_chat_client:
assert isinstance(foundry_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(ChatMessage(role="user", text="What's the weather like in Seattle?"))
# Test that the client can be used to get a response
response = await foundry_chat_client.get_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
)
assert response is not None
assert isinstance(response, ChatResponse)
assert any(word in response.text.lower() for word in ["sunny", "25"])
@skip_if_foundry_integration_tests_disabled
async def test_foundry_chat_client_streaming() -> None:
"""Test Foundry Chat Client streaming response."""
async with FoundryChatClient(async_credential=AzureCliCredential()) as foundry_chat_client:
assert isinstance(foundry_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="The weather in Seattle is currently sunny with a high of 25°C. "
"It's a beautiful day for outdoor activities.",
)
)
messages.append(ChatMessage(role="user", text="What's the weather like today?"))
# Test that the client can be used to get a response
response = foundry_chat_client.get_streaming_response(messages=messages)
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
assert any(word in full_message.lower() for word in ["sunny", "25"])
@skip_if_foundry_integration_tests_disabled
async def test_foundry_chat_client_streaming_tools() -> None:
"""Test Foundry Chat Client streaming response with tools."""
async with FoundryChatClient(async_credential=AzureCliCredential()) as foundry_chat_client:
assert isinstance(foundry_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(ChatMessage(role="user", text="What's the weather like in Seattle?"))
# Test that the client can be used to get a response
response = foundry_chat_client.get_streaming_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
)
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
assert any(word in full_message.lower() for word in ["sunny", "25"])
@skip_if_foundry_integration_tests_disabled
async def test_foundry_chat_client_agent_basic_run() -> None:
"""Test ChatAgent basic run functionality with FoundryChatClient."""
async with ChatAgent(
chat_client=FoundryChatClient(async_credential=AzureCliCredential()),
) as agent:
# Run a simple query
response = await agent.run("Hello! Please respond with 'Hello World' exactly.")
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
assert "Hello World" in response.text
@skip_if_foundry_integration_tests_disabled
async def test_foundry_chat_client_agent_basic_run_streaming() -> None:
"""Test ChatAgent basic streaming functionality with FoundryChatClient."""
async with ChatAgent(
chat_client=FoundryChatClient(async_credential=AzureCliCredential()),
) as agent:
# Run streaming query
full_message: str = ""
async for chunk in agent.run_stream("Please respond with exactly: 'This is a streaming response test.'"):
assert chunk is not None
assert isinstance(chunk, AgentRunResponseUpdate)
if chunk.text:
full_message += chunk.text
# Validate streaming response
assert len(full_message) > 0
assert "streaming response test" in full_message.lower()
@skip_if_foundry_integration_tests_disabled
async def test_foundry_chat_client_agent_thread_persistence() -> None:
"""Test ChatAgent thread persistence across runs with FoundryChatClient."""
async with ChatAgent(
chat_client=FoundryChatClient(async_credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as agent:
# Create a new thread that will be reused
thread = agent.get_new_thread()
# First message - establish context
first_response = await agent.run(
"Remember this number: 42. What number did I just tell you to remember?", thread=thread
)
assert isinstance(first_response, AgentRunResponse)
assert "42" in first_response.text
# Second message - test conversation memory
second_response = await agent.run(
"What number did I tell you to remember in my previous message?", thread=thread
)
assert isinstance(second_response, AgentRunResponse)
assert "42" in second_response.text
@skip_if_foundry_integration_tests_disabled
async def test_foundry_chat_client_agent_existing_thread_id() -> None:
"""Test ChatAgent existing thread ID functionality with FoundryChatClient."""
async with ChatAgent(
chat_client=FoundryChatClient(async_credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as first_agent:
# Start a conversation and get the thread ID
thread = first_agent.get_new_thread()
first_response = await first_agent.run("My name is Alice. Remember this.", thread=thread)
# Validate first response
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# The thread ID is set after the first response
existing_thread_id = thread.service_thread_id
assert existing_thread_id is not None
# Now continue with the same thread ID in a new agent instance
async with ChatAgent(
chat_client=FoundryChatClient(thread_id=existing_thread_id, async_credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as second_agent:
# Create a thread with the existing ID
thread = AgentThread(service_thread_id=existing_thread_id)
# Ask about the previous conversation
response2 = await second_agent.run("What is my name?", thread=thread)
# Validate that the agent remembers the previous conversation
assert isinstance(response2, AgentRunResponse)
assert response2.text is not None
# Should reference Alice from the previous conversation
assert "alice" in response2.text.lower()
@skip_if_foundry_integration_tests_disabled
async def test_foundry_chat_client_agent_code_interpreter():
"""Test ChatAgent with code interpreter through FoundryChatClient."""
async with ChatAgent(
chat_client=FoundryChatClient(async_credential=AzureCliCredential()),
instructions="You are a helpful assistant that can write and execute Python code.",
tools=[HostedCodeInterpreterTool()],
) as agent:
# Request code execution
response = await agent.run("Write Python code to calculate the factorial of 5 and show the result.")
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.text is not None
# Factorial of 5 is 120
assert "120" in response.text or "factorial" in response.text.lower()
@skip_if_foundry_integration_tests_disabled
async def test_foundry_chat_client_agent_with_mcp_tools() -> None:
"""Test MCP tools defined at agent creation with FoundryChatClient."""
async with ChatAgent(
chat_client=FoundryChatClient(async_credential=AzureCliCredential()),
name="DocsAgent",
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
tools=MCPStreamableHTTPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
),
) as agent:
# Test that the agent can use MCP tools to answer questions
response = await agent.run("What is Azure App Service?")
assert isinstance(response, AgentRunResponse)
assert response.text is not None
# Verify the response contains relevant information about Azure App Service
assert any(term in response.text.lower() for term in ["app service", "azure", "web", "application"])
@skip_if_foundry_integration_tests_disabled
async def test_foundry_chat_client_agent_level_tool_persistence():
"""Test that agent-level tools persist across multiple runs with FoundryChatClient."""
async with ChatAgent(
chat_client=FoundryChatClient(async_credential=AzureCliCredential()),
instructions="You are a helpful assistant that uses available tools.",
tools=[get_weather],
) as agent:
# First run - agent-level tool should be available
first_response = await agent.run("What's the weather like in Chicago?")
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Should use the agent-level weather tool
assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "25"])
# Second run - agent-level tool should still be available (persistence test)
second_response = await agent.run("What's the weather in Miami?")
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
# Should use the agent-level weather tool again
assert any(term in second_response.text.lower() for term in ["miami", "sunny", "25"])