mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Implemented FoundryChatClient (#193)
* Initial version of FoundryChatClient * Updates to the tool call streaming wrapper * Small fixes * Small updates and addressed PR feedback * Handle automatic client creation * Small improvement * Added credential parameter * Small improvements * Made FoundryChatClient disposable * Small fixes * Added unit tests * Refactored samples * Small improvements * Small fix * Addressed PR feedback * Small fixes * Small updates * Small fix * Addressed PR feedback
This commit is contained in:
committed by
GitHub
Unverified
parent
9287572b0d
commit
ccd7a44ec7
@@ -0,0 +1,16 @@
|
||||
# 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__",
|
||||
]
|
||||
@@ -0,0 +1,569 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
from collections.abc import AsyncIterable, MutableMapping, MutableSequence
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from agent_framework import (
|
||||
AFBaseSettings,
|
||||
AIContents,
|
||||
AITool,
|
||||
ChatClientBase,
|
||||
ChatMessage,
|
||||
ChatOptions,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
ChatRole,
|
||||
ChatToolMode,
|
||||
DataContent,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
TextContent,
|
||||
UriContent,
|
||||
UsageContent,
|
||||
UsageDetails,
|
||||
use_tool_calling,
|
||||
)
|
||||
from agent_framework._clients import tool_to_json_schema_spec
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from azure.ai.agents.models import (
|
||||
AgentsNamedToolChoice,
|
||||
AgentsNamedToolChoiceType,
|
||||
AgentsToolChoiceOptionMode,
|
||||
AgentStreamEvent,
|
||||
AsyncAgentEventHandler,
|
||||
AsyncAgentRunStream,
|
||||
FunctionName,
|
||||
ListSortOrder,
|
||||
MessageDeltaChunk,
|
||||
MessageImageUrlParam,
|
||||
MessageInputContentBlock,
|
||||
MessageInputImageUrlBlock,
|
||||
MessageInputTextBlock,
|
||||
MessageRole,
|
||||
RequiredFunctionToolCall,
|
||||
ResponseFormatJsonSchema,
|
||||
ResponseFormatJsonSchemaType,
|
||||
RunStatus,
|
||||
RunStep,
|
||||
SubmitToolOutputsAction,
|
||||
ThreadMessageOptions,
|
||||
ThreadRun,
|
||||
ToolOutput,
|
||||
)
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from pydantic import Field, PrivateAttr, ValidationError
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Optional settings for prefix 'FOUNDRY_' are:
|
||||
- project_endpoint: str | None - The Azure AI Foundry project endpoint URL.
|
||||
(Env var FOUNDRY_PROJECT_ENDPOINT)
|
||||
- model_deployment_name: str | None - The name of the model deployment to use.
|
||||
(Env var FOUNDRY_MODEL_DEPLOYMENT_NAME)
|
||||
- agent_name: str | None - Default name for automatically created agents.
|
||||
(Env var FOUNDRY_AGENT_NAME)
|
||||
- env_file_path: str | None - if provided, the .env settings are read from this file path location
|
||||
"""
|
||||
|
||||
env_prefix: ClassVar[str] = "FOUNDRY_"
|
||||
|
||||
project_endpoint: str | None = None
|
||||
model_deployment_name: str | None = None
|
||||
agent_name: str | None = "UnnamedAgent"
|
||||
|
||||
|
||||
@use_tool_calling
|
||||
class FoundryChatClient(ChatClientBase):
|
||||
client: AIProjectClient = Field(...)
|
||||
agent_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
|
||||
_foundry_settings: FoundrySettings = PrivateAttr()
|
||||
|
||||
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,
|
||||
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 from ChatOptions, 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.
|
||||
credential: Azure async credential to use for authentication. If not provided,
|
||||
DefaultAzureCredential will be used.
|
||||
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
|
||||
if client is None:
|
||||
if not foundry_settings.project_endpoint:
|
||||
raise ServiceInitializationError("Project endpoint is required when client is not provided.")
|
||||
|
||||
if agent_id is None and not foundry_settings.model_deployment_name:
|
||||
raise ServiceInitializationError("Model deployment name is required for agent creation.")
|
||||
|
||||
# Use provided credential or fallback to DefaultAzureCredential
|
||||
if credential is None:
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
|
||||
credential = DefaultAzureCredential()
|
||||
|
||||
client = AIProjectClient(endpoint=foundry_settings.project_endpoint, credential=credential)
|
||||
|
||||
super().__init__(
|
||||
client=client, # type: ignore[reportCallIssue]
|
||||
agent_id=agent_id, # type: ignore[reportCallIssue]
|
||||
thread_id=thread_id, # type: ignore[reportCallIssue]
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._should_delete_agent = False
|
||||
self._foundry_settings = foundry_settings
|
||||
|
||||
async def __aenter__(self) -> "FoundryChatClient":
|
||||
"""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()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, settings: dict[str, Any]) -> "FoundryChatClient":
|
||||
"""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, tool_results = 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 self.thread_id
|
||||
)
|
||||
|
||||
if thread_id is None and tool_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()
|
||||
|
||||
# Create the streaming response
|
||||
stream, thread_id = await self._create_agent_stream(thread_id, agent_id, run_options, tool_results)
|
||||
|
||||
# Process and yield each update from the stream
|
||||
async for update in self._process_stream_events(stream, thread_id):
|
||||
yield update
|
||||
|
||||
async def _get_agent_id_or_create(self) -> 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._foundry_settings.model_deployment_name:
|
||||
raise ServiceInitializationError("Model deployment name is required for agent creation.")
|
||||
|
||||
agent_name = self._foundry_settings.agent_name
|
||||
created_agent = await self.client.agents.create_agent(
|
||||
model=self._foundry_settings.model_deployment_name, name=agent_name
|
||||
)
|
||||
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],
|
||||
tool_results: list[FunctionResultContent] | 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)
|
||||
|
||||
handler: AsyncAgentEventHandler[Any] = AsyncAgentEventHandler()
|
||||
tool_run_id, tool_outputs = self._convert_function_results_to_tool_output(tool_results)
|
||||
|
||||
if thread_run is not None and tool_run_id is not None and tool_run_id == thread_run.id and tool_outputs:
|
||||
# There's an active run and we have tool results to submit, so submit the results.
|
||||
await self.client.agents.runs.submit_tool_outputs_stream( # type: ignore[reportUnknownMemberType]
|
||||
thread_run.thread_id, tool_run_id, tool_outputs=tool_outputs, event_handler=handler
|
||||
)
|
||||
# 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.
|
||||
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):
|
||||
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 None:
|
||||
# No thread ID was provided, so create a new thread.
|
||||
thread = await self.client.agents.threads.create(
|
||||
messages=run_options["additional_messages"],
|
||||
tool_resources=run_options.get("tool_resources"),
|
||||
metadata=run_options.get("metadata"),
|
||||
)
|
||||
run_options["additional_messages"] = []
|
||||
return thread.id
|
||||
|
||||
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
|
||||
|
||||
async def _process_stream_events(
|
||||
self,
|
||||
stream: AsyncAgentRunStream[AsyncAgentEventHandler[Any]] | AsyncAgentEventHandler[Any],
|
||||
thread_id: str,
|
||||
) -> AsyncIterable[ChatResponseUpdate]:
|
||||
"""Process events from the agent stream and yield ChatResponseUpdate objects."""
|
||||
response_id: str | None = None
|
||||
|
||||
if stream is not None:
|
||||
# Use 'async with' only if the stream supports async context management (main agent stream).
|
||||
# Tool output handlers only support async iteration, not context management.
|
||||
if isinstance(stream, contextlib.AbstractAsyncContextManager):
|
||||
async with stream as response_stream: # type: ignore
|
||||
async for update in self._process_stream_events_from_iterator(
|
||||
response_stream, thread_id, response_id
|
||||
):
|
||||
yield update
|
||||
else:
|
||||
async for update in self._process_stream_events_from_iterator(stream, thread_id, response_id):
|
||||
yield update
|
||||
|
||||
async def _process_stream_events_from_iterator(
|
||||
self,
|
||||
stream_iter: Any,
|
||||
thread_id: str,
|
||||
response_id: str | None,
|
||||
) -> AsyncIterable[ChatResponseUpdate]:
|
||||
"""Process events from the stream iterator and yield ChatResponseUpdate objects."""
|
||||
async for event_type, event_data, _ in stream_iter: # type: ignore
|
||||
if event_type == AgentStreamEvent.THREAD_RUN_CREATED and isinstance(event_data, ThreadRun):
|
||||
yield ChatResponseUpdate(
|
||||
contents=[],
|
||||
conversation_id=event_data.thread_id,
|
||||
message_id=response_id,
|
||||
raw_representation=event_data,
|
||||
response_id=response_id,
|
||||
role=ChatRole.ASSISTANT,
|
||||
)
|
||||
elif event_type == AgentStreamEvent.THREAD_RUN_STEP_CREATED and isinstance(event_data, RunStep):
|
||||
response_id = event_data.run_id
|
||||
elif event_type == AgentStreamEvent.THREAD_MESSAGE_DELTA and isinstance(event_data, MessageDeltaChunk):
|
||||
role = ChatRole.USER if event_data.delta.role == MessageRole.USER else ChatRole.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,
|
||||
)
|
||||
elif (
|
||||
event_type == AgentStreamEvent.THREAD_RUN_REQUIRES_ACTION
|
||||
and isinstance(event_data, ThreadRun)
|
||||
and isinstance(event_data.required_action, SubmitToolOutputsAction)
|
||||
):
|
||||
contents = self._create_function_call_contents(event_data, response_id)
|
||||
if contents:
|
||||
yield ChatResponseUpdate(
|
||||
role=ChatRole.ASSISTANT,
|
||||
contents=contents,
|
||||
conversation_id=thread_id,
|
||||
message_id=response_id,
|
||||
raw_representation=event_data,
|
||||
response_id=response_id,
|
||||
)
|
||||
elif (
|
||||
event_type == AgentStreamEvent.THREAD_RUN_COMPLETED
|
||||
and isinstance(event_data, RunStep)
|
||||
and event_data.usage is not None
|
||||
):
|
||||
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=ChatRole.ASSISTANT,
|
||||
contents=[usage_content],
|
||||
conversation_id=thread_id,
|
||||
message_id=response_id,
|
||||
raw_representation=event_data,
|
||||
response_id=response_id,
|
||||
)
|
||||
else:
|
||||
yield ChatResponseUpdate(
|
||||
contents=[],
|
||||
conversation_id=thread_id,
|
||||
message_id=response_id,
|
||||
raw_representation=event_data, # type: ignore
|
||||
response_id=response_id,
|
||||
role=ChatRole.ASSISTANT,
|
||||
)
|
||||
|
||||
def _create_function_call_contents(self, event_data: ThreadRun, response_id: str | None) -> list[AIContents]:
|
||||
"""Create function call contents from a tool action event."""
|
||||
contents: list[AIContents] = []
|
||||
|
||||
if isinstance(event_data.required_action, SubmitToolOutputsAction):
|
||||
for tool_call in event_data.required_action.submit_tool_outputs.tool_calls:
|
||||
if isinstance(tool_call, RequiredFunctionToolCall):
|
||||
call_id = json.dumps([response_id, tool_call.id])
|
||||
function_name = tool_call.function.name
|
||||
function_arguments = json.loads(tool_call.function.arguments)
|
||||
contents.append(
|
||||
FunctionCallContent(call_id=call_id, name=function_name, arguments=function_arguments)
|
||||
)
|
||||
|
||||
return contents
|
||||
|
||||
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
|
||||
|
||||
def _create_run_options(
|
||||
self,
|
||||
messages: MutableSequence[ChatMessage],
|
||||
chat_options: ChatOptions | None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[dict[str, Any], list[FunctionResultContent] | 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.tools is not None:
|
||||
tool_definitions: list[MutableMapping[str, Any]] = []
|
||||
|
||||
for tool in chat_options.tools:
|
||||
if isinstance(tool, AITool):
|
||||
tool_definitions.append(tool_to_json_schema_spec(tool))
|
||||
else:
|
||||
tool_definitions.append(tool)
|
||||
|
||||
if len(tool_definitions) > 0:
|
||||
run_options["tools"] = tool_definitions
|
||||
|
||||
if chat_options.tool_choice is not None:
|
||||
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] = []
|
||||
tool_results: list[FunctionResultContent] | 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):
|
||||
if tool_results is None:
|
||||
tool_results = []
|
||||
tool_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 == ChatRole.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, tool_results
|
||||
|
||||
def _convert_function_results_to_tool_output(
|
||||
self,
|
||||
tool_results: list[FunctionResultContent] | None,
|
||||
) -> tuple[str | None, list[ToolOutput] | None]:
|
||||
run_id: str | None = None
|
||||
tool_outputs: list[ToolOutput] | None = None
|
||||
|
||||
if tool_results:
|
||||
for function_result_content in tool_results:
|
||||
# When creating the FunctionCallContent, we created it with a CallId == [runId, callId].
|
||||
# We need to extract the run ID and ensure that the ToolOutput we send back to Azure
|
||||
# is only the call ID.
|
||||
run_and_call_ids: list[str] = json.loads(function_result_content.call_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 tool_outputs is None:
|
||||
tool_outputs = []
|
||||
tool_outputs.append(ToolOutput(tool_call_id=call_id, output=str(function_result_content.result)))
|
||||
|
||||
return run_id, tool_outputs
|
||||
@@ -0,0 +1,122 @@
|
||||
[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 = {file = "../../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.1.0b1",
|
||||
"aiohttp ~= 3.8"
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pre-commit >= 3.7",
|
||||
"ruff>=0.11.8",
|
||||
"pytest>=8.4.1",
|
||||
"pytest-asyncio>=1.0.0",
|
||||
"pytest-cov>=6.2.1",
|
||||
"pytest-xdist[psutil]>=3.8.0",
|
||||
"pytest-timeout>=2.3.1",
|
||||
"mypy>=1.16.1",
|
||||
"pyright>=1.1.402",
|
||||
|
||||
#tasks
|
||||
"poethepoet>=0.36.0",
|
||||
"rich",
|
||||
"tomli",
|
||||
"tomli-w",
|
||||
"markdownify",
|
||||
# Documentation
|
||||
"myst-nb==1.1.2",
|
||||
"pydata-sphinx-theme==0.16.0",
|
||||
"sphinx-copybutton",
|
||||
"sphinx-design",
|
||||
"sphinx",
|
||||
"sphinxcontrib-apidoc",
|
||||
"autodoc_pydantic~=2.2",
|
||||
"pygments",
|
||||
"sphinxext-rediraffe",
|
||||
"opentelemetry-instrumentation-openai",
|
||||
"markdown-it-py[linkify]",
|
||||
# Documentation tooling
|
||||
"diskcache",
|
||||
"redis",
|
||||
"sphinx-autobuild",
|
||||
]
|
||||
|
||||
[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.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
|
||||
disallow_any_unimported = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_foundry"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.uv.build-backend]
|
||||
module-name = "agent_framework_foundry"
|
||||
module-root = ""
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.7.19,<0.8.0"]
|
||||
build-backend = "uv_build"
|
||||
@@ -0,0 +1,78 @@
|
||||
# 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 not in exclude_list:
|
||||
monkeypatch.setenv(key, value) # type: ignore
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False) # 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 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()
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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
|
||||
@@ -0,0 +1,272 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from agent_framework import ChatClient, ChatMessage, ChatOptions, ChatRole
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
|
||||
from agent_framework_foundry import FoundryChatClient, FoundrySettings
|
||||
|
||||
|
||||
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()
|
||||
|
||||
return FoundryChatClient.model_construct(
|
||||
client=mock_ai_project_client,
|
||||
agent_id=agent_id,
|
||||
thread_id=thread_id,
|
||||
_should_delete_agent=should_delete_agent,
|
||||
_foundry_settings=foundry_settings,
|
||||
)
|
||||
|
||||
|
||||
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, ChatClient)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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_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"
|
||||
|
||||
|
||||
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_get_agent_id_or_create_missing_model(
|
||||
mock_ai_project_client: MagicMock,
|
||||
) -> 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")
|
||||
|
||||
|
||||
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=ChatRole.USER, text="Hello")]
|
||||
chat_options = ChatOptions(max_tokens=100, temperature=0.7)
|
||||
|
||||
run_options, tool_results = chat_client._create_run_options(messages, chat_options) # type: ignore
|
||||
|
||||
assert run_options is not None
|
||||
assert tool_results is None
|
||||
|
||||
|
||||
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=ChatRole.USER, text="Hello")]
|
||||
|
||||
run_options, tool_results = chat_client._create_run_options(messages, None) # type: ignore
|
||||
|
||||
assert run_options is not None
|
||||
assert tool_results is None
|
||||
|
||||
|
||||
def test_foundry_chat_client_convert_function_results_to_tool_output(mock_ai_project_client: MagicMock) -> None:
|
||||
"""Test _convert_function_results_to_tool_output method."""
|
||||
from agent_framework import FunctionResultContent
|
||||
|
||||
chat_client = create_test_foundry_chat_client(mock_ai_project_client)
|
||||
|
||||
function_results = [
|
||||
FunctionResultContent(call_id='["run_123", "call_456"]', result="Result 1"),
|
||||
FunctionResultContent(call_id='["run_123", "call_789"]', result="Result 2"),
|
||||
]
|
||||
|
||||
run_id, tool_outputs = chat_client._convert_function_results_to_tool_output(function_results) # type: ignore
|
||||
|
||||
assert run_id == "run_123"
|
||||
assert tool_outputs is not None
|
||||
assert len(tool_outputs) == 2
|
||||
|
||||
|
||||
def test_foundry_chat_client_convert_function_results_to_tool_output_none(mock_ai_project_client: MagicMock) -> None:
|
||||
"""Test _convert_function_results_to_tool_output with None input."""
|
||||
chat_client = create_test_foundry_chat_client(mock_ai_project_client)
|
||||
|
||||
run_id, tool_outputs = chat_client._convert_function_results_to_tool_output(None) # type: ignore
|
||||
|
||||
assert run_id is None
|
||||
assert tool_outputs is None
|
||||
@@ -1,10 +1,16 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import sys
|
||||
from collections.abc import AsyncIterable, Callable, MutableMapping, Sequence
|
||||
from enum import Enum
|
||||
from typing import Any, Literal, Protocol, TypeVar, runtime_checkable
|
||||
from uuid import uuid4
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ._clients import ChatClient
|
||||
@@ -359,6 +365,23 @@ class ChatClientAgent(AgentBase):
|
||||
|
||||
super().__init__(**args)
|
||||
|
||||
async def __aenter__(self) -> "Self":
|
||||
"""Async context manager entry.
|
||||
|
||||
If the chat_client supports async context management, enter its context.
|
||||
"""
|
||||
if hasattr(self.chat_client, "__aenter__") and hasattr(self.chat_client, "__aexit__"):
|
||||
await self.chat_client.__aenter__() # type: ignore[reportUnknownMemberType]
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
|
||||
"""Async context manager exit.
|
||||
|
||||
If the chat_client supports async context management, exit its context.
|
||||
"""
|
||||
if hasattr(self.chat_client, "__aenter__") and hasattr(self.chat_client, "__aexit__"):
|
||||
await self.chat_client.__aexit__(exc_type, exc_val, exc_tb) # type: ignore[reportUnknownMemberType]
|
||||
|
||||
async def run(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
|
||||
@@ -4,9 +4,9 @@ import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, MutableSequence, Sequence
|
||||
from functools import wraps
|
||||
from typing import Annotated, Any, Generic, Literal, Protocol, TypeVar, runtime_checkable
|
||||
from typing import Any, Generic, Literal, Protocol, TypeVar, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel, StringConstraints
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._logging import get_logger
|
||||
from ._pydantic import AFBaseModel
|
||||
@@ -75,7 +75,7 @@ async def _auto_invoke_function(
|
||||
)
|
||||
|
||||
|
||||
def _tool_to_json_schema_spec(tool: AITool) -> dict[str, Any]:
|
||||
def tool_to_json_schema_spec(tool: AITool) -> dict[str, Any]:
|
||||
"""Convert a AITool to the JSON Schema function specification format."""
|
||||
return {
|
||||
"type": "function",
|
||||
@@ -95,7 +95,7 @@ def _prepare_tools_and_tool_choice(chat_options: ChatOptions) -> None:
|
||||
chat_options.tool_choice = ChatToolMode.NONE.mode
|
||||
return
|
||||
chat_options.tools = [
|
||||
(_tool_to_json_schema_spec(t) if isinstance(t, AITool) else t)
|
||||
(tool_to_json_schema_spec(t) if isinstance(t, AITool) else t)
|
||||
for t in chat_options._ai_tools or [] # type: ignore[reportPrivateUsage]
|
||||
]
|
||||
if not chat_options.tools:
|
||||
@@ -205,6 +205,12 @@ def _tool_call_streaming(func: TInnerGetStreamingResponse) -> TInnerGetStreaming
|
||||
messages.append(response.messages[0])
|
||||
function_calls = [item for item in response.messages[0].contents if isinstance(item, FunctionCallContent)]
|
||||
|
||||
# When conversation id is present, it means that messages are hosted on the server.
|
||||
# In this case, we need to update ChatOptions with conversation id and also clear messages
|
||||
if response.conversation_id is not None:
|
||||
chat_options.conversation_id = response.conversation_id
|
||||
messages = []
|
||||
|
||||
if function_calls:
|
||||
# Run all function calls concurrently
|
||||
results = await asyncio.gather(*[
|
||||
@@ -393,8 +399,6 @@ class ChatClient(Protocol):
|
||||
class ChatClientBase(AFBaseModel, ABC):
|
||||
"""Base class for chat clients."""
|
||||
|
||||
ai_model_id: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
|
||||
def _prepare_messages(
|
||||
self, messages: str | ChatMessage | list[str] | list[ChatMessage]
|
||||
) -> MutableSequence[ChatMessage]:
|
||||
|
||||
@@ -269,7 +269,7 @@ def _coalesce_text_content(
|
||||
first_new_content = i
|
||||
else:
|
||||
if first_new_content is not None:
|
||||
new_content = type_(text=" ".join(current_texts))
|
||||
new_content = type_(text="".join(current_texts))
|
||||
new_content.raw_representation = contents[first_new_content].raw_representation
|
||||
new_content.additional_properties = contents[first_new_content].additional_properties
|
||||
# Store the replacement node. We inherit the properties of the first text node. We don't
|
||||
@@ -280,7 +280,7 @@ def _coalesce_text_content(
|
||||
first_new_content = None
|
||||
coalesced_contents.append(content)
|
||||
if current_texts:
|
||||
coalesced_contents.append(type_(text=" ".join(current_texts)))
|
||||
coalesced_contents.append(type_(text="".join(current_texts)))
|
||||
contents.clear()
|
||||
contents.extend(coalesced_contents)
|
||||
|
||||
@@ -398,6 +398,7 @@ class DataContent(AIContent):
|
||||
uri: The URI of the data represented by this instance, typically in the form of a data URI.
|
||||
Should be in the form: "data:{media_type};base64,{base64_data}".
|
||||
type: The type of content, which is always "data" for this class.
|
||||
media_type: The media type of the data.
|
||||
additional_properties: Optional additional properties associated with the content.
|
||||
raw_representation: Optional raw representation of the content.
|
||||
|
||||
@@ -405,6 +406,7 @@ class DataContent(AIContent):
|
||||
|
||||
type: Literal["data"] = "data" # type: ignore[assignment]
|
||||
uri: str
|
||||
media_type: str | None = None
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
@@ -486,6 +488,7 @@ class DataContent(AIContent):
|
||||
uri = f"data:{media_type};base64,{base64.b64encode(data).decode('utf-8')}"
|
||||
super().__init__(
|
||||
uri=uri, # type: ignore[reportCallIssue]
|
||||
media_type=media_type, # type: ignore[reportCallIssue]
|
||||
raw_representation=raw_representation,
|
||||
additional_properties=additional_properties,
|
||||
**kwargs,
|
||||
@@ -506,6 +509,9 @@ class DataContent(AIContent):
|
||||
raise ValueError(f"Unknown media type: {media_type}")
|
||||
return uri
|
||||
|
||||
def has_top_level_media_type(self, top_level_media_type: str) -> bool:
|
||||
return _has_top_level_media_type(self.media_type, top_level_media_type)
|
||||
|
||||
|
||||
class UriContent(AIContent):
|
||||
"""Represents a URI content.
|
||||
@@ -559,6 +565,19 @@ class UriContent(AIContent):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def has_top_level_media_type(self, top_level_media_type: str) -> bool:
|
||||
return _has_top_level_media_type(self.media_type, top_level_media_type)
|
||||
|
||||
|
||||
def _has_top_level_media_type(media_type: str | None, top_level_media_type: str) -> bool:
|
||||
if media_type is None:
|
||||
return False
|
||||
|
||||
slash_index = media_type.find("/")
|
||||
span = media_type[:slash_index] if slash_index >= 0 else media_type
|
||||
span = span.strip()
|
||||
return span.lower() == top_level_media_type.lower()
|
||||
|
||||
|
||||
class ErrorContent(AIContent):
|
||||
"""Represents an error.
|
||||
@@ -1449,7 +1468,12 @@ ChatToolMode.NONE = ChatToolMode(mode="none") # type: ignore[assignment]
|
||||
class ChatOptions(AFBaseModel):
|
||||
"""Common request settings for AI services."""
|
||||
|
||||
additional_properties: MutableMapping[str, Any] = Field(
|
||||
default_factory=dict, description="Provider-specific additional properties."
|
||||
)
|
||||
ai_model_id: Annotated[str | None, Field(serialization_alias="model")] = None
|
||||
allow_multiple_tool_calls: bool | None = None
|
||||
conversation_id: str | None = None
|
||||
frequency_penalty: Annotated[float | None, Field(ge=-2.0, le=2.0)] = None
|
||||
logit_bias: MutableMapping[str | int, float] | None = None
|
||||
max_tokens: Annotated[int | None, Field(gt=0)] = None
|
||||
@@ -1464,12 +1488,9 @@ class ChatOptions(AFBaseModel):
|
||||
temperature: Annotated[float | None, Field(ge=0.0, le=2.0)] = None
|
||||
tool_choice: ChatToolMode | Literal["auto", "required", "none"] | Mapping[str, Any] | None = None
|
||||
tools: list[AITool | MutableMapping[str, Any]] | None = None
|
||||
_ai_tools: list[AITool | MutableMapping[str, Any]] | None = PrivateAttr(default=None)
|
||||
top_p: Annotated[float | None, Field(ge=0.0, le=1.0)] = None
|
||||
user: str | None = None
|
||||
additional_properties: MutableMapping[str, Any] = Field(
|
||||
default_factory=dict, description="Provider-specific additional properties."
|
||||
)
|
||||
_ai_tools: list[AITool | MutableMapping[str, Any]] | None = PrivateAttr(default=None)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _copy_to_ai_tools(self) -> Self:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
PACKAGE_NAME = "agent_framework_foundry"
|
||||
PACKAGE_EXTRA = "foundry"
|
||||
_IMPORTS = ["__version__", "FoundryChatClient"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
try:
|
||||
return getattr(importlib.import_module(PACKAGE_NAME), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The '{PACKAGE_EXTRA}' extra is not installed, "
|
||||
f"please do `pip install agent-framework[{PACKAGE_EXTRA}]`"
|
||||
) from exc
|
||||
raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return _IMPORTS
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_foundry import FoundryChatClient, __version__
|
||||
|
||||
__all__ = ["FoundryChatClient", "__version__"]
|
||||
@@ -33,6 +33,9 @@ dependencies = [
|
||||
azure = [
|
||||
"agent-framework-azure"
|
||||
]
|
||||
foundry = [
|
||||
"agent-framework-foundry"
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
@@ -49,7 +49,7 @@ class MockAgent(Agent):
|
||||
|
||||
async def run(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str | ChatMessage] | None = None,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
@@ -58,7 +58,7 @@ class MockAgent(Agent):
|
||||
|
||||
async def run_stream(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str | ChatMessage] | None = None,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
|
||||
@@ -121,7 +121,7 @@ def chat_client() -> MockChatClient:
|
||||
|
||||
@fixture
|
||||
def chat_client_base() -> MockChatClientBase:
|
||||
return MockChatClientBase(ai_model_id="test")
|
||||
return MockChatClientBase()
|
||||
|
||||
|
||||
@fixture
|
||||
|
||||
@@ -350,7 +350,7 @@ def test_chat_response_updates_to_chat_response_one():
|
||||
|
||||
# Check the type and content
|
||||
assert len(chat_response.messages) == 1
|
||||
assert chat_response.text == "I'm doing well, thank you!"
|
||||
assert chat_response.text == "I'm doing well, thank you!"
|
||||
assert isinstance(chat_response.messages[0], ChatMessage)
|
||||
assert len(chat_response.messages[0].contents) == 1
|
||||
assert chat_response.messages[0].message_id == "1"
|
||||
@@ -429,13 +429,13 @@ def test_chat_response_updates_to_chat_response_multiple_multiple():
|
||||
|
||||
assert len(chat_response.messages[0].contents) == 3
|
||||
assert isinstance(chat_response.messages[0].contents[0], TextContent)
|
||||
assert chat_response.messages[0].contents[0].text == "I'm doing well, thank you!"
|
||||
assert chat_response.messages[0].contents[0].text == "I'm doing well, thank you!"
|
||||
assert isinstance(chat_response.messages[0].contents[1], TextReasoningContent)
|
||||
assert chat_response.messages[0].contents[1].text == "Additional context"
|
||||
assert isinstance(chat_response.messages[0].contents[2], TextContent)
|
||||
assert chat_response.messages[0].contents[2].text == "More context Final part"
|
||||
assert chat_response.messages[0].contents[2].text == "More contextFinal part"
|
||||
|
||||
assert chat_response.text == "I'm doing well, thank you! More context Final part"
|
||||
assert chat_response.text == "I'm doing well, thank you! More contextFinal part"
|
||||
|
||||
|
||||
# region: ChatToolMode
|
||||
@@ -643,7 +643,7 @@ def test_agent_run_response_from_updates(agent_run_response_update: AgentRunResp
|
||||
updates = [agent_run_response_update, agent_run_response_update]
|
||||
response = AgentRunResponse.from_agent_run_response_updates(updates)
|
||||
assert len(response.messages) > 0
|
||||
assert response.text == "Test content Test content"
|
||||
assert response.text == "Test contentTest content"
|
||||
|
||||
|
||||
def test_agent_run_response_str_method(chat_message: ChatMessage) -> None:
|
||||
|
||||
Reference in New Issue
Block a user