From c012aac5f24b27d06a88488aa99cf3a23c892e96 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 25 Mar 2026 19:03:43 +0100 Subject: [PATCH 01/31] Python: [BREAKING] Reduce core dependencies and simplify optional integrations (#4904) * improved dependencies and some fixes * fix for mypy * improve mcp --- python/packages/azure-ai/pyproject.toml | 2 + .../packages/core/agent_framework/_agents.py | 17 +- python/packages/core/agent_framework/_mcp.py | 638 ++++++++++-------- .../core/agent_framework/observability.py | 81 ++- python/packages/core/pyproject.toml | 12 +- python/packages/core/tests/conftest.py | 2 +- python/packages/core/tests/core/test_mcp.py | 373 +++++++++- .../core/tests/core/test_observability.py | 50 +- .../tests/core/test_optional_dependencies.py | 181 +++++ python/packages/devui/pyproject.toml | 13 +- .../agent_framework_foundry/_foundry_agent.py | 2 +- python/packages/lab/pyproject.toml | 13 +- python/pyproject.toml | 2 + python/uv.lock | 39 +- 14 files changed, 985 insertions(+), 440 deletions(-) create mode 100644 python/packages/core/tests/core/test_optional_dependencies.py diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml index e46b52e9ae..5f0f3a31e1 100644 --- a/python/packages/azure-ai/pyproject.toml +++ b/python/packages/azure-ai/pyproject.toml @@ -25,8 +25,10 @@ classifiers = [ dependencies = [ "agent-framework-core>=1.0.0rc5", "agent-framework-openai>=1.0.0rc5", + "azure-ai-projects>=2.0.0,<3.0", "azure-ai-agents>=1.2.0b5,<1.2.0b6", "azure-ai-inference>=1.0.0b9,<1.0.0b10", + "azure-identity>=1,<2", "aiohttp>=3.7.0,<4", ] diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index c2c6e874f1..27a6a45747 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -24,9 +24,6 @@ from typing import ( ) from uuid import uuid4 -from mcp import types -from mcp.server.lowlevel import Server -from mcp.shared.exceptions import McpError from pydantic import BaseModel from . import _tools as _tool_utils # pyright: ignore[reportPrivateUsage] @@ -71,6 +68,9 @@ else: from typing_extensions import Self, TypedDict # pragma: no cover if TYPE_CHECKING: + from mcp import types + from mcp.server.lowlevel import Server + from ._compaction import CompactionStrategy, TokenizerProtocol from ._types import ChatOptions @@ -1369,6 +1369,15 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] Returns: The MCP server instance. """ + try: + from mcp import types + from mcp.server.lowlevel import Server + from mcp.shared.exceptions import McpError + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + "`mcp` is required to use `Agent.as_mcp_server()`. Please install `mcp`." + ) from exc + server_args: dict[str, Any] = { "name": server_name, "version": version, @@ -1469,8 +1478,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] class Agent( - AgentTelemetryLayer, AgentMiddlewareLayer, + AgentTelemetryLayer, RawAgent[OptionsCoT], Generic[OptionsCoT], ): diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 062df5491c..c7db6177da 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -13,25 +13,12 @@ from collections.abc import Callable, Collection, Sequence from contextlib import AsyncExitStack, _AsyncGeneratorContextManager # type: ignore from datetime import timedelta from functools import partial -from typing import TYPE_CHECKING, Any, Literal, TypedDict +from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast -import httpx -from anyio import ClosedResourceError -from mcp import types -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client -from mcp.client.streamable_http import streamable_http_client -from mcp.client.websocket import websocket_client -from mcp.shared.context import RequestContext -from mcp.shared.exceptions import McpError -from mcp.shared.session import RequestResponder from opentelemetry import propagate from ._tools import FunctionTool -from ._types import ( - Content, - Message, -) +from ._types import Content, Message from .exceptions import ToolException, ToolExecutionException if sys.version_info >= (3, 11): @@ -40,9 +27,18 @@ else: from typing_extensions import Self # pragma: no cover if TYPE_CHECKING: + from httpx import AsyncClient + from mcp import types + from mcp.client.session import ClientSession + from mcp.shared.context import RequestContext + from mcp.shared.session import RequestResponder + from ._clients import SupportsChatGetResponse +logger = logging.getLogger(__name__) + + class MCPSpecificApproval(TypedDict, total=False): """Represents the specific approval mode for an MCP tool. @@ -57,13 +53,12 @@ class MCPSpecificApproval(TypedDict, total=False): never_require_approval: Collection[str] | None -logger = logging.getLogger(__name__) _MCP_REMOTE_NAME_KEY = "_mcp_remote_name" _MCP_NORMALIZED_NAME_KEY = "_mcp_normalized_name" # region: Helpers -LOG_LEVEL_MAPPING: dict[types.LoggingLevel, int] = { +LOG_LEVEL_MAPPING: dict[str, int] = { "debug": logging.DEBUG, "info": logging.INFO, "notice": logging.INFO, @@ -75,269 +70,6 @@ LOG_LEVEL_MAPPING: dict[types.LoggingLevel, int] = { } -def _parse_prompt_result_from_mcp( - mcp_type: types.GetPromptResult, -) -> str: - """Parse an MCP GetPromptResult directly into a string representation. - - Converts each message in the prompt result to its string form and combines them. - - Args: - mcp_type: The MCP GetPromptResult object to convert. - - Returns: - A string representation of the prompt result. - """ - parts: list[str] = [] - for message in mcp_type.messages: - content = message.content - if isinstance(content, types.TextContent): - parts.append(content.text) - elif isinstance(content, (types.ImageContent, types.AudioContent)): - parts.append( - json.dumps( - { - "type": "image" if isinstance(content, types.ImageContent) else "audio", - "data": content.data, - "mimeType": content.mimeType, - }, - default=str, - ) - ) - elif isinstance(content, types.EmbeddedResource): - match content.resource: - case types.TextResourceContents(): - parts.append(content.resource.text) - case types.BlobResourceContents(): - parts.append( - json.dumps( - { - "type": "blob", - "data": content.resource.blob, - "mimeType": content.resource.mimeType, - }, - default=str, - ) - ) - else: - parts.append(str(content)) - if not parts: - return "" - if len(parts) == 1: - return parts[0] - return json.dumps(parts, default=str) - - -def _parse_message_from_mcp( - mcp_type: types.PromptMessage | types.SamplingMessage, -) -> Message: - """Parse an MCP container type into an Agent Framework type.""" - return Message( - role=mcp_type.role, - contents=_parse_content_from_mcp(mcp_type.content), - raw_representation=mcp_type, - ) - - -def _parse_tool_result_from_mcp( - mcp_type: types.CallToolResult, -) -> list[Content]: - """Parse an MCP CallToolResult into a list of Content items. - - Converts each content item in the MCP result to its appropriate - Content form. Text items become ``Content(type="text")`` and media - items (images, audio) are preserved as rich Content. - - Args: - mcp_type: The MCP CallToolResult object to convert. - - Returns: - A list of Content items representing the tool result. - """ - result: list[Content] = [] - for item in mcp_type.content: - match item: - case types.TextContent(): - result.append(Content.from_text(item.text)) - case types.ImageContent() | types.AudioContent(): - decoded = base64.b64decode(item.data) - result.append( - Content.from_data( - data=decoded, - media_type=item.mimeType, - ) - ) - case types.ResourceLink(): - result.append( - Content.from_uri( - uri=str(item.uri), - media_type=item.mimeType, - ) - ) - case types.EmbeddedResource(): - match item.resource: - case types.TextResourceContents(): - result.append(Content.from_text(item.resource.text)) - case types.BlobResourceContents(): - blob = item.resource.blob - mime = item.resource.mimeType or "application/octet-stream" - if not blob.startswith("data:"): - blob = f"data:{mime};base64,{blob}" - result.append( - Content.from_uri( - uri=blob, - media_type=mime, - ) - ) - case _: - result.append(Content.from_text(str(item))) - - if not result: - result.append(Content.from_text("null")) - return result - - -def _parse_content_from_mcp( - mcp_type: types.ImageContent - | types.TextContent - | types.AudioContent - | types.EmbeddedResource - | types.ResourceLink - | types.ToolUseContent - | types.ToolResultContent - | Sequence[ - types.ImageContent - | types.TextContent - | types.AudioContent - | types.EmbeddedResource - | types.ResourceLink - | types.ToolUseContent - | types.ToolResultContent - ], -) -> list[Content]: - """Parse an MCP type into an Agent Framework type.""" - mcp_types = mcp_type if isinstance(mcp_type, Sequence) else [mcp_type] - return_types: list[Content] = [] - for mcp_type in mcp_types: - match mcp_type: - case types.TextContent(): - return_types.append(Content.from_text(text=mcp_type.text, raw_representation=mcp_type)) - case types.ImageContent() | types.AudioContent(): - # MCP protocol uses base64-encoded strings, convert to bytes - data_bytes = base64.b64decode(mcp_type.data) if isinstance(mcp_type.data, str) else mcp_type.data - return_types.append( - Content.from_data( - data=data_bytes, - media_type=mcp_type.mimeType, - raw_representation=mcp_type, - ) - ) - case types.ResourceLink(): - return_types.append( - Content.from_uri( - uri=str(mcp_type.uri), - media_type=mcp_type.mimeType or "application/json", - raw_representation=mcp_type, - ) - ) - case types.ToolUseContent(): - return_types.append( - Content.from_function_call( - call_id=mcp_type.id, - name=mcp_type.name, - arguments=mcp_type.input, - raw_representation=mcp_type, - ) - ) - case types.ToolResultContent(): - return_types.append( - Content.from_function_result( - call_id=mcp_type.toolUseId, - result=_parse_content_from_mcp(mcp_type.content) - if mcp_type.content - else mcp_type.structuredContent, - exception=str(Exception()) if mcp_type.isError else None, # type: ignore[arg-type] - raw_representation=mcp_type, - ) - ) - case types.EmbeddedResource(): - match mcp_type.resource: - case types.TextResourceContents(): - return_types.append( - Content.from_text( - text=mcp_type.resource.text, - raw_representation=mcp_type, - additional_properties=( - mcp_type.annotations.model_dump() if mcp_type.annotations else None - ), - ) - ) - case types.BlobResourceContents(): - return_types.append( - Content.from_uri( - uri=mcp_type.resource.blob, - media_type=mcp_type.resource.mimeType, - raw_representation=mcp_type, - additional_properties=( - mcp_type.annotations.model_dump() if mcp_type.annotations else None - ), - ) - ) - return return_types - - -def _prepare_content_for_mcp( - content: Content, -) -> types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink | None: - """Prepare an Agent Framework content type for MCP.""" - if content.type == "text": - return types.TextContent(type="text", text=content.text) # type: ignore[attr-defined] - if content.type == "data": - if content.media_type and content.media_type.startswith("image/"): # type: ignore[attr-defined] - return types.ImageContent(type="image", data=content.uri, mimeType=content.media_type) # type: ignore[attr-defined] - if content.media_type and content.media_type.startswith("audio/"): # type: ignore[attr-defined] - return types.AudioContent(type="audio", data=content.uri, mimeType=content.media_type) # type: ignore[attr-defined] - if content.media_type and content.media_type.startswith("application/"): # type: ignore[attr-defined] - return types.EmbeddedResource( - type="resource", - resource=types.BlobResourceContents( - blob=content.uri, # type: ignore[attr-defined] - mimeType=content.media_type, # type: ignore[attr-defined] - # uri's are not limited in MCP but they have to be set. - # the uri of data content, contains the data uri, which - # is not the uri meant here, UriContent would match this. - uri=( - content.additional_properties.get("uri", "af://binary") - if content.additional_properties - else "af://binary" - ), # type: ignore[reportArgumentType] - ), - ) - return None - if content.type == "uri": - return types.ResourceLink( - type="resource_link", - uri=content.uri, # type: ignore[reportArgumentType,attr-defined] - mimeType=content.media_type, # type: ignore[attr-defined] - name=(content.additional_properties.get("name", "Unknown") if content.additional_properties else "Unknown"), - ) - return None - - -def _prepare_message_for_mcp( - content: Message, -) -> list[types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink]: - """Prepare a Message for MCP format.""" - messages: list[ - types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink - ] = [] - for item in content.contents: - mcp_content = _prepare_content_for_mcp(item) - if mcp_content: - messages.append(mcp_content) - return messages - - def _get_input_model_from_mcp_prompt(prompt: types.Prompt) -> dict[str, Any]: """Get the input model from an MCP prompt. @@ -462,8 +194,8 @@ class MCPTool: ``Callable[[types.GetPromptResult], str]`` that overrides the default prompt result parsing. When ``None`` (the default), the built-in parser converts MCP prompt results to a string. If you need per-function result parsing, - access the ``.functions`` list after connecting and set ``result_parser`` on - individual ``FunctionTool`` instances. + access the ``.functions`` list after connecting and set ``result_parser`` on + individual ``FunctionTool`` instances. session: An existing MCP client session to use. request_timeout: Timeout in seconds for MCP requests. client: A chat client for sampling callbacks. @@ -495,6 +227,264 @@ class MCPTool: def __str__(self) -> str: return f"MCPTool(name={self.name}, description={self.description})" + def _parse_prompt_result_from_mcp( + self, + mcp_type: types.GetPromptResult, + ) -> str: + """Parse an MCP GetPromptResult directly into a string representation.""" + from mcp import types + + parts: list[str] = [] + for message in mcp_type.messages: + content = message.content + if isinstance(content, types.TextContent): + parts.append(content.text) + elif isinstance(content, (types.ImageContent, types.AudioContent)): + parts.append( + json.dumps( + { + "type": "image" if isinstance(content, types.ImageContent) else "audio", + "data": content.data, + "mimeType": content.mimeType, + }, + default=str, + ) + ) + elif isinstance(content, types.EmbeddedResource): + match content.resource: + case types.TextResourceContents(): + parts.append(content.resource.text) + case types.BlobResourceContents(): + parts.append( + json.dumps( + { + "type": "blob", + "data": content.resource.blob, + "mimeType": content.resource.mimeType, + }, + default=str, + ) + ) + else: + parts.append(str(content)) + if not parts: + return "" + if len(parts) == 1: + return parts[0] + return json.dumps(parts, default=str) + + def _parse_message_from_mcp( + self, + mcp_type: types.PromptMessage | types.SamplingMessage, + ) -> Message: + """Parse an MCP container type into an Agent Framework type.""" + return Message( + role=mcp_type.role, + contents=self._parse_content_from_mcp(mcp_type.content), + raw_representation=mcp_type, + ) + + def _parse_tool_result_from_mcp( + self, + mcp_type: types.CallToolResult, + ) -> list[Content]: + """Parse an MCP CallToolResult into a list of Content items.""" + from mcp import types + + result: list[Content] = [] + for item in mcp_type.content: + match item: + case types.TextContent(): + result.append(Content.from_text(item.text)) + case types.ImageContent() | types.AudioContent(): + decoded = base64.b64decode(item.data) + result.append( + Content.from_data( + data=decoded, + media_type=item.mimeType, + ) + ) + case types.ResourceLink(): + result.append( + Content.from_uri( + uri=str(item.uri), + media_type=item.mimeType, + ) + ) + case types.EmbeddedResource(): + match item.resource: + case types.TextResourceContents(): + result.append(Content.from_text(item.resource.text)) + case types.BlobResourceContents(): + blob = item.resource.blob + mime = item.resource.mimeType or "application/octet-stream" + if not blob.startswith("data:"): + blob = f"data:{mime};base64,{blob}" + result.append( + Content.from_uri( + uri=blob, + media_type=mime, + ) + ) + case _: + result.append(Content.from_text(str(item))) + + if not result: + result.append(Content.from_text("null")) + return result + + def _parse_content_from_mcp( + self, + mcp_type: types.ImageContent + | types.TextContent + | types.AudioContent + | types.EmbeddedResource + | types.ResourceLink + | types.ToolUseContent + | types.ToolResultContent + | Sequence[ + types.ImageContent + | types.TextContent + | types.AudioContent + | types.EmbeddedResource + | types.ResourceLink + | types.ToolUseContent + | types.ToolResultContent + ], + ) -> list[Content]: + """Parse an MCP type into an Agent Framework type.""" + from mcp import types + + mcp_content_types: Sequence[Any] = ( + cast(Sequence[Any], mcp_type) if isinstance(mcp_type, Sequence) else [mcp_type] + ) # type: ignore[redundant-cast] + return_types: list[Content] = [] + for mcp_type in mcp_content_types: + match mcp_type: + case types.TextContent(): + return_types.append(Content.from_text(text=mcp_type.text, raw_representation=mcp_type)) + case types.ImageContent() | types.AudioContent(): + data_bytes = base64.b64decode(mcp_type.data) if isinstance(mcp_type.data, str) else mcp_type.data + return_types.append( + Content.from_data( + data=data_bytes, + media_type=mcp_type.mimeType, + raw_representation=mcp_type, + ) + ) + case types.ResourceLink(): + return_types.append( + Content.from_uri( + uri=str(mcp_type.uri), + media_type=mcp_type.mimeType or "application/json", + raw_representation=mcp_type, + ) + ) + case types.ToolUseContent(): + return_types.append( + Content.from_function_call( + call_id=mcp_type.id, + name=mcp_type.name, + arguments=mcp_type.input, + raw_representation=mcp_type, + ) + ) + case types.ToolResultContent(): + return_types.append( + Content.from_function_result( + call_id=mcp_type.toolUseId, + result=self._parse_content_from_mcp(mcp_type.content) + if mcp_type.content + else mcp_type.structuredContent, + exception=str(Exception()) if mcp_type.isError else None, # type: ignore[arg-type] + raw_representation=mcp_type, + ) + ) + case types.EmbeddedResource(): + match mcp_type.resource: + case types.TextResourceContents(): + return_types.append( + Content.from_text( + text=mcp_type.resource.text, + raw_representation=mcp_type, + additional_properties=( + mcp_type.annotations.model_dump() if mcp_type.annotations else None + ), + ) + ) + case types.BlobResourceContents(): + return_types.append( + Content.from_uri( + uri=mcp_type.resource.blob, + media_type=mcp_type.resource.mimeType, + raw_representation=mcp_type, + additional_properties=( + mcp_type.annotations.model_dump() if mcp_type.annotations else None + ), + ) + ) + case _: + pass + return return_types + + def _prepare_content_for_mcp( + self, + content: Content, + ) -> ( + types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink | None + ): + """Prepare an Agent Framework content type for MCP.""" + from mcp import types + + if content.type == "text": + return types.TextContent(type="text", text=content.text) # type: ignore[attr-defined] + if content.type == "data": + if content.media_type and content.media_type.startswith("image/"): # type: ignore[attr-defined] + return types.ImageContent(type="image", data=content.uri, mimeType=content.media_type) # type: ignore[attr-defined] + if content.media_type and content.media_type.startswith("audio/"): # type: ignore[attr-defined] + return types.AudioContent(type="audio", data=content.uri, mimeType=content.media_type) # type: ignore[attr-defined] + if content.media_type and content.media_type.startswith("application/"): # type: ignore[attr-defined] + return types.EmbeddedResource( + type="resource", + resource=types.BlobResourceContents( + blob=content.uri, # type: ignore[attr-defined] + mimeType=content.media_type, # type: ignore[attr-defined] + uri=( + content.additional_properties.get("uri", "af://binary") + if content.additional_properties + else "af://binary" + ), # type: ignore[arg-type] + ), + ) + return None + if content.type == "uri": + resource_name = ( + content.additional_properties.get("name", "Unknown") if content.additional_properties else "Unknown" + ) + return types.ResourceLink( + type="resource_link", + uri=content.uri, # type: ignore[arg-type,attr-defined] + mimeType=content.media_type, # type: ignore[attr-defined] + name=resource_name, + ) + return None + + def _prepare_message_for_mcp( + self, + content: Message, + ) -> list[ + types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink + ]: + """Prepare a Message for MCP format.""" + messages: list[ + types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink + ] = [] + for item in content.contents: + mcp_content = self._prepare_content_for_mcp(item) + if mcp_content: + messages.append(mcp_content) + return messages + @property def functions(self) -> list[FunctionTool]: """Get the list of functions that are allowed.""" @@ -649,8 +639,16 @@ class MCPTool: error_msg = f"Failed to connect to MCP server: {ex}" raise ToolException(error_msg, inner_exception=ex) from ex try: + try: + from mcp.client.session import ClientSession as runtime_client_session + except ModuleNotFoundError as ex: + await self._safe_close_exit_stack() + raise ToolException( + "MCP support requires `mcp`. Please install `mcp`.", + inner_exception=ex, + ) from ex session = await self._exit_stack.enter_async_context( - ClientSession( + runtime_client_session( read_stream=transport[0], write_stream=transport[1], read_timeout_seconds=( @@ -681,7 +679,7 @@ class MCPTool: error_msg = f"MCP server failed to initialize: {ex}" raise ToolException(error_msg, inner_exception=ex) from ex self.session = session - elif self.session._request_id == 0: # type: ignore[reportPrivateUsage] + elif self.session._request_id == 0: # type: ignore[attr-defined] # If the session is not initialized, we need to reinitialize it await self.session.initialize() logger.debug("Connected to MCP server: %s", self.session) @@ -695,9 +693,10 @@ class MCPTool: if logger.level != logging.NOTSET: try: - await self.session.set_logging_level( - next(level for level, value in LOG_LEVEL_MAPPING.items() if value == logger.level) + level_name = cast( + Any, next(level for level, value in LOG_LEVEL_MAPPING.items() if value == logger.level) ) + await self.session.set_logging_level(level_name) except Exception as exc: logger.warning("Failed to set log level to %s", logger.level, exc_info=exc) @@ -723,6 +722,8 @@ class MCPTool: Returns: Either a CreateMessageResult with the generated message or ErrorData if generation fails. """ + from mcp import types + if not self.client: return types.ErrorData( code=types.INTERNAL_ERROR, @@ -731,7 +732,7 @@ class MCPTool: logger.debug("Sampling callback called with params: %s", params) messages: list[Message] = [] for msg in params.messages: - messages.append(_parse_message_from_mcp(msg)) + messages.append(self._parse_message_from_mcp(msg)) try: response = await self.client.get_response( messages, @@ -749,7 +750,7 @@ class MCPTool: code=types.INTERNAL_ERROR, message="Failed to get chat message content.", ) - mcp_contents = _prepare_message_for_mcp(response.messages[0]) + mcp_contents = self._prepare_message_for_mcp(response.messages[0]) # grab the first content that is of type TextContent or ImageContent mcp_content = next( (content for content in mcp_contents if isinstance(content, (types.TextContent, types.ImageContent))), @@ -798,6 +799,8 @@ class MCPTool: Args: message: The message from the MCP server (request responder, notification, or exception). """ + from mcp import types + if isinstance(message, Exception): logger.error("Error from MCP server: %s", message, exc_info=message) return @@ -824,7 +827,7 @@ class MCPTool: ): return "never_require" return None - return self.approval_mode # type: ignore[reportReturnType] + return self.approval_mode # type: ignore[return-value] async def load_prompts(self) -> None: """Load prompts from the MCP server. @@ -835,6 +838,8 @@ class MCPTool: Raises: ToolExecutionException: If the MCP server is not connected. """ + from mcp import types + # Track existing function names to prevent duplicates existing_names = {func.name for func in self._functions} @@ -883,6 +888,8 @@ class MCPTool: Raises: ToolExecutionException: If the MCP server is not connected. """ + from mcp import types + # Track existing function names to prevent duplicates existing_names = {func.name for func in self._functions} @@ -996,6 +1003,9 @@ class MCPTool: ToolExecutionException: If the MCP server is not connected, tools are not loaded, or the tool call fails. """ + from anyio import ClosedResourceError + from mcp.shared.exceptions import McpError + if not self.load_tools_flag: raise ToolExecutionException( "Tools are not loaded for this server, please set load_tools=True in the constructor." @@ -1025,8 +1035,7 @@ class MCPTool: # Inject OpenTelemetry trace context into MCP _meta for distributed tracing. otel_meta = _inject_otel_into_mcp_meta() - parser = self.parse_tool_results or _parse_tool_result_from_mcp - + parser = self.parse_tool_results or self._parse_tool_result_from_mcp # Try the operation, reconnecting once if the connection is closed for attempt in range(2): try: @@ -1062,7 +1071,8 @@ class MCPTool: inner_exception=cl_ex, ) from cl_ex except McpError as mcp_exc: - raise ToolExecutionException(mcp_exc.error.message, inner_exception=mcp_exc) from mcp_exc + error_message = mcp_exc.error.message + raise ToolExecutionException(error_message, inner_exception=mcp_exc) from mcp_exc except Exception as ex: raise ToolExecutionException(f"Failed to call tool '{tool_name}'.", inner_exception=ex) from ex raise ToolExecutionException(f"Failed to call tool '{tool_name}' after retries.") @@ -1083,13 +1093,15 @@ class MCPTool: ToolExecutionException: If the MCP server is not connected, prompts are not loaded, or the prompt call fails. """ + from anyio import ClosedResourceError + from mcp.shared.exceptions import McpError + if not self.load_prompts_flag: raise ToolExecutionException( "Prompts are not loaded for this server, please set load_prompts=True in the constructor." ) - parser = self.parse_prompt_results or _parse_prompt_result_from_mcp - + parser = self.parse_prompt_results or self._parse_prompt_result_from_mcp # Try the operation, reconnecting once if the connection is closed for attempt in range(2): try: @@ -1115,7 +1127,8 @@ class MCPTool: inner_exception=cl_ex, ) from cl_ex except McpError as mcp_exc: - raise ToolExecutionException(mcp_exc.error.message, inner_exception=mcp_exc) from mcp_exc + error_message = mcp_exc.error.message + raise ToolExecutionException(error_message, inner_exception=mcp_exc) from mcp_exc except Exception as ex: raise ToolExecutionException(f"Failed to call prompt '{prompt_name}'.", inner_exception=ex) from ex raise ToolExecutionException(f"Failed to get prompt '{prompt_name}' after retries.") @@ -1289,6 +1302,11 @@ class MCPStdioTool(MCPTool): args["encoding"] = self.encoding if self._client_kwargs: args.update(self._client_kwargs) + try: + from mcp.client.stdio import StdioServerParameters, stdio_client + except ModuleNotFoundError as ex: + raise ModuleNotFoundError("`mcp` is required to use `MCPStdioTool`. Please install `mcp`.") from ex + return stdio_client(server=StdioServerParameters(**args)) @@ -1333,7 +1351,7 @@ class MCPStreamableHTTPTool(MCPTool): terminate_on_close: bool | None = None, client: SupportsChatGetResponse | None = None, additional_properties: dict[str, Any] | None = None, - http_client: httpx.AsyncClient | None = None, + http_client: AsyncClient | None = None, **kwargs: Any, ) -> None: """Initialize the MCP streamable HTTP tool. @@ -1341,7 +1359,7 @@ class MCPStreamableHTTPTool(MCPTool): Note: The arguments are used to create a streamable HTTP client using the new ``mcp.client.streamable_http.streamable_http_client`` API. - If an httpx.AsyncClient is provided via ``http_client``, it will be used directly. + If an asyncClient is provided via ``http_client``, it will be used directly. Otherwise, the ``streamable_http_client`` API will create and manage a default client. Args: @@ -1377,10 +1395,10 @@ class MCPStreamableHTTPTool(MCPTool): additional_properties: Additional properties. terminate_on_close: Close the transport when the MCP client is terminated. client: The chat client to use for sampling. - http_client: Optional httpx.AsyncClient to use. If not provided, the + http_client: Optional asyncClient to use. If not provided, the ``streamable_http_client`` API will create and manage a default client. To configure headers, timeouts, or other HTTP client settings, create - and pass your own ``httpx.AsyncClient`` instance. + and pass your own ``asyncClient`` instance. kwargs: Additional keyword arguments (accepted for backward compatibility but not used). """ super().__init__( @@ -1400,7 +1418,7 @@ class MCPStreamableHTTPTool(MCPTool): ) self.url = url self.terminate_on_close = terminate_on_close - self._httpx_client: httpx.AsyncClient | None = http_client + self._httpx_client: AsyncClient | None = http_client def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: """Get an MCP streamable HTTP client. @@ -1408,6 +1426,11 @@ class MCPStreamableHTTPTool(MCPTool): Returns: An async context manager for the streamable HTTP client transport. """ + try: + from mcp.client.streamable_http import streamable_http_client + except ModuleNotFoundError as ex: + raise ModuleNotFoundError("`mcp` is required to use `MCPStreamableHTTPTool`. Please install `mcp`.") from ex + # Pass the http_client (which may be None) to streamable_http_client return streamable_http_client( url=self.url, @@ -1522,6 +1545,21 @@ class MCPWebsocketTool(MCPTool): Returns: An async context manager for the WebSocket client transport. """ + try: + from mcp.client.websocket import websocket_client + except ModuleNotFoundError as ex: + missing_name = ex.name or "mcp/websocket dependencies" + if missing_name == "mcp" or missing_name.startswith("mcp."): + reason = "The `mcp` package is not installed." + elif missing_name == "websockets" or missing_name.startswith("websockets."): + reason = "WebSocket transport support is not installed." + else: + reason = f"The optional dependency `{missing_name}` is not installed." + raise ModuleNotFoundError( + f"`MCPWebsocketTool` requires websocket transport support. {reason} " + "Please install `mcp[ws]` and update your dependencies." + ) from ex + args: dict[str, Any] = { "url": self.url, } diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 7462181036..f673f5cc61 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -27,9 +27,6 @@ from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Literal, TypedD from dotenv import load_dotenv from opentelemetry import metrics, trace -from opentelemetry.sdk.resources import Resource -from opentelemetry.semconv.attributes import service_attributes -from opentelemetry.semconv_ai import Meters from . import __version__ as version_info from ._settings import load_settings @@ -43,6 +40,7 @@ if TYPE_CHECKING: # pragma: no cover from opentelemetry.sdk._logs.export import LogRecordExporter from opentelemetry.sdk.metrics.export import MetricExporter from opentelemetry.sdk.metrics.view import View + from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace.export import SpanExporter from opentelemetry.trace import Tracer from opentelemetry.util._decorator import _AgnosticContextManager # type: ignore[reportPrivateUsage] @@ -206,6 +204,8 @@ class OtelAttr(str, Enum): TOOL_RESULT = "gen_ai.tool.call.result" # Agent attributes AGENT_ID = "gen_ai.agent.id" + SERVICE_NAME = "service.name" + SERVICE_VERSION = "service.version" # Client attributes # replaced TOKEN with T, because both ruff and bandit, # complain about TOKEN being a potential secret @@ -214,6 +214,8 @@ class OtelAttr(str, Enum): T_TYPE_INPUT = "input" T_TYPE_OUTPUT = "output" DURATION_UNIT = "s" + LLM_OPERATION_DURATION = "gen_ai.client.operation.duration" + LLM_TOKEN_USAGE = "gen_ai.client.token.usage" # nosec B105 # noqa: S105 - OpenTelemetry metric name, not a secret. # Agent attributes AGENT_NAME = "gen_ai.agent.name" @@ -224,8 +226,6 @@ class OtelAttr(str, Enum): INPUT_MESSAGES = "gen_ai.input.messages" OUTPUT_MESSAGES = "gen_ai.output.messages" SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions" - # Attributes previously from opentelemetry-semantic-conventions-ai SpanAttributes, - # removed in v0.4.14. Defined here for forward compatibility. SYSTEM = "gen_ai.system" REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens" REQUEST_TEMPERATURE = "gen_ai.request.temperature" @@ -282,7 +282,7 @@ class OtelAttr(str, Enum): CHAT_COMPLETION_OPERATION = "chat" EMBEDDING_OPERATION = "embeddings" TOOL_EXECUTION_OPERATION = "execute_tool" - # Describes GenAI agent creation and is usually applicable when working with remote agent services. + # Describes GenAI agent creation and is usually applicable when working with remote agent services. AGENT_CREATE_OPERATION = "create_agent" AGENT_INVOKE_OPERATION = "invoke_agent" @@ -576,25 +576,27 @@ def create_resource( # Load from custom .env file resource = create_resource(env_file_path="config/.env") """ - # Load environment variables from a .env file only when explicitly provided + try: + from opentelemetry.sdk.resources import Resource + except ModuleNotFoundError as ex: + raise ModuleNotFoundError( + "`opentelemetry-sdk` is required to use `create_resource()`. " + "Please install `opentelemetry-sdk` and update your dependencies." + ) from ex + if env_file_path is not None: load_dotenv(dotenv_path=env_file_path, encoding=env_file_encoding) - # Start with provided attributes resource_attributes: dict[str, Any] = dict(attributes) - # Set service name if service_name is None: service_name = os.getenv("OTEL_SERVICE_NAME", "agent_framework") - resource_attributes[service_attributes.SERVICE_NAME] = service_name + resource_attributes[OtelAttr.SERVICE_NAME] = service_name - # Set service version if service_version is None: service_version = os.getenv("OTEL_SERVICE_VERSION", version_info) - resource_attributes[service_attributes.SERVICE_VERSION] = service_version + resource_attributes[OtelAttr.SERVICE_VERSION] = service_version - # Parse OTEL_RESOURCE_ATTRIBUTES environment variable - # Format: key1=value1,key2=value2 if resource_attrs_env := os.getenv("OTEL_RESOURCE_ATTRIBUTES"): resource_attributes.update(_parse_headers(resource_attrs_env)) return Resource.create(resource_attributes) @@ -602,10 +604,15 @@ def create_resource( def create_metric_views() -> list[View]: """Create the default OpenTelemetry metric views for Agent Framework.""" - from opentelemetry.sdk.metrics.view import DropAggregation, View + try: + from opentelemetry.sdk.metrics.view import DropAggregation, View + except ModuleNotFoundError as ex: + raise ModuleNotFoundError( + "`opentelemetry-sdk` is required to use `create_metric_views()`. " + "Please install `opentelemetry-sdk` and update your dependencies." + ) from ex return [ - # Dropping all enable_instrumentation names except for those starting with "agent_framework" View(instrument_name="agent_framework*"), View(instrument_name="gen_ai*"), View(instrument_name="*", aggregation=DropAggregation()), @@ -659,7 +666,7 @@ class ObservabilitySettings: """ def __init__(self, **kwargs: Any) -> None: - """Initialize the settings and create the resource.""" + """Initialize the settings.""" env_file_path = kwargs.pop("env_file_path", None) env_file_encoding = kwargs.pop("env_file_encoding", None) data = load_settings( @@ -674,10 +681,6 @@ class ObservabilitySettings: self.vs_code_extension_port: int | None = data.get("vs_code_extension_port") self.env_file_path = env_file_path self.env_file_encoding = env_file_encoding - self._resource = create_resource( - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) self._executed_setup = False @property @@ -762,17 +765,27 @@ class ObservabilitySettings: exporters: A list of exporters for logs, metrics and/or spans. views: Optional list of OpenTelemetry views for metrics. Default is empty list. """ - from opentelemetry._logs import set_logger_provider - from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler - from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, LogRecordExporter - from opentelemetry.sdk.metrics import MeterProvider - from opentelemetry.sdk.metrics.export import MetricExporter, PeriodicExportingMetricReader - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter + try: + from opentelemetry._logs import set_logger_provider + from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler + from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, LogRecordExporter + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics.export import MetricExporter, PeriodicExportingMetricReader + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter + except ModuleNotFoundError as ex: + raise ModuleNotFoundError( + "`opentelemetry-sdk` is required to use `configure_otel_providers()`. " + "Please install `opentelemetry-sdk` and update your dependencies." + ) from ex span_exporters: list[SpanExporter] = [] log_exporters: list[LogRecordExporter] = [] metric_exporters: list[MetricExporter] = [] + resource = create_resource( + env_file_path=self.env_file_path, + env_file_encoding=self.env_file_encoding, + ) for exp in exporters: if isinstance(exp, SpanExporter): span_exporters.append(exp) @@ -783,14 +796,14 @@ class ObservabilitySettings: # Tracing if span_exporters: - tracer_provider = TracerProvider(resource=self._resource) + tracer_provider = TracerProvider(resource=resource) trace.set_tracer_provider(tracer_provider) for exporter in span_exporters: tracer_provider.add_span_processor(BatchSpanProcessor(exporter)) # Logging if log_exporters: - logger_provider = LoggerProvider(resource=self._resource) + logger_provider = LoggerProvider(resource=resource) for log_exporter in log_exporters: logger_provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter)) # Attach a handler with the provider to the root logger @@ -805,7 +818,7 @@ class ObservabilitySettings: PeriodicExportingMetricReader(exporter, export_interval_millis=5000) for exporter in metric_exporters ], - resource=self._resource, + resource=resource, views=views or [], ) metrics.set_meter_provider(meter_provider) @@ -1106,7 +1119,6 @@ def configure_otel_providers( OBSERVABILITY_SETTINGS.vs_code_extension_port = updated_settings.vs_code_extension_port OBSERVABILITY_SETTINGS.env_file_path = updated_settings.env_file_path OBSERVABILITY_SETTINGS.env_file_encoding = updated_settings.env_file_encoding - OBSERVABILITY_SETTINGS._resource = updated_settings._resource # type: ignore[reportPrivateUsage] OBSERVABILITY_SETTINGS._executed_setup = False # type: ignore[reportPrivateUsage] else: # Re-read settings from current environment in case env vars were set @@ -1123,7 +1135,6 @@ def configure_otel_providers( OBSERVABILITY_SETTINGS.vs_code_extension_port = ( vs_code_extension_port if vs_code_extension_port is not None else _read_int_env("VS_CODE_EXTENSION_PORT") ) - OBSERVABILITY_SETTINGS._resource = create_resource() # type: ignore[reportPrivateUsage] OBSERVABILITY_SETTINGS._executed_setup = False # type: ignore[reportPrivateUsage] OBSERVABILITY_SETTINGS._configure( # type: ignore[reportPrivateUsage] @@ -1137,7 +1148,7 @@ def configure_otel_providers( def _get_duration_histogram() -> metrics.Histogram: return get_meter().create_histogram( - name=Meters.LLM_OPERATION_DURATION, + name=OtelAttr.LLM_OPERATION_DURATION, unit=OtelAttr.DURATION_UNIT, description="Captures the duration of operations of function-invoking chat clients", explicit_bucket_boundaries_advisory=OPERATION_DURATION_BUCKET_BOUNDARIES, @@ -1146,7 +1157,7 @@ def _get_duration_histogram() -> metrics.Histogram: def _get_token_usage_histogram() -> metrics.Histogram: return get_meter().create_histogram( - name=Meters.LLM_TOKEN_USAGE, + name=OtelAttr.LLM_TOKEN_USAGE, unit=OtelAttr.T_UNIT, description="Captures the token usage of chat clients", explicit_bucket_boundaries_advisory=TOKEN_USAGE_BUCKET_BOUNDARIES, diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 6c855c872d..caeebc5319 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -23,28 +23,20 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - # utilities "typing-extensions>=4.15.0,<5", "pydantic>=2,<3", "python-dotenv>=1,<2", - # telemetry "opentelemetry-api>=1.39.0,<2", - "opentelemetry-sdk>=1.39.0,<2", - "opentelemetry-semantic-conventions-ai>=0.4.13,<0.4.14", - # connectors and functions - "openai>=1.99.0,<3", - "azure-identity>=1,<2", - "azure-ai-projects>=2.0.0,<3.0", - "mcp[ws]>=1.24.0,<2", - "packaging>=24.1,<25", ] [project.optional-dependencies] all = [ + "mcp>=1.24.0,<2", "agent-framework-a2a", "agent-framework-ag-ui", "agent-framework-azure-ai-search", "agent-framework-anthropic", + "agent-framework-openai", "agent-framework-claude", "agent-framework-azure-ai", "agent-framework-azurefunctions", diff --git a/python/packages/core/tests/conftest.py b/python/packages/core/tests/conftest.py index 08f3f07762..b4900808f4 100644 --- a/python/packages/core/tests/conftest.py +++ b/python/packages/core/tests/conftest.py @@ -67,7 +67,7 @@ def span_exporter(monkeypatch, enable_instrumentation: bool, enable_sensitive_da if enable_instrumentation or enable_sensitive_data: from opentelemetry.sdk.trace import TracerProvider - tracer_provider = TracerProvider(resource=observability_settings._resource) + tracer_provider = TracerProvider(resource=observability.create_resource()) trace.set_tracer_provider(tracer_provider) monkeypatch.setattr(observability, "OBSERVABILITY_SETTINGS", observability_settings, raising=False) # type: ignore diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 7972a6d50d..3c340a13b8 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. # type: ignore[reportPrivateUsage] +import json import logging import os from contextlib import _AsyncGeneratorContextManager # type: ignore @@ -23,13 +24,9 @@ from agent_framework import ( ) from agent_framework._mcp import ( MCPTool, + _build_prefixed_mcp_name, _get_input_model_from_mcp_prompt, _normalize_mcp_name, - _parse_content_from_mcp, - _parse_message_from_mcp, - _parse_tool_result_from_mcp, - _prepare_content_for_mcp, - _prepare_message_for_mcp, logger, ) from agent_framework._middleware import FunctionMiddlewarePipeline @@ -50,6 +47,9 @@ def _mcp_result_to_text(result: str | list[Content]) -> str: return text or str(result) +_HELPER_MCP_TOOL = MCPTool(name="helper") + + # Helper function tests def test_normalize_mcp_name(): """Test MCP name normalization.""" @@ -61,6 +61,10 @@ def test_normalize_mcp_name(): assert _normalize_mcp_name("name/with\\slashes") == "name-with-slashes" +def test_build_prefixed_mcp_name_ignores_empty_normalized_prefix() -> None: + assert _build_prefixed_mcp_name("search", "---") == "search" + + def test_mcp_transport_subclasses_accept_tool_name_prefix() -> None: assert MCPStdioTool(name="stdio", command="python", tool_name_prefix="stdio").tool_name_prefix == "stdio" assert ( @@ -139,7 +143,7 @@ async def test_load_prompts_with_tool_name_prefix() -> None: def test_mcp_prompt_message_to_ai_content(): """Test conversion from MCP prompt message to AI content.""" mcp_message = types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hello, world!")) - ai_content = _parse_message_from_mcp(mcp_message) + ai_content = _HELPER_MCP_TOOL._parse_message_from_mcp(mcp_message) assert isinstance(ai_content, Message) assert ai_content.role == "user" @@ -149,6 +153,55 @@ def test_mcp_prompt_message_to_ai_content(): assert ai_content.raw_representation == mcp_message +def test_mcp_tool_str_and_parse_prompt_result_rich_content() -> None: + tool = MCPTool(name="helper", description="Helper MCP tool") + prompt_result = types.GetPromptResult( + messages=[ + types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hello")), + types.PromptMessage( + role="assistant", + content=types.ImageContent(type="image", data="eHl6", mimeType="image/png"), + ), + types.PromptMessage( + role="assistant", + content=types.AudioContent(type="audio", data="YXVkaW8=", mimeType="audio/wav"), + ), + types.PromptMessage( + role="assistant", + content=types.EmbeddedResource( + type="resource", + resource=types.TextResourceContents( + uri=AnyUrl("file://prompt.txt"), + mimeType="text/plain", + text="Embedded prompt", + ), + ), + ), + types.PromptMessage( + role="assistant", + content=types.EmbeddedResource( + type="resource", + resource=types.BlobResourceContents( + uri=AnyUrl("file://prompt.bin"), + mimeType="application/pdf", + blob="ZGF0YQ==", + ), + ), + ), + ] + ) + + result = tool._parse_prompt_result_from_mcp(prompt_result) + parsed = json.loads(result) + + assert str(tool) == "MCPTool(name=helper, description=Helper MCP tool)" + assert parsed[0] == "Hello" + assert json.loads(parsed[1]) == {"type": "image", "data": "eHl6", "mimeType": "image/png"} + assert json.loads(parsed[2]) == {"type": "audio", "data": "YXVkaW8=", "mimeType": "audio/wav"} + assert parsed[3] == "Embedded prompt" + assert json.loads(parsed[4]) == {"type": "blob", "data": "ZGF0YQ==", "mimeType": "application/pdf"} + + def test_parse_tool_result_from_mcp(): """Test conversion from MCP tool result with images preserves original order.""" mcp_result = types.CallToolResult( @@ -159,7 +212,7 @@ def test_parse_tool_result_from_mcp(): types.ImageContent(type="image", data="YWJj", mimeType="image/webp"), ] ) - result = _parse_tool_result_from_mcp(mcp_result) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) # Results with images return a list of Content objects in original order assert isinstance(result, list) @@ -180,7 +233,7 @@ def test_parse_tool_result_from_mcp(): def test_parse_tool_result_from_mcp_single_text(): """Test conversion from MCP tool result with a single text item.""" mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="Simple result")]) - result = _parse_tool_result_from_mcp(mcp_result) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) # Single text item returns list with one text Content assert isinstance(result, list) @@ -196,7 +249,7 @@ def test_parse_tool_result_from_mcp_meta_not_in_string(): _meta={"isError": True, "errorCode": "TOOL_ERROR"}, ) - result = _parse_tool_result_from_mcp(mcp_result) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) assert isinstance(result, list) assert len(result) == 1 assert result[0].text == "Error occurred" @@ -205,7 +258,7 @@ def test_parse_tool_result_from_mcp_meta_not_in_string(): def test_parse_tool_result_from_mcp_empty_content(): """Test that empty MCP content normalizes to JSON null text content.""" mcp_result = types.CallToolResult(content=[]) - result = _parse_tool_result_from_mcp(mcp_result) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) assert isinstance(result, list) assert len(result) == 1 assert result[0].type == "text" @@ -222,7 +275,7 @@ def test_parse_tool_result_from_mcp_audio_content(): types.AudioContent(type="audio", data="YXVkaW8=", mimeType="audio/wav"), ] ) - result = _parse_tool_result_from_mcp(mcp_result) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) assert isinstance(result, list) assert len(result) == 1 @@ -245,7 +298,7 @@ def test_parse_tool_result_from_mcp_blob_plain_base64(): ), ] ) - result = _parse_tool_result_from_mcp(mcp_result) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) assert isinstance(result, list) assert len(result) == 1 @@ -254,10 +307,39 @@ def test_parse_tool_result_from_mcp_blob_plain_base64(): assert "dGVzdCBkYXRh" in result[0].uri +def test_parse_tool_result_from_mcp_resource_link_text_resource_and_unknown(): + """Test additional MCP tool result variants.""" + mcp_result = types.CallToolResult( + content=[ + types.ResourceLink( + type="resource_link", + uri=AnyUrl("https://example.com/resource"), + name="resource", + mimeType="application/json", + ), + types.EmbeddedResource( + type="resource", + resource=types.TextResourceContents( + uri=AnyUrl("file://prompt.txt"), + mimeType="text/plain", + text="Embedded result", + ), + ), + ] + ) + + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) + + assert result[0].type == "uri" + assert result[0].uri == "https://example.com/resource" + assert result[1].type == "text" + assert result[1].text == "Embedded result" + + def test_mcp_content_types_to_ai_content_text(): """Test conversion of MCP text content to AI content.""" mcp_content = types.TextContent(type="text", text="Sample text") - ai_content = _parse_content_from_mcp(mcp_content)[0] + ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0] assert ai_content.type == "text" assert ai_content.text == "Sample text" @@ -268,7 +350,7 @@ def test_mcp_content_types_to_ai_content_image(): """Test conversion of MCP image content to AI content.""" # MCP can send data as base64 string or as bytes mcp_content = types.ImageContent(type="image", data="YWJj", mimeType="image/jpeg") # base64 for b"abc" - ai_content = _parse_content_from_mcp(mcp_content)[0] + ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0] assert ai_content.type == "data" assert ai_content.uri == "data:image/jpeg;base64,YWJj" @@ -280,7 +362,7 @@ def test_mcp_content_types_to_ai_content_audio(): """Test conversion of MCP audio content to AI content.""" # Use properly padded base64 mcp_content = types.AudioContent(type="audio", data="ZGVm", mimeType="audio/wav") # base64 for b"def" - ai_content = _parse_content_from_mcp(mcp_content)[0] + ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0] assert ai_content.type == "data" assert ai_content.uri == "data:audio/wav;base64,ZGVm" @@ -296,7 +378,7 @@ def test_mcp_content_types_to_ai_content_resource_link(): name="test_resource", mimeType="application/json", ) - ai_content = _parse_content_from_mcp(mcp_content)[0] + ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0] assert ai_content.type == "uri" assert ai_content.uri == "https://example.com/resource" @@ -312,7 +394,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_text(): text="Embedded text content", ) mcp_content = types.EmbeddedResource(type="resource", resource=text_resource) - ai_content = _parse_content_from_mcp(mcp_content)[0] + ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0] assert ai_content.type == "text" assert ai_content.text == "Embedded text content" @@ -328,7 +410,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_blob(): blob="data:application/octet-stream;base64,dGVzdCBkYXRh", ) mcp_content = types.EmbeddedResource(type="resource", resource=blob_resource) - ai_content = _parse_content_from_mcp(mcp_content)[0] + ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0] assert ai_content.type == "data" assert ai_content.uri == "data:application/octet-stream;base64,dGVzdCBkYXRh" @@ -336,10 +418,33 @@ def test_mcp_content_types_to_ai_content_embedded_resource_blob(): assert ai_content.raw_representation == mcp_content +def test_mcp_content_types_to_ai_content_tool_use_and_tool_result(): + """Test conversion of MCP tool use/result content to AI function call/result content.""" + tool_use_content = types.ToolUseContent(type="tool_use", id="call-1", name="calculator", input={"x": 1}) + tool_result_content = types.ToolResultContent( + type="tool_result", + toolUseId="call-1", + content=[types.TextContent(type="text", text="done")], + isError=True, + ) + + function_call = _HELPER_MCP_TOOL._parse_content_from_mcp(tool_use_content)[0] + function_result = _HELPER_MCP_TOOL._parse_content_from_mcp(tool_result_content)[0] + + assert function_call.type == "function_call" + assert function_call.call_id == "call-1" + assert function_call.name == "calculator" + assert function_call.arguments == {"x": 1} + assert function_result.type == "function_result" + assert function_result.call_id == "call-1" + assert function_result.result == "done" + assert function_result.exception == "" + + def test_ai_content_to_mcp_content_types_text(): """Test conversion of AI text content to MCP content.""" ai_content = Content.from_text(text="Sample text") - mcp_content = _prepare_content_for_mcp(ai_content) + mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content) assert isinstance(mcp_content, types.TextContent) assert mcp_content.type == "text" @@ -349,7 +454,7 @@ def test_ai_content_to_mcp_content_types_text(): def test_ai_content_to_mcp_content_types_data_image(): """Test conversion of AI data content to MCP content.""" ai_content = Content.from_uri(uri="data:image/png;base64,xyz", media_type="image/png") - mcp_content = _prepare_content_for_mcp(ai_content) + mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content) assert isinstance(mcp_content, types.ImageContent) assert mcp_content.type == "image" @@ -360,7 +465,7 @@ def test_ai_content_to_mcp_content_types_data_image(): def test_ai_content_to_mcp_content_types_data_audio(): """Test conversion of AI data content to MCP content.""" ai_content = Content.from_uri(uri="data:audio/mpeg;base64,xyz", media_type="audio/mpeg") - mcp_content = _prepare_content_for_mcp(ai_content) + mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content) assert isinstance(mcp_content, types.AudioContent) assert mcp_content.type == "audio" @@ -374,7 +479,7 @@ def test_ai_content_to_mcp_content_types_data_binary(): uri="data:application/octet-stream;base64,xyz", media_type="application/octet-stream", ) - mcp_content = _prepare_content_for_mcp(ai_content) + mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content) assert isinstance(mcp_content, types.EmbeddedResource) assert mcp_content.type == "resource" @@ -385,7 +490,7 @@ def test_ai_content_to_mcp_content_types_data_binary(): def test_ai_content_to_mcp_content_types_uri(): """Test conversion of AI URI content to MCP content.""" ai_content = Content.from_uri(uri="https://example.com/resource", media_type="application/json") - mcp_content = _prepare_content_for_mcp(ai_content) + mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content) assert isinstance(mcp_content, types.ResourceLink) assert mcp_content.type == "resource_link" @@ -401,12 +506,24 @@ def test_prepare_message_for_mcp(): Content.from_uri(uri="data:image/png;base64,xyz", media_type="image/png"), ], ) - mcp_contents = _prepare_message_for_mcp(message) + mcp_contents = _HELPER_MCP_TOOL._prepare_message_for_mcp(message) assert len(mcp_contents) == 2 assert isinstance(mcp_contents[0], types.TextContent) assert isinstance(mcp_contents[1], types.ImageContent) +def test_prepare_message_for_mcp_skips_unsupported_content() -> None: + unsupported = Content(type="annotations", text="ignored") + + assert _HELPER_MCP_TOOL._prepare_content_for_mcp(unsupported) is None + + mcp_contents = _HELPER_MCP_TOOL._prepare_message_for_mcp( + Message(role="user", contents=[Content.from_text("kept"), unsupported]) + ) + assert len(mcp_contents) == 1 + assert isinstance(mcp_contents[0], types.TextContent) + + @pytest.mark.parametrize( "test_id,input_schema", [ @@ -1287,6 +1404,18 @@ async def test_mcp_tool_approval_mode(approval_mode, expected_approvals): assert func.approval_mode == expected_approvals[func.name] +def test_mcp_tool_approval_mode_returns_none_for_unmatched_names() -> None: + tool = MCPTool( + name="test_tool", + approval_mode={ + "always_require_approval": ["tool_one"], + "never_require_approval": ["tool_two"], + }, + ) + + assert tool._determine_approval_mode("tool_three") is None + + @pytest.mark.parametrize( "allowed_tools,expected_count,expected_names", [ @@ -1618,6 +1747,46 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): assert "Failed to get right content types from the response." in result.message +async def test_mcp_tool_sampling_callback_no_response_and_successful_message_creation(): + """Test sampling callback when the chat client returns no response and then valid content.""" + tool = MCPStdioTool(name="test_tool", command="python") + tool.client = AsyncMock() + + params = Mock() + params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + + tool.client.get_response.return_value = None + no_response = await tool.sampling_callback(Mock(), params) + + assert isinstance(no_response, types.ErrorData) + assert no_response.message == "Failed to get chat message content." + + tool.client.get_response.return_value = Mock( + messages=[Message(role="assistant", contents=[Content.from_text("Hello")])], + model_id="test-model", + ) + + success = await tool.sampling_callback(Mock(), params) + + assert isinstance(success, types.CreateMessageResult) + assert success.role == "assistant" + assert success.model == "test-model" + assert isinstance(success.content, types.TextContent) + assert success.content.text == "Hello" + + +async def test_mcp_tool_logging_callback_logs_at_requested_level() -> None: + tool = MCPStdioTool(name="test_tool", command="python") + + with patch.object(logger, "log") as mock_log: + await tool.logging_callback(types.LoggingMessageNotificationParams(level="warning", data="be careful")) + + mock_log.assert_called_once_with(logging.WARNING, "be careful") + + # Test error handling in connect() method @@ -1633,7 +1802,7 @@ async def test_connect_session_creation_failure(): tool.get_mcp_client = Mock(return_value=mock_context_manager) # Mock ClientSession to raise an exception - with patch("agent_framework._mcp.ClientSession") as mock_session_class: + with patch("mcp.client.session.ClientSession") as mock_session_class: mock_session_class.side_effect = RuntimeError("Session creation failed") with pytest.raises(ToolException) as exc_info: @@ -1658,7 +1827,7 @@ async def test_connect_initialization_failure_http_no_command(): mock_session = Mock() mock_session.initialize = AsyncMock(side_effect=ConnectionError("Server not ready")) - with patch("agent_framework._mcp.ClientSession") as mock_session_class: + with patch("mcp.client.session.ClientSession") as mock_session_class: mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session) mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None) @@ -1687,6 +1856,18 @@ async def test_connect_cleanup_on_transport_failure(): tool._exit_stack.aclose.assert_called_once() +async def test_connect_cleanup_on_transport_failure_http_uses_generic_message(): + """Test HTTP transport failures use the generic connection message when no command exists.""" + tool = MCPStreamableHTTPTool(name="test", url="https://example.com/mcp") + tool._exit_stack.aclose = AsyncMock() + tool.get_mcp_client = Mock(side_effect=RuntimeError("Transport failed")) + + with pytest.raises(ToolException, match="Failed to connect to MCP server: Transport failed"): + await tool.connect() + + tool._exit_stack.aclose.assert_called_once() + + async def test_connect_cleanup_on_initialization_failure(): """Test that _exit_stack.aclose() is called when initialization fails.""" tool = MCPStdioTool(name="test", command="test-command") @@ -1705,7 +1886,7 @@ async def test_connect_cleanup_on_initialization_failure(): mock_session = Mock() mock_session.initialize = AsyncMock(side_effect=RuntimeError("Init failed")) - with patch("agent_framework._mcp.ClientSession") as mock_session_class: + with patch("mcp.client.session.ClientSession") as mock_session_class: mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session) mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None) @@ -1722,18 +1903,20 @@ def test_mcp_stdio_tool_get_mcp_client_with_env_and_kwargs(): tool = MCPStdioTool( name="test", command="test-command", + encoding="utf-16", env=env_vars, custom_param="value1", another_param=42, ) - with patch("agent_framework._mcp.stdio_client"), patch("agent_framework._mcp.StdioServerParameters") as mock_params: + with patch("mcp.client.stdio.stdio_client"), patch("mcp.client.stdio.StdioServerParameters") as mock_params: tool.get_mcp_client() # Verify all parameters including custom kwargs were passed mock_params.assert_called_once_with( command="test-command", args=[], + encoding="utf-16", env=env_vars, custom_param="value1", another_param=42, @@ -1748,7 +1931,7 @@ def test_mcp_streamable_http_tool_get_mcp_client_all_params(): terminate_on_close=True, ) - with patch("agent_framework._mcp.streamable_http_client") as mock_http_client: + with patch("mcp.client.streamable_http.streamable_http_client") as mock_http_client: tool.get_mcp_client() # Verify streamable_http_client was called with None for http_client @@ -1770,7 +1953,7 @@ def test_mcp_websocket_tool_get_mcp_client_with_kwargs(): compression="deflate", ) - with patch("agent_framework._mcp.websocket_client") as mock_ws_client: + with patch("mcp.client.websocket.websocket_client") as mock_ws_client: tool.get_mcp_client() # Verify all kwargs were passed @@ -1928,8 +2111,8 @@ async def test_mcp_streamable_http_tool_httpx_client_cleanup(): # Mock the streamable_http_client to avoid actual connections with ( - patch("agent_framework._mcp.streamable_http_client") as mock_client, - patch("agent_framework._mcp.ClientSession") as mock_session_class, + patch("mcp.client.streamable_http.streamable_http_client") as mock_client, + patch("mcp.client.session.ClientSession") as mock_session_class, ): # Setup mock context manager for streamable_http_client mock_transport = (Mock(), Mock()) @@ -2624,6 +2807,80 @@ async def test_mcp_tool_get_prompt_reconnection_on_closed_resource_error(): assert "failed to reconnect" in str(exc_info.value).lower() +async def test_mcp_tool_call_tool_requires_loaded_tools() -> None: + tool = MCPTool(name="test_tool", load_tools=False) + + with pytest.raises(ToolExecutionException, match="Tools are not loaded"): + await tool.call_tool("remote_tool") + + +async def test_mcp_tool_get_prompt_requires_loaded_prompts() -> None: + tool = MCPTool(name="test_tool", load_prompts=False) + + with pytest.raises(ToolExecutionException, match="Prompts are not loaded"): + await tool.get_prompt("remote_prompt") + + +async def test_mcp_tool_call_tool_raises_after_reconnection_still_fails() -> None: + from anyio.streams.memory import ClosedResourceError + + tool = MCPTool(name="test_tool", load_tools=True) + tool.session = Mock(call_tool=AsyncMock(side_effect=[ClosedResourceError(), ClosedResourceError()])) + + with ( + patch.object(tool, "connect", AsyncMock()) as mock_connect, + patch.object(logger, "error") as mock_error, + pytest.raises(ToolExecutionException, match="connection lost"), + ): + await tool.call_tool("remote_tool") + + mock_connect.assert_awaited_once_with(reset=True) + mock_error.assert_called_once() + + +async def test_mcp_tool_get_prompt_raises_after_reconnection_still_fails() -> None: + from anyio.streams.memory import ClosedResourceError + + tool = MCPTool(name="test_tool", load_prompts=True) + tool.session = Mock(get_prompt=AsyncMock(side_effect=[ClosedResourceError(), ClosedResourceError()])) + + with ( + patch.object(tool, "connect", AsyncMock()) as mock_connect, + patch.object(logger, "error") as mock_error, + pytest.raises(ToolExecutionException, match="connection lost"), + ): + await tool.get_prompt("remote_prompt") + + mock_connect.assert_awaited_once_with(reset=True) + mock_error.assert_called_once() + + +async def test_mcp_tool_wraps_unexpected_call_tool_and_get_prompt_errors() -> None: + tool = MCPTool(name="test_tool", load_tools=True, load_prompts=True) + tool.session = Mock() + tool.session.call_tool = AsyncMock(side_effect=RuntimeError("tool boom")) + tool.session.get_prompt = AsyncMock(side_effect=RuntimeError("prompt boom")) + + with pytest.raises(ToolExecutionException, match="Failed to call tool 'remote_tool'"): + await tool.call_tool("remote_tool") + + with pytest.raises(ToolExecutionException, match="Failed to call prompt 'remote_prompt'"): + await tool.get_prompt("remote_prompt") + + +async def test_mcp_tool_aenter_wraps_unexpected_errors_and_closes() -> None: + tool = MCPStdioTool(name="test_tool", command="python") + + with ( + patch.object(tool, "connect", AsyncMock(side_effect=RuntimeError("boom"))), + patch.object(tool, "close", AsyncMock()) as mock_close, + pytest.raises(ToolExecutionException, match="Failed to enter context manager"), + ): + await tool.__aenter__() + + mock_close.assert_awaited_once() + + async def test_mcp_tool_close_cleans_up_in_original_task(caplog): """Closing an MCP tool from another task should still unwind contexts in the owner task.""" import asyncio @@ -2663,7 +2920,7 @@ async def test_mcp_tool_close_cleans_up_in_original_task(caplog): with ( patch.object(tool, "get_mcp_client", return_value=transport_context), - patch("agent_framework._mcp.ClientSession", return_value=mock_session_context), + patch("mcp.client.session.ClientSession", return_value=mock_session_context), ): await asyncio.create_task(tool.connect()) @@ -2721,7 +2978,7 @@ async def test_mcp_tool_connect_reset_cleans_up_in_original_task(caplog): with ( patch.object(tool, "get_mcp_client", side_effect=transport_contexts), - patch("agent_framework._mcp.ClientSession", side_effect=session_contexts), + patch("mcp.client.session.ClientSession", side_effect=session_contexts), ): await tool.connect() @@ -2905,7 +3162,7 @@ async def test_connect_sets_logging_level_when_logger_level_is_set(): with ( patch.object(tool, "get_mcp_client", return_value=mock_context), - patch("agent_framework._mcp.ClientSession", return_value=mock_session_context), + patch("mcp.client.session.ClientSession", return_value=mock_session_context), patch.object(logger, "level", logging.DEBUG), # Set logger level to DEBUG ): await tool.connect() @@ -2942,7 +3199,7 @@ async def test_connect_does_not_set_logging_level_when_logger_level_is_notset(): with ( patch.object(tool, "get_mcp_client", return_value=mock_context), - patch("agent_framework._mcp.ClientSession", return_value=mock_session_context), + patch("mcp.client.session.ClientSession", return_value=mock_session_context), patch.object(logger, "level", logging.NOTSET), # Set logger level to NOTSET ): await tool.connect() @@ -2980,7 +3237,7 @@ async def test_connect_handles_set_logging_level_exception(): with ( patch.object(tool, "get_mcp_client", return_value=mock_context), - patch("agent_framework._mcp.ClientSession", return_value=mock_session_context), + patch("mcp.client.session.ClientSession", return_value=mock_session_context), patch.object(logger, "level", logging.INFO), # Set logger level to INFO patch.object(logger, "warning") as mock_warning, ): @@ -2996,6 +3253,48 @@ async def test_connect_handles_set_logging_level_exception(): assert "Failed to set log level" in call_args[0][0] +async def test_connect_reinitializes_existing_session_and_loads_tools_and_prompts() -> None: + tool = MCPTool(name="test_tool", load_tools=True, load_prompts=True) + tool.is_connected = True + tool.session = Mock() + tool.session._request_id = 0 + tool.session.initialize = AsyncMock() + + with ( + patch.object(tool, "load_tools", AsyncMock()) as mock_load_tools, + patch.object(tool, "load_prompts", AsyncMock()) as mock_load_prompts, + patch.object(logger, "level", logging.NOTSET), + ): + await tool._connect_on_owner() + + tool.session.initialize.assert_awaited_once() + mock_load_tools.assert_awaited_once() + mock_load_prompts.assert_awaited_once() + assert tool._tools_loaded is True + assert tool._prompts_loaded is True + + +async def test_ensure_connected_reconnects_on_failed_ping() -> None: + tool = MCPTool(name="test_tool") + tool.session = Mock(send_ping=AsyncMock(side_effect=RuntimeError("closed"))) + + with patch.object(tool, "connect", AsyncMock()) as mock_connect: + await tool._ensure_connected() + + mock_connect.assert_awaited_once_with(reset=True) + + +async def test_ensure_connected_wraps_reconnect_failure() -> None: + tool = MCPTool(name="test_tool") + tool.session = Mock(send_ping=AsyncMock(side_effect=RuntimeError("closed"))) + + with ( + patch.object(tool, "connect", AsyncMock(side_effect=RuntimeError("still closed"))), + pytest.raises(ToolExecutionException, match="Failed to establish MCP connection"), + ): + await tool._ensure_connected() + + async def test_mcp_tool_filters_framework_kwargs(): """Test that call_tool filters out framework-specific kwargs before calling MCP session. diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 5152712b8c..7642ffe73a 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -3,7 +3,7 @@ import logging from collections.abc import AsyncIterable, Awaitable, MutableSequence, Sequence from typing import Any -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter @@ -1080,7 +1080,8 @@ def test_configure_otel_providers_reads_env_sensitive_data(monkeypatch): # Simulate load_dotenv() setting env var after import monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true") - observability.configure_otel_providers() + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers() assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True @@ -1135,7 +1136,8 @@ def test_configure_otel_providers_explicit_param_overrides_env(monkeypatch): importlib.reload(observability) # Explicit False should override the env var True - observability.configure_otel_providers(enable_sensitive_data=False) + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers(enable_sensitive_data=False) assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False @@ -1196,7 +1198,8 @@ def test_enable_instrumentation_does_not_clobber_console_exporters(monkeypatch): importlib.reload(observability) # Set console exporters via configure_otel_providers - observability.configure_otel_providers(enable_console_exporters=True) + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers(enable_console_exporters=True) assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True # Calling enable_instrumentation should not clobber the value @@ -1224,7 +1227,8 @@ def test_enable_instrumentation_with_sensitive_data_does_not_touch_console_expor importlib.reload(observability) # Set console exporters via configure_otel_providers - observability.configure_otel_providers(enable_console_exporters=True) + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers(enable_console_exporters=True) assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True # Calling enable_instrumentation with explicit sensitive_data should not clobber console exporters @@ -1275,7 +1279,8 @@ def test_configure_otel_providers_reads_env_console_exporters(monkeypatch): # Simulate load_dotenv() setting env var after import monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true") - observability.configure_otel_providers() + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers() assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True @@ -1298,7 +1303,8 @@ def test_configure_otel_providers_explicit_console_exporters_overrides_env(monke importlib.reload(observability) # Explicit False should override the env var True - observability.configure_otel_providers(enable_console_exporters=False) + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers(enable_console_exporters=False) assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False @@ -2005,6 +2011,14 @@ async def test_agent_streaming_observability(span_exporter: InMemorySpanExporter assert len(spans) == 1 +def test_agent_middleware_wraps_agent_telemetry() -> None: + """Agent middleware must run outside telemetry so middleware time is excluded from agent latency.""" + from agent_framework import Agent + from agent_framework._middleware import AgentMiddlewareLayer + + assert Agent.__mro__.index(AgentMiddlewareLayer) < Agent.__mro__.index(AgentTelemetryLayer) + + # region Test AgentTelemetryLayer error cases @@ -3049,11 +3063,12 @@ def test_configure_otel_providers_with_env_file_path(monkeypatch, tmp_path): env_file = tmp_path / ".env" env_file.write_text("ENABLE_INSTRUMENTATION=true\n") - observability.configure_otel_providers( - env_file_path=str(env_file), - enable_sensitive_data=True, - vs_code_extension_port=None, - ) + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers( + env_file_path=str(env_file), + enable_sensitive_data=True, + vs_code_extension_port=None, + ) assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True @@ -3078,11 +3093,12 @@ def test_configure_otel_providers_with_env_file_and_vs_code_port(monkeypatch, tm env_file = tmp_path / ".env" env_file.write_text("ENABLE_INSTRUMENTATION=true\n") - observability.configure_otel_providers( - env_file_path=str(env_file), - env_file_encoding="utf-8", - vs_code_extension_port=4317, - ) + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers( + env_file_path=str(env_file), + env_file_encoding="utf-8", + vs_code_extension_port=4317, + ) assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317 diff --git a/python/packages/core/tests/core/test_optional_dependencies.py b/python/packages/core/tests/core/test_optional_dependencies.py new file mode 100644 index 0000000000..7c424b3454 --- /dev/null +++ b/python/packages/core/tests/core/test_optional_dependencies.py @@ -0,0 +1,181 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import sys + +import pytest + +import agent_framework +import agent_framework.observability as observability +from agent_framework import Agent + + +def _hide_otel_sdk(monkeypatch: pytest.MonkeyPatch) -> None: + import builtins + + real_import = builtins.__import__ + for module_name in list(sys.modules): + if module_name == "opentelemetry.sdk" or module_name.startswith("opentelemetry.sdk."): + sys.modules.pop(module_name, None) + + def _import_without_otel_sdk( + name: str, + globals_: dict[str, object] | None = None, + locals_: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + if name == "opentelemetry.sdk" or name.startswith("opentelemetry.sdk."): + raise ModuleNotFoundError(f"No module named '{name}'", name=name) + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", _import_without_otel_sdk) + + +def test_create_resource_requires_otel_sdk(monkeypatch: pytest.MonkeyPatch) -> None: + _hide_otel_sdk(monkeypatch) + + with pytest.raises(ModuleNotFoundError, match="opentelemetry-sdk"): + observability.create_resource() + + +def test_observability_settings_initializes_without_cached_resource(monkeypatch: pytest.MonkeyPatch) -> None: + _hide_otel_sdk(monkeypatch) + + settings = observability.ObservabilitySettings() + + assert not hasattr(settings, "_resource") + + +def test_configure_otel_providers_requires_otel_sdk(monkeypatch: pytest.MonkeyPatch) -> None: + _hide_otel_sdk(monkeypatch) + for key in [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + "VS_CODE_EXTENSION_PORT", + ]: + monkeypatch.delenv(key, raising=False) + + with pytest.raises(ModuleNotFoundError, match="opentelemetry-sdk"): + observability.configure_otel_providers() + + +def test_agent_framework_mcp_exports_remain_importable_without_mcp(monkeypatch: pytest.MonkeyPatch) -> None: + import builtins + + import agent_framework._mcp as mcp_module + + real_import = builtins.__import__ + + def _import_without_mcp( + name: str, + globals_: dict[str, object] | None = None, + locals_: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + if name == "mcp" or name.startswith("mcp."): + raise ModuleNotFoundError("No module named 'mcp'") + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", _import_without_mcp) + assert agent_framework.MCPStdioTool is mcp_module.MCPStdioTool + + with pytest.raises(ModuleNotFoundError, match=r"Please install `mcp`\.$"): + agent_framework.MCPStdioTool(name="test", command="python").get_mcp_client() + + +def test_mcp_streamable_http_tool_requires_mcp(monkeypatch: pytest.MonkeyPatch) -> None: + import builtins + + real_import = builtins.__import__ + + def _import_without_mcp( + name: str, + globals_: dict[str, object] | None = None, + locals_: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + if name == "mcp" or name.startswith("mcp."): + raise ModuleNotFoundError("No module named 'mcp'") + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", _import_without_mcp) + + with pytest.raises(ModuleNotFoundError, match=r"Please install `mcp`\.$"): + agent_framework.MCPStreamableHTTPTool(name="test", url="https://example.com").get_mcp_client() + + +def test_agent_as_mcp_server_requires_mcp(client, monkeypatch: pytest.MonkeyPatch) -> None: + import builtins + + real_import = builtins.__import__ + + def _import_without_mcp( + name: str, + globals_: dict[str, object] | None = None, + locals_: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + if name == "mcp" or name.startswith("mcp."): + raise ModuleNotFoundError("No module named 'mcp'") + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", _import_without_mcp) + + agent = Agent(client=client) + + with pytest.raises(ModuleNotFoundError, match=r"Please install `mcp`\.$"): + agent.as_mcp_server() + + +def test_mcp_websocket_tool_requires_ws_support(monkeypatch: pytest.MonkeyPatch) -> None: + import builtins + + real_import = builtins.__import__ + + sys.modules.pop("mcp.client.websocket", None) + + def _import_without_websocket_support( + name: str, + globals_: dict[str, object] | None = None, + locals_: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + if name == "mcp.client.websocket": + raise ModuleNotFoundError("No module named 'websockets'", name="websockets") + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", _import_without_websocket_support) + + with pytest.raises(ModuleNotFoundError, match=r"mcp\[ws\]"): + agent_framework.MCPWebsocketTool(name="test", url="wss://example.com").get_mcp_client() + + +def test_mcp_websocket_tool_requires_mcp(monkeypatch: pytest.MonkeyPatch) -> None: + import builtins + + real_import = builtins.__import__ + sys.modules.pop("mcp.client.websocket", None) + + def _import_without_mcp( + name: str, + globals_: dict[str, object] | None = None, + locals_: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + if name == "mcp.client.websocket": + raise ModuleNotFoundError("No module named 'mcp.client.websocket'", name="mcp.client.websocket") + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", _import_without_mcp) + + with pytest.raises(ModuleNotFoundError, match=r"agent-framework-core\[mcp\]|mcp\[ws\]"): + agent_framework.MCPWebsocketTool(name="test", url="wss://example.com").get_mcp_client() diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index 5dfb96d111..f8441af3b0 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -22,12 +22,13 @@ classifiers = [ "Programming Language :: Python :: 3.14", "Typing :: Typed", ] -dependencies = [ - "agent-framework-core>=1.0.0rc5", - "openai>=1.99.0,<3", - "fastapi>=0.115.0,<0.133.1", - "uvicorn[standard]>=0.30.0,<0.42.0" -] + dependencies = [ + "agent-framework-core>=1.0.0rc5", + "openai>=1.99.0,<3", + "opentelemetry-sdk>=1.39.0,<2", + "fastapi>=0.115.0,<0.133.1", + "uvicorn[standard]>=0.30.0,<0.42.0" + ] [project.optional-dependencies] dev = [ diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_agent.py b/python/packages/foundry/agent_framework_foundry/_foundry_agent.py index 9ec2d6bd13..19c298eaf8 100644 --- a/python/packages/foundry/agent_framework_foundry/_foundry_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_foundry_agent.py @@ -195,8 +195,8 @@ class RawFoundryAgent( # type: ignore[misc] class FoundryAgent( # type: ignore[misc] - AgentTelemetryLayer, AgentMiddlewareLayer, + AgentTelemetryLayer, RawFoundryAgent[FoundryAgentOptionsT], ): """Microsoft Foundry Agent with full middleware and telemetry support. diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index a9dd359e7c..45a4ae374f 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -27,12 +27,13 @@ dependencies = [ [project.optional-dependencies] # GAIA benchmark module dependencies -gaia = [ - "pydantic>=2.0.0", - "opentelemetry-api>=1.39.0", - "tqdm>=4.60.0", - "huggingface-hub>=0.20.0", - "orjson>=3.10.7,<4", + gaia = [ + "pydantic>=2.0.0", + "opentelemetry-api>=1.39.0", + "opentelemetry-sdk>=1.39.0,<2", + "tqdm>=4.60.0", + "huggingface-hub>=0.20.0", + "orjson>=3.10.7,<4", "pyarrow>=18.0.0", # For reading parquet files ] diff --git a/python/pyproject.toml b/python/pyproject.toml index 3c35b55ff1..c19a6024d0 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -39,6 +39,8 @@ dev = [ "pytest-retry==1.7.0", "mypy==1.19.1", "pyright==1.1.408", + "mcp[ws]>=1.24.0,<2", + "opentelemetry-sdk>=1.39.0,<2", #tasks "poethepoet==0.42.1", "rich==13.7.1", diff --git a/python/uv.lock b/python/uv.lock index c6bf122c99..317113161f 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -103,7 +103,9 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "flit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "poethepoet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "prek", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pyright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -125,7 +127,9 @@ requires-dist = [{ name = "agent-framework-core", extras = ["all"], editable = " [package.metadata.requires-dev] dev = [ { name = "flit", specifier = "==3.12.0" }, + { name = "mcp", extras = ["ws"], specifier = ">=1.24.0,<2" }, { name = "mypy", specifier = "==1.19.1" }, + { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2" }, { name = "poethepoet", specifier = "==0.42.1" }, { name = "prek", specifier = "==0.3.4" }, { name = "pyright", specifier = "==1.1.408" }, @@ -209,6 +213,8 @@ dependencies = [ { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "azure-ai-agents", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "azure-ai-inference", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-projects", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -218,6 +224,8 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.7.0,<4" }, { name = "azure-ai-agents", specifier = ">=1.2.0b5,<1.2.0b6" }, { name = "azure-ai-inference", specifier = ">=1.0.0b9,<1.0.0b10" }, + { name = "azure-ai-projects", specifier = ">=2.0.0,<3.0" }, + { name = "azure-identity", specifier = ">=1,<2" }, ] [[package]] @@ -339,14 +347,7 @@ name = "agent-framework-core" version = "1.0.0rc5" source = { editable = "packages/core" } dependencies = [ - { name = "azure-ai-projects", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions-ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -373,9 +374,11 @@ all = [ { name = "agent-framework-lab", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-mem0", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-orchestrations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-purview", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mcp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -399,17 +402,12 @@ requires-dist = [ { name = "agent-framework-lab", marker = "extra == 'all'", editable = "packages/lab" }, { name = "agent-framework-mem0", marker = "extra == 'all'", editable = "packages/mem0" }, { name = "agent-framework-ollama", marker = "extra == 'all'", editable = "packages/ollama" }, + { name = "agent-framework-openai", marker = "extra == 'all'", editable = "packages/openai" }, { name = "agent-framework-orchestrations", marker = "extra == 'all'", editable = "packages/orchestrations" }, { name = "agent-framework-purview", marker = "extra == 'all'", editable = "packages/purview" }, { name = "agent-framework-redis", marker = "extra == 'all'", editable = "packages/redis" }, - { name = "azure-ai-projects", specifier = ">=2.0.0,<3.0" }, - { name = "azure-identity", specifier = ">=1,<2" }, - { name = "mcp", extras = ["ws"], specifier = ">=1.24.0,<2" }, - { name = "openai", specifier = ">=1.99.0,<3" }, + { name = "mcp", marker = "extra == 'all'", specifier = ">=1.24.0,<2" }, { name = "opentelemetry-api", specifier = ">=1.39.0,<2" }, - { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2" }, - { name = "opentelemetry-semantic-conventions-ai", specifier = ">=0.4.13,<0.4.14" }, - { name = "packaging", specifier = ">=24.1,<25" }, { name = "pydantic", specifier = ">=2,<3" }, { name = "python-dotenv", specifier = ">=1,<2" }, { name = "typing-extensions", specifier = ">=4.15.0,<5" }, @@ -449,6 +447,7 @@ dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] @@ -469,6 +468,7 @@ requires-dist = [ { name = "agent-framework-orchestrations", marker = "extra == 'dev'", editable = "packages/orchestrations" }, { name = "fastapi", specifier = ">=0.115.0,<0.133.1" }, { name = "openai", specifier = ">=1.99.0,<3" }, + { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2" }, { name = "pytest", marker = "extra == 'all'", specifier = "==9.0.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<0.42.0" }, @@ -565,6 +565,7 @@ dependencies = [ gaia = [ { name = "huggingface-hub", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "orjson", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pyarrow", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -607,6 +608,7 @@ requires-dist = [ { name = "loguru", marker = "extra == 'tau2'", specifier = ">=0.7.3" }, { name = "numpy", marker = "extra == 'tau2'" }, { name = "opentelemetry-api", marker = "extra == 'gaia'", specifier = ">=1.39.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'gaia'", specifier = ">=1.39.0,<2" }, { name = "orjson", marker = "extra == 'gaia'", specifier = ">=3.10.7,<4" }, { name = "pyarrow", marker = "extra == 'gaia'", specifier = ">=18.0.0" }, { name = "pydantic", marker = "extra == 'gaia'", specifier = ">=2.0.0" }, @@ -4220,15 +4222,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, ] -[[package]] -name = "opentelemetry-semantic-conventions-ai" -version = "0.4.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/e6/40b59eda51ac47009fb47afcdf37c6938594a0bd7f3b9fadcbc6058248e3/opentelemetry_semantic_conventions_ai-0.4.13.tar.gz", hash = "sha256:94efa9fb4ffac18c45f54a3a338ffeb7eedb7e1bb4d147786e77202e159f0036", size = 5368, upload-time = "2025-08-22T10:14:17.387Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/b5/cf25da2218910f0d6cdf7f876a06bed118c4969eacaf60a887cbaef44f44/opentelemetry_semantic_conventions_ai-0.4.13-py3-none-any.whl", hash = "sha256:883a30a6bb5deaec0d646912b5f9f6dcbb9f6f72557b73d0f2560bf25d13e2d5", size = 6080, upload-time = "2025-08-22T10:14:16.477Z" }, -] - [[package]] name = "ordered-set" version = "4.1.0" From db16a9e74cdab168628d0b6eed0326ec095dbbc3 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:50:29 -0700 Subject: [PATCH 02/31] .NET: Clarify IResettableExecutor usage comment in workflow sample (#4905) * Clarify IResettableExecutor usage comment in workflow sample * Update dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Agents/WorkflowAsAnAgent/Program.cs | 15 ++++++++++-- .../WorkflowAsAnAgent/WorkflowFactory.cs | 24 ++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/Program.cs b/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/Program.cs index 07ba96989a..bc9faff3b0 100644 --- a/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/Program.cs +++ b/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/Program.cs @@ -9,7 +9,7 @@ using Microsoft.Extensions.AI; namespace WorkflowAsAnAgentSample; /// -/// This sample introduces the concepts workflows as agents, where a workflow can be +/// This sample introduces the concept of workflows as agents, where a workflow can be /// treated as an . This allows you to interact with a workflow /// as if it were a single agent. /// @@ -18,6 +18,14 @@ namespace WorkflowAsAnAgentSample; /// /// You will interact with the workflow in an interactive loop, sending messages and receiving /// streaming responses from the workflow as if it were an agent who responds in both languages. +/// +/// This sample also demonstrates , which is required +/// for stateful executors that are shared across multiple workflow runs. Each iteration +/// of the interactive loop triggers a new workflow run against the same workflow instance. +/// Between runs, the framework automatically calls +/// on shared executors so that accumulated state (e.g., collected messages) is cleared +/// before the next run begins. See WorkflowFactory.ConcurrentAggregationExecutor +/// for the implementation. /// /// /// Pre-requisites: @@ -39,7 +47,10 @@ public static class Program var agent = workflow.AsAIAgent("workflow-agent", "Workflow Agent"); var session = await agent.CreateSessionAsync(); - // Start an interactive loop to interact with the workflow as if it were an agent + // Start an interactive loop to interact with the workflow as if it were an agent. + // Each iteration runs the workflow again on the same workflow instance. Between runs, + // the framework calls IResettableExecutor.ResetAsync() on shared stateful executors + // (like ConcurrentAggregationExecutor) to clear accumulated state from the previous run. while (true) { Console.WriteLine(); diff --git a/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs b/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs index 2fdfe703bf..bcac8894ab 100644 --- a/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs +++ b/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs @@ -10,6 +10,14 @@ internal static class WorkflowFactory { /// /// Creates a workflow that uses two language agents to process input concurrently. + /// + /// In this workflow, the Start and the + /// are provided as shared instances, meaning + /// the same executor objects are reused across multiple workflow runs. The language agents + /// (French and English) are created via a factory and instantiated per workflow run. + /// Stateful shared executors must implement so the + /// framework can clear their state between runs. Framework-provided executors like + /// already implement this interface. /// /// The chat client to use for the agents /// A workflow that processes input using two language agents @@ -40,6 +48,16 @@ internal static class WorkflowFactory /// /// Executor that aggregates the results from the concurrent agents. + /// + /// This executor is stateful — it accumulates messages in + /// as they arrive from each agent. Because it is provided as a shared instance + /// (not via a factory), the same object is reused across workflow runs. Implementing + /// allows the framework to call + /// between runs, clearing accumulated state so each run starts fresh. + /// + /// Without , attempting to reuse a workflow containing + /// shared executor instances that do not implement this interface would throw an + /// . /// [YieldsOutput(typeof(string))] private sealed class ConcurrentAggregationExecutor() : @@ -65,7 +83,11 @@ internal static class WorkflowFactory } } - /// + /// + /// Resets the executor state between workflow runs by clearing accumulated messages. + /// The framework calls this automatically when a workflow run completes, before the + /// workflow can be used for another run. + /// public ValueTask ResetAsync() { this._messages.Clear(); From 23921c0f6ecfbc72d474346ddae7c517b8e3e81d Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:23:44 -0700 Subject: [PATCH 03/31] .NET: Pass through external input request and handle response conversion for workflow as agent scenario (#4361) * Handle external input request and response conversion for workflow as agent scenario * Remove unnecessary test comment * Fix PR comments * Updated to fix edge cases, and add more tests. * Update pending requests to use typed properties instead of relying on StateBag. replying to PR feedback. * Fixed external response de-dup and updated possible brittle test. * Address PR comments on sending turn token for normal messages and handle contentId collision by source agent * Remove unnecessary serialization element and address pr comment on intercepted outgoing requests * Updated MEAI changes for UserInput request and response abstractions. --- .../Execution/AsyncRunHandle.cs | 3 + .../Execution/EdgeMap.cs | 13 + .../Execution/ISuperStepRunner.cs | 1 + .../InProc/InProcessRunner.cs | 2 + .../InProc/InProcessRunnerContext.cs | 3 + .../Specialized/AIAgentHostExecutor.cs | 28 +- .../Specialized/AIContentExternalHandler.cs | 10 +- .../StreamingRun.cs | 3 + .../WorkflowSession.cs | 299 ++++++++- .../JsonSerializationTests.cs | 49 ++ .../WorkflowHostSmokeTests.cs | 619 ++++++++++++++++++ 11 files changed, 1011 insertions(+), 19 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs index f5d1d40370..bda7e61a38 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs @@ -53,6 +53,9 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable public ValueTask GetStatusAsync(CancellationToken cancellationToken = default) => this._eventStream.GetStatusAsync(cancellationToken); + internal bool TryGetResponsePortExecutorId(string portId, out string? executorId) + => this._stepRunner.TryGetResponsePortExecutorId(portId, out executorId); + public async IAsyncEnumerable TakeEventStreamAsync(bool blockOnPendingRequest, [EnumeratorCancellation] CancellationToken cancellationToken = default) { //Debug.Assert(breakOnHalt); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeMap.cs index 8c2162508d..6e3f2af5e6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeMap.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeMap.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -95,6 +96,18 @@ internal sealed class EdgeMap return portRunner.ChaseEdgeAsync(new MessageEnvelope(response, ExecutorIdentity.None), this._stepTracer, cancellationToken); } + internal bool TryGetResponsePortExecutorId(string portId, [NotNullWhen(true)] out string? executorId) + { + if (this._portEdgeRunners.TryGetValue(portId, out ResponseEdgeRunner? portRunner)) + { + executorId = portRunner.ExecutorId; + return true; + } + + executorId = null; + return false; + } + internal async ValueTask> ExportStateAsync() { Dictionary exportedStates = []; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs index 9b8c3c460c..8de0dbd5e2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs @@ -19,6 +19,7 @@ internal interface ISuperStepRunner bool HasUnprocessedMessages { get; } ValueTask EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellationToken = default); + bool TryGetResponsePortExecutorId(string portId, out string? executorId); ValueTask IsValidInputTypeAsync(CancellationToken cancellationToken = default); ValueTask EnqueueMessageAsync(T message, CancellationToken cancellationToken = default); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index 2a61f80ced..f93b09ddf3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -160,6 +160,8 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle bool ISuperStepRunner.HasUnservicedRequests => this.RunContext.HasUnservicedRequests; bool ISuperStepRunner.HasUnprocessedMessages => this.RunContext.NextStepHasActions; + bool ISuperStepRunner.TryGetResponsePortExecutorId(string portId, out string? executorId) + => this.RunContext.TryGetResponsePortExecutorId(portId, out executorId); public bool IsCheckpointingEnabled => this.RunContext.IsCheckpointingEnabled; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index eda7b90a80..f0bb8cac26 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -296,6 +296,9 @@ internal sealed class InProcessRunnerContext : IRunnerContext return this._externalRequests.TryRemove(requestId, out _); } + internal bool TryGetResponsePortExecutorId(string portId, [NotNullWhen(true)] out string? executorId) + => this._edgeMap.TryGetResponsePortExecutorId(portId, out executorId); + private IEventSink OutgoingEvents { get; } internal StateManager StateManager { get; } = new(); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs index 9cc72d7310..cf9ddbe3a3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs @@ -68,10 +68,17 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor throw new InvalidOperationException($"No pending ToolApprovalRequest found with id '{response.RequestId}'."); } - List implicitTurnMessages = [new ChatMessage(ChatRole.User, [response])]; + // Merge the external response with any already-buffered regular messages so mixed-content + // resumes can be processed in one invocation. + return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) => + { + pendingMessages.Add(new ChatMessage(ChatRole.User, [response])); - // ContinueTurnAsync owns failing to emit a TurnToken if this response does not clear up all remaining outstanding requests. - return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken); + await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false); + + // Clear the buffered turn messages because they were consumed by ContinueTurnAsync. + return null; + }, context, cancellationToken); } private ValueTask HandleFunctionResultAsync( @@ -84,8 +91,17 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor throw new InvalidOperationException($"No pending FunctionCall found with id '{result.CallId}'."); } - List implicitTurnMessages = [new ChatMessage(ChatRole.Tool, [result])]; - return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken); + // Merge the external response with any already-buffered regular messages so mixed-content + // resumes can be processed in one invocation. + return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) => + { + pendingMessages.Add(new ChatMessage(ChatRole.Tool, [result])); + + await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false); + + // Clear the buffered turn messages because they were consumed by ContinueTurnAsync. + return null; + }, context, cancellationToken); } public bool ShouldEmitStreamingEvents(bool? emitEvents) @@ -198,7 +214,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor ExtractUnservicedRequests(response.Messages.SelectMany(message => message.Contents)); } - if (this._options.EmitAgentResponseEvents == true) + if (this._options.EmitAgentResponseEvents) { await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIContentExternalHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIContentExternalHandler.cs index 9173100b3e..15203fd5dc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIContentExternalHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIContentExternalHandler.cs @@ -16,10 +16,12 @@ internal sealed class AIContentExternalHandler _pendingRequests = new(); public AIContentExternalHandler(ref ProtocolBuilder protocolBuilder, string portId, bool intercepted, Func handler) { + this._portId = portId; PortBinding? portBinding = null; protocolBuilder = protocolBuilder.ConfigureRoutes(routeBuilder => ConfigureRoutes(routeBuilder, out portBinding)); this._portBinding = portBinding; @@ -58,12 +60,14 @@ internal sealed class AIContentExternalHandler this._portBinding == null; + private string CreateExternalRequestId(string requestId) => $"{this._portId.Length}:{this._portId}:{requestId}"; + private static string MakeKey(string id) => $"{id}_PendingRequests"; public async ValueTask OnCheckpointingAsync(string id, IWorkflowContext context, CancellationToken cancellationToken = default) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs index b479cae75e..c7a19380b2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs @@ -60,6 +60,9 @@ public sealed class StreamingRun : CheckpointableRunBase, IAsyncDisposable internal ValueTask TrySendMessageUntypedAsync(object message, Type? declaredType = null) => this._runHandle.EnqueueMessageUntypedAsync(message, declaredType); + internal bool TryGetResponsePortExecutorId(string portId, out string? executorId) + => this._runHandle.TryGetResponsePortExecutorId(portId, out executorId); + /// /// Asynchronously streams workflow events as they occur during workflow execution. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index 40a18dbadb..db3d299ee9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -25,6 +25,25 @@ internal sealed class WorkflowSession : AgentSession private InMemoryCheckpointManager? _inMemoryCheckpointManager; + /// + /// Tracks pending external requests by their workflow-facing request ID. + /// This mapping enables converting incoming response content back to + /// when resuming a workflow from a checkpoint. + /// + /// + /// + /// Entries are added when a is received during workflow execution, + /// and removed when a matching response is delivered via . + /// + /// + /// The number of entries is bounded by the number of outstanding external requests in a single workflow run. + /// When a session is abandoned, all pending requests are released with the session object. + /// Request-level timeouts, if needed, should be implemented in the workflow definition itself + /// (e.g., using a timer racing against an external event). + /// + /// + private readonly Dictionary _pendingRequests = []; + internal static bool VerifyCheckpointingConfiguration(IWorkflowExecutionEnvironment executionEnvironment, [NotNullWhen(true)] out InProcessExecutionEnvironment? inProcEnv) { inProcEnv = null; @@ -90,6 +109,7 @@ internal sealed class WorkflowSession : AgentSession this.LastCheckpoint = sessionState.LastCheckpoint; this.StateBag = sessionState.StateBag; + this._pendingRequests = sessionState.PendingRequests ?? []; } public CheckpointInfo? LastCheckpoint { get; set; } @@ -101,7 +121,8 @@ internal sealed class WorkflowSession : AgentSession this.SessionId, this.LastCheckpoint, this._inMemoryCheckpointManager, - this.StateBag); + this.StateBag, + this._pendingRequests); return marshaller.Marshal(info); } @@ -141,7 +162,7 @@ internal sealed class WorkflowSession : AgentSession return update; } - private async ValueTask CreateOrResumeRunAsync(List messages, CancellationToken cancellationToken = default) + private async ValueTask CreateOrResumeRunAsync(List messages, CancellationToken cancellationToken = default) { // The workflow is validated to be a ChatProtocol workflow by the WorkflowHostAgent before creating the session, // and does not need to be checked again here. @@ -154,18 +175,155 @@ internal sealed class WorkflowSession : AgentSession cancellationToken) .ConfigureAwait(false); - await run.TrySendMessageAsync(messages).ConfigureAwait(false); - return run; + // Process messages: convert response content to ExternalResponse, send regular messages as-is + ResumeDispatchInfo dispatchInfo = await this.SendMessagesWithResponseConversionAsync(run, messages).ConfigureAwait(false); + return new ResumeRunResult(run, dispatchInfo); } - return await this._executionEnvironment + StreamingRun newRun = await this._executionEnvironment .RunStreamingAsync(this._workflow, messages, this.SessionId, cancellationToken) .ConfigureAwait(false); + return new ResumeRunResult(newRun); } + /// + /// Sends messages to the run, converting FunctionResultContent and UserInputResponseContent + /// to ExternalResponse when there's a matching pending request. + /// + /// + /// Structured information about how resume content was dispatched. + /// + private async ValueTask SendMessagesWithResponseConversionAsync(StreamingRun run, List messages) + { + List regularMessages = []; + // Responses are deferred until after regular messages are queued so response handlers + // can merge buffered regular content in the same continuation turn. + List<(ExternalResponse Response, string RequestId)> externalResponses = []; + bool hasMatchedResponseForStartExecutor = false; + + // Tracks content IDs already matched to pending requests within this invocation, + // preventing duplicate responses for the same ID from being sent to the workflow engine. + HashSet? matchedContentIds = null; + + foreach (ChatMessage message in messages) + { + List regularContents = []; + + foreach (AIContent content in message.Contents) + { + string? contentId = GetResponseContentId(content); + + // Skip duplicate response content for an already-matched content ID + if (contentId != null && matchedContentIds?.Contains(contentId) == true) + { + continue; + } + + if (contentId != null + && this.TryGetPendingRequest(contentId) is ExternalRequest pendingRequest) + { + // For intercepted/complex topologies the port may not be registered in the EdgeMap. + // Treat unknown port as non-start-executor (conservative): TurnToken will still be sent. + if (run.TryGetResponsePortExecutorId(pendingRequest.PortInfo.PortId, out string? responseExecutorId)) + { + hasMatchedResponseForStartExecutor |= string.Equals(responseExecutorId, this._workflow.StartExecutorId, StringComparison.Ordinal); + } + + AIContent normalizedResponseContent = NormalizeResponseContentForDelivery(content, pendingRequest); + externalResponses.Add((pendingRequest.CreateResponse(normalizedResponseContent), pendingRequest.RequestId)); + (matchedContentIds ??= new(StringComparer.Ordinal)).Add(contentId); + } + else + { + regularContents.Add(content); + } + } + + if (regularContents.Count > 0) + { + ChatMessage cloned = message.Clone(); + cloned.Contents = regularContents; + regularMessages.Add(cloned); + } + } + + // Send regular messages first so response handlers can merge them with responses. + bool hasRegularMessages = regularMessages.Count > 0; + if (hasRegularMessages) + { + await run.TrySendMessageAsync(regularMessages).ConfigureAwait(false); + } + + // Send external responses after regular messages. + bool hasMatchedExternalResponses = false; + foreach ((ExternalResponse response, string requestId) in externalResponses) + { + await run.SendResponseAsync(response).ConfigureAwait(false); + hasMatchedExternalResponses = true; + this.RemovePendingRequest(requestId); + } + + return new ResumeDispatchInfo( + hasRegularMessages, + hasMatchedExternalResponses, + hasMatchedResponseForStartExecutor); + } + + /// + /// Creates the workflow-facing request content surfaced in response updates. + /// + private static AIContent CreateRequestContentForDelivery(ExternalRequest request) => request switch + { + ExternalRequest externalRequest when externalRequest.TryGetDataAs(out FunctionCallContent? functionCallContent) + => CloneFunctionCallContent(functionCallContent, externalRequest.RequestId), + ExternalRequest externalRequest when externalRequest.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent) + => CloneToolApprovalRequestContent(toolApprovalRequestContent, externalRequest.RequestId), + ExternalRequest externalRequest + => externalRequest.ToFunctionCall(), + }; + + /// + /// Rewrites workflow-facing response content back to the original agent-owned content ID. + /// + private static AIContent NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request) => content switch + { + FunctionResultContent functionResultContent when request.TryGetDataAs(out FunctionCallContent? functionCallContent) + => CloneFunctionResultContent(functionResultContent, functionCallContent.CallId), + ToolApprovalResponseContent toolApprovalResponseContent when request.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent) + => CloneToolApprovalResponseContent(toolApprovalResponseContent, toolApprovalRequestContent.RequestId), + _ => content, + }; + + /// + /// Gets the workflow-facing request ID from response content types. + /// + private static string? GetResponseContentId(AIContent content) => content switch + { + FunctionResultContent functionResultContent => functionResultContent.CallId, + ToolApprovalResponseContent toolApprovalResponseContent => toolApprovalResponseContent.RequestId, + _ => null + }; + + /// + /// Tries to get a pending request by workflow-facing request ID. + /// + private ExternalRequest? TryGetPendingRequest(string requestId) => + this._pendingRequests.TryGetValue(requestId, out ExternalRequest? request) ? request : null; + + /// + /// Adds a pending request indexed by workflow-facing request ID. + /// + private void AddPendingRequest(string requestId, ExternalRequest request) => this._pendingRequests[requestId] = request; + + /// + /// Removes a pending request by workflow-facing request ID. + /// + private void RemovePendingRequest(string requestId) => + this._pendingRequests.Remove(requestId); + internal async IAsyncEnumerable InvokeStageAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default) @@ -175,12 +333,25 @@ internal sealed class WorkflowSession : AgentSession this.LastResponseId = Guid.NewGuid().ToString("N"); List messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList(); -#pragma warning disable CA2007 // Analyzer misfiring and not seeing .ConfigureAwait(false) below. - await using StreamingRun run = + ResumeRunResult resumeResult = await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false); +#pragma warning disable CA2007 // Analyzer misfiring. + await using StreamingRun run = resumeResult.Run; #pragma warning restore CA2007 - await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); + ResumeDispatchInfo dispatchInfo = resumeResult.DispatchInfo; + + // Send a TurnToken to the start executor unless the only activity is an external + // response directed at the start executor itself (which self-emits a TurnToken via + // ContinueTurnAsync). Non-start executors (e.g., RequestInfoExecutor) do not emit + // TurnTokens after processing responses, so the session must always provide one. + bool shouldSendTurnToken = + !dispatchInfo.HasMatchedExternalResponses + || !dispatchInfo.HasMatchedResponseForStartExecutor; + if (shouldSendTurnToken) + { + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); + } await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken) .ConfigureAwait(false) .WithCancellation(cancellationToken)) @@ -192,8 +363,13 @@ internal sealed class WorkflowSession : AgentSession break; case RequestInfoEvent requestInfo: - FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall(); - AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, fcContent); + AIContent requestContent = CreateRequestContentForDelivery(requestInfo.Request); + + // Track the pending request so we can convert incoming responses back to ExternalResponse. + // External callers respond using the workflow-facing request ID, which is always RequestId. + this.AddPendingRequest(requestInfo.Request.RequestId, requestInfo.Request); + + AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, requestContent); yield return update; break; @@ -267,15 +443,116 @@ internal sealed class WorkflowSession : AgentSession /// public WorkflowChatHistoryProvider ChatHistoryProvider { get; } + /// + /// Captures the outcome of creating or resuming a workflow run, + /// indicating what types of messages were sent during resume. + /// + private readonly struct ResumeRunResult + { + /// The streaming run that was created or resumed. + public StreamingRun Run { get; } + + /// How resume-time content was dispatched into the workflow runtime. + public ResumeDispatchInfo DispatchInfo { get; } + + public ResumeRunResult(StreamingRun run, ResumeDispatchInfo dispatchInfo = default) + { + this.Run = Throw.IfNull(run); + this.DispatchInfo = dispatchInfo; + } + } + + /// + /// Captures how resumed input was split across regular-message and external-response delivery paths. + /// + private readonly struct ResumeDispatchInfo + { + public ResumeDispatchInfo(bool hasRegularMessages, bool hasMatchedExternalResponses, bool hasMatchedResponseForStartExecutor) + { + this.HasRegularMessages = hasRegularMessages; + this.HasMatchedExternalResponses = hasMatchedExternalResponses; + this.HasMatchedResponseForStartExecutor = hasMatchedResponseForStartExecutor; + } + + public bool HasRegularMessages { get; } + + public bool HasMatchedExternalResponses { get; } + + public bool HasMatchedResponseForStartExecutor { get; } + } + + /// + /// Clones a with a workflow-facing call ID. + /// + private static FunctionCallContent CloneFunctionCallContent(FunctionCallContent content, string callId) + { + FunctionCallContent clone = new(callId, content.Name, content.Arguments) + { + Exception = content.Exception, + InformationalOnly = content.InformationalOnly, + }; + + return CopyContentMetadata(content, clone); + } + + /// + /// Clones a with an agent-owned call ID. + /// + private static FunctionResultContent CloneFunctionResultContent(FunctionResultContent content, string callId) + { + FunctionResultContent clone = new(callId, content.Result) + { + Exception = content.Exception, + }; + + return CopyContentMetadata(content, clone); + } + + /// + /// Clones a with a workflow-facing request ID. + /// + private static ToolApprovalRequestContent CloneToolApprovalRequestContent(ToolApprovalRequestContent content, string id) + { + ToolApprovalRequestContent clone = new(id, content.ToolCall); + return CopyContentMetadata(content, clone); + } + + /// + /// Clones a with an agent-owned request ID. + /// + private static ToolApprovalResponseContent CloneToolApprovalResponseContent(ToolApprovalResponseContent content, string id) + { + ToolApprovalResponseContent clone = new(id, content.Approved, content.ToolCall) + { + Reason = content.Reason, + }; + + return CopyContentMetadata(content, clone); + } + + /// + /// Copies shared metadata to a cloned content instance. + /// + private static TContent CopyContentMetadata(AIContent source, TContent target) + where TContent : AIContent + { + target.AdditionalProperties = source.AdditionalProperties; + target.Annotations = source.Annotations; + target.RawRepresentation = source.RawRepresentation; + return target; + } + internal sealed class SessionState( string sessionId, CheckpointInfo? lastCheckpoint, InMemoryCheckpointManager? checkpointManager = null, - AgentSessionStateBag? stateBag = null) + AgentSessionStateBag? stateBag = null, + Dictionary? pendingRequests = null) { public string SessionId { get; } = sessionId; public CheckpointInfo? LastCheckpoint { get; } = lastCheckpoint; public InMemoryCheckpointManager? CheckpointManager { get; } = checkpointManager; public AgentSessionStateBag StateBag { get; } = stateBag ?? new(); + public Dictionary? PendingRequests { get; } = pendingRequests; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs index c2a538b302..572f858f13 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs @@ -673,6 +673,55 @@ public class JsonSerializationTests ValidateCheckpoint(retrievedCheckpoint, prototype); } + [Fact] + public void Test_SessionState_JsonRoundtrip_WithPendingRequests() + { + // Arrange + Dictionary pendingRequests = new() + { + ["call-1"] = TestExternalRequest, + ["call-2"] = ExternalRequest.Create(TestPort, "Request2", "OtherData"), + }; + + WorkflowSession.SessionState prototype = new( + sessionId: "test-session-123", + lastCheckpoint: TestParentCheckpointInfo, + pendingRequests: pendingRequests); + + // Act + WorkflowSession.SessionState result = RunJsonRoundtrip(prototype); + + // Assert + result.SessionId.Should().Be(prototype.SessionId); + result.LastCheckpoint.Should().Be(prototype.LastCheckpoint); + result.StateBag.Should().NotBeNull(); + result.PendingRequests.Should().NotBeNull() + .And.HaveCount(pendingRequests.Count); + + foreach (string key in pendingRequests.Keys) + { + result.PendingRequests.Should().ContainKey(key); + ValidateExternalRequest(result.PendingRequests![key], pendingRequests[key]); + } + } + + [Fact] + public void Test_SessionState_JsonRoundtrip_WithoutPendingRequests() + { + // Arrange + WorkflowSession.SessionState prototype = new( + sessionId: "test-session-456", + lastCheckpoint: null); + + // Act + WorkflowSession.SessionState result = RunJsonRoundtrip(prototype); + + // Assert + result.SessionId.Should().Be(prototype.SessionId); + result.LastCheckpoint.Should().BeNull(); + result.PendingRequests.Should().BeNull(); + } + /// /// Verifies that the default behavior (without AllowOutOfOrderMetadataProperties) fails /// when $type metadata is not the first property, demonstrating the PostgreSQL jsonb issue. diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index 40e4f2098f..1920a57cb1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -28,6 +28,184 @@ public sealed class ExpectedException : Exception } } +/// +/// A simple agent that emits a FunctionCallContent or ToolApprovalRequestContent request. +/// Used to test that RequestInfoEvent handling preserves the original content type. +/// +internal sealed class RequestEmittingAgent : AIAgent +{ + private readonly AIContent _requestContent; + private readonly bool _completeOnResponse; + + /// + /// Creates a new that emits the given request content. + /// + /// The content to emit on each turn. + /// + /// When , the agent emits a text completion instead of re-emitting + /// the request when the incoming messages contain a + /// or . This models realistic agent behaviour + /// where the agent processes the tool result and produces a final answer. + /// + public RequestEmittingAgent(AIContent requestContent, bool completeOnResponse = false) + { + this._requestContent = requestContent; + this._completeOnResponse = completeOnResponse; + } + + private sealed class Session : AgentSession + { + public Session() { } + } + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => new(new Session()); + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new Session()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => default; + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + => this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken); + + protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (this._completeOnResponse && messages.Any(m => m.Contents.Any(c => + c is FunctionResultContent || c is ToolApprovalResponseContent))) + { + yield return new AgentResponseUpdate(ChatRole.Assistant, [new TextContent("Request processed")]); + } + else + { + // Emit the request content + yield return new AgentResponseUpdate(ChatRole.Assistant, [this._requestContent]); + } + } +} + +internal sealed class KickoffOnStartExecutor : ChatProtocolExecutor +{ + private static readonly ChatProtocolExecutorOptions s_options = new() + { + AutoSendTurnToken = false, + }; + + private readonly string _downstreamExecutorId; + private readonly string _kickoffInputText; + private readonly string _kickoffMessageText; + private readonly string _regularResumeText; + private readonly string _regularProcessedText; + + public KickoffOnStartExecutor( + string id, + string downstreamExecutorId, + string kickoffInputText, + string kickoffMessageText, + string regularResumeText, + string regularProcessedText) + : base(id, s_options) + { + this._downstreamExecutorId = downstreamExecutorId; + this._kickoffInputText = kickoffInputText; + this._kickoffMessageText = kickoffMessageText; + this._regularResumeText = regularResumeText; + this._regularProcessedText = regularProcessedText; + } + + protected override async ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) + { + List textContents = + [ + .. messages + .SelectMany(message => message.Contents.OfType()) + .Select(content => content.Text) + ]; + + if (textContents.Contains(this._kickoffInputText, StringComparer.Ordinal)) + { + await context.SendMessageAsync( + new List { new(ChatRole.User, this._kickoffMessageText) }, + this._downstreamExecutorId, + cancellationToken).ConfigureAwait(false); + await context.SendMessageAsync( + new TurnToken(emitEvents), + this._downstreamExecutorId, + cancellationToken).ConfigureAwait(false); + } + + if (textContents.Contains(this._regularResumeText, StringComparer.Ordinal)) + { + AgentResponseUpdate update = new(ChatRole.Assistant, [new TextContent(this._regularProcessedText)]) + { + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + ResponseId = Guid.NewGuid().ToString("N"), + Role = ChatRole.Assistant, + }; + + await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false); + } + } +} + +/// +/// A start executor that always emits a response update on every turn, +/// useful for verifying that a TurnToken was delivered by the session. +/// On the first turn (user messages present), it kicks off a downstream executor. +/// +internal sealed class TurnTrackingStartExecutor : ChatProtocolExecutor +{ + private static readonly ChatProtocolExecutorOptions s_options = new() + { + AutoSendTurnToken = false, + }; + + private readonly string _downstreamExecutorId; + private readonly string _activatedMarker; + private int _activationCount; + + /// Gets the number of times this executor has been activated (i.e., called). + public int ActivationCount => this._activationCount; + + public TurnTrackingStartExecutor(string id, string downstreamExecutorId, string activatedMarker) + : base(id, s_options) + { + this._downstreamExecutorId = downstreamExecutorId; + this._activatedMarker = activatedMarker; + } + + protected override async ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._activationCount); + + // On the first turn, forward user messages and a TurnToken to the downstream executor. + if (messages.Any(m => m.Role == ChatRole.User)) + { + await context.SendMessageAsync( + messages, + this._downstreamExecutorId, + cancellationToken).ConfigureAwait(false); + await context.SendMessageAsync( + new TurnToken(emitEvents), + this._downstreamExecutorId, + cancellationToken).ConfigureAwait(false); + } + + // Always emit a marker to prove this executor was activated. + AgentResponseUpdate update = new(ChatRole.Assistant, [new TextContent(this._activatedMarker)]) + { + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + ResponseId = Guid.NewGuid().ToString("N"), + Role = ChatRole.Assistant, + }; + + await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false); + } +} + public class WorkflowHostSmokeTests { private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent @@ -112,4 +290,445 @@ public class WorkflowHostSmokeTests hadErrorContent.Should().BeTrue(); } + + /// + /// Tests that when a workflow emits a RequestInfoEvent with FunctionCallContent data, + /// the AgentResponseUpdate preserves the original FunctionCallContent type. + /// + [Fact] + public async Task Test_AsAgent_FunctionCallContentPreservedInRequestInfoAsync() + { + // Arrange + const string CallId = "test-call-id"; + const string FunctionName = "testFunction"; + FunctionCallContent originalContent = new(CallId, FunctionName); + RequestEmittingAgent requestAgent = new(originalContent); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + + // Act + List updates = await workflow.AsAIAgent("WorkflowAgent") + .RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello")) + .ToListAsync(); + + // Assert + AgentResponseUpdate? updateWithFunctionCall = updates.FirstOrDefault(u => + u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is FunctionCallContent)); + + updateWithFunctionCall.Should().NotBeNull("a FunctionCallContent should be present in the response updates"); + FunctionCallContent retrievedContent = updateWithFunctionCall!.Contents + .OfType() + .Should().ContainSingle() + .Which; + + retrievedContent.CallId.Should().NotBe(CallId); + retrievedContent.CallId.Should().EndWith($":{CallId}"); + retrievedContent.Name.Should().Be(FunctionName); + } + + /// + /// Tests that when a workflow emits a RequestInfoEvent with ToolApprovalRequestContent data, + /// the AgentResponseUpdate preserves the original ToolApprovalRequestContent type. + /// + [Fact] + public async Task Test_AsAgent_ToolApprovalRequestContentPreservedInRequestInfoAsync() + { + // Arrange + const string RequestId = "test-request-id"; + McpServerToolCallContent mcpCall = new("call-id", "testToolName", "http://localhost"); + ToolApprovalRequestContent originalContent = new(RequestId, mcpCall); + RequestEmittingAgent requestAgent = new(originalContent); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUserInputRequests = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + + // Act + List updates = await workflow.AsAIAgent("WorkflowAgent") + .RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello")) + .ToListAsync(); + + // Assert + AgentResponseUpdate? updateWithUserInput = updates.FirstOrDefault(u => + u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is ToolApprovalRequestContent)); + + updateWithUserInput.Should().NotBeNull("a ToolApprovalRequestContent should be present in the response updates"); + ToolApprovalRequestContent retrievedContent = updateWithUserInput!.Contents + .OfType() + .Should().ContainSingle() + .Which; + + retrievedContent.Should().NotBeNull(); + retrievedContent.RequestId.Should().NotBe(RequestId); + retrievedContent.RequestId.Should().EndWith($":{RequestId}"); + } + + /// + /// Tests the full roundtrip: workflow emits a request, external caller responds, workflow processes response. + /// + [Fact] + public async Task Test_AsAgent_FunctionCallRoundtrip_ResponseIsProcessedAsync() + { + // Arrange: Create an agent that emits a FunctionCallContent request + const string CallId = "roundtrip-call-id"; + const string FunctionName = "testFunction"; + FunctionCallContent requestContent = new(CallId, FunctionName); + RequestEmittingAgent requestAgent = new(requestContent, completeOnResponse: true); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + // Act 1: First call - should receive the FunctionCallContent request + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, "Start"), + session).ToListAsync(); + + // Assert 1: We should have received a FunctionCallContent + AgentResponseUpdate? updateWithRequest = firstCallUpdates.FirstOrDefault(u => + u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is FunctionCallContent)); + updateWithRequest.Should().NotBeNull("a FunctionCallContent should be present in the response updates"); + + FunctionCallContent receivedRequest = updateWithRequest!.Contents + .OfType() + .First(); + receivedRequest.CallId.Should().EndWith($":{CallId}"); + + // Act 2: Send the response back + FunctionResultContent responseContent = new(receivedRequest.CallId, "test result"); + ChatMessage responseMessage = new(ChatRole.Tool, [responseContent]); + + // Act 2: Run the workflow with the response and capture the resulting updates + List secondCallUpdates = await agent.RunStreamingAsync(responseMessage, session).ToListAsync(); + + // Assert 2: The response should be processed and the original request should no longer be pending. + // Concretely, the workflow should not re-emit a FunctionCallContent with the same CallId. + secondCallUpdates.Should().NotBeNull("processing the response should produce updates"); + secondCallUpdates.Should().NotBeEmpty("processing the response should progress the workflow"); + secondCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Should() + .NotContain(c => c.CallId == receivedRequest.CallId, "the external FunctionCallContent request should be cleared after processing the response"); + } + + /// + /// Tests the full roundtrip for ToolApprovalRequestContent: workflow emits request, external caller responds. + /// Verifying inbound ToolApprovalResponseContent conversion. + /// + [Fact] + public async Task Test_AsAgent_ToolApprovalRoundtrip_ResponseIsProcessedAsync() + { + // Arrange: Create an agent that emits a ToolApprovalRequestContent request + const string RequestId = "roundtrip-request-id"; + McpServerToolCallContent mcpCall = new("mcp-call-id", "testMcpTool", "http://localhost"); + ToolApprovalRequestContent requestContent = new(RequestId, mcpCall); + RequestEmittingAgent requestAgent = new(requestContent, completeOnResponse: true); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUserInputRequests = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + // Act 1: First call - should receive the ToolApprovalRequestContent request + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, "Start"), + session).ToListAsync(); + + // Assert 1: We should have received a ToolApprovalRequestContent + AgentResponseUpdate? updateWithRequest = firstCallUpdates.FirstOrDefault(u => + u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is ToolApprovalRequestContent)); + updateWithRequest.Should().NotBeNull("a ToolApprovalRequestContent should be present in the response updates"); + + ToolApprovalRequestContent receivedRequest = updateWithRequest!.Contents + .OfType() + .First(); + receivedRequest.RequestId.Should().EndWith($":{RequestId}"); + + // Act 2: Send the response back - use CreateResponse to get the right response type + ToolApprovalResponseContent responseContent = receivedRequest.CreateResponse(approved: true); + ChatMessage responseMessage = new(ChatRole.User, [responseContent]); + + // Act 2: Run the workflow again with the response and capture the updates + List secondCallUpdates = await agent.RunStreamingAsync(responseMessage, session).ToListAsync(); + + // Assert 2: The response should be applied so that the original request is no longer pending + secondCallUpdates.Should().NotBeEmpty("handling the user input response should produce follow-up updates"); + bool requestStillPresent = secondCallUpdates.Any(u => + u.RawRepresentation is RequestInfoEvent + && u.Contents.OfType().Any(r => r.RequestId == receivedRequest.RequestId)); + requestStillPresent.Should().BeFalse("the original ToolApprovalRequestContent should not be re-emitted after its response is processed"); + } + + /// + /// Tests the mixed-message scenario: resume contains both an external response + /// (FunctionResultContent matching a pending request) and regular non-response content + /// in the same message. + /// Verifies that regular content is still processed and that no duplicate + /// pending-request errors, redundant FunctionCallContent re-emissions, + /// or workflow errors occur. + /// + [Fact] + public async Task Test_AsAgent_MixedResponseAndRegularMessage_BothProcessedAsync() + { + // Arrange: Create an agent that emits a FunctionCallContent request + const string CallId = "mixed-call-id"; + const string FunctionName = "mixedTestFunction"; + FunctionCallContent requestContent = new(CallId, FunctionName); + RequestEmittingAgent requestAgent = new(requestContent, completeOnResponse: true); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + // Act 1: First call - should receive the FunctionCallContent request + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, "Start"), + session).ToListAsync(); + + // Assert 1: We should have received a FunctionCallContent + AgentResponseUpdate requestUpdate = firstCallUpdates.First(u => + u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is FunctionCallContent)); + FunctionCallContent emittedRequest = requestUpdate.Contents.OfType().Single(); + + firstCallUpdates.Should().Contain(u => u.Contents.Any(c => c is FunctionCallContent), + "the first call should emit a FunctionCallContent request"); + + // Act 2: Send a mixed message containing both the function result AND regular non-response content + FunctionResultContent responseContent = new(emittedRequest.CallId, "tool output"); + ChatMessage mixedMessage = new(ChatRole.Tool, [responseContent, new TextContent("additional context")]); + + List secondCallUpdates = await agent.RunStreamingAsync(mixedMessage, session).ToListAsync(); + + // Assert 2: The workflow should have processed both parts without errors + secondCallUpdates.Should().NotBeEmpty("the mixed message should produce follow-up updates"); + secondCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Should() + .NotContain(c => c.CallId == emittedRequest.CallId, "the external FunctionCallContent should be cleared after the response is processed"); + secondCallUpdates + .SelectMany(u => u.Contents.OfType()) + .Should() + .BeEmpty("no workflow errors should occur when processing a mixed response-and-regular message"); + } + + [Fact] + public async Task Test_AsAgent_ResponseThenRegularAcrossMessages_NoDuplicateFunctionCallAsync() + { + const string CallId = "mixed-separate-call-id"; + const string FunctionName = "mixedSeparateTestFunction"; + + RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: true); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "Start"), session).ToListAsync(); + FunctionCallContent emittedRequest = firstCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Single(); + + ChatMessage[] resumeMessages = + [ + new(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]), + new(ChatRole.Tool, [new TextContent("extra context in separate message")]) + ]; + + List secondCallUpdates = await agent.RunStreamingAsync(resumeMessages, session).ToListAsync(); + + secondCallUpdates.Should().NotBeEmpty(); + secondCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Should() + .NotContain(c => c.CallId == emittedRequest.CallId, "response+regular content split across messages should not re-emit the handled external request"); + secondCallUpdates + .SelectMany(u => u.Contents.OfType()) + .Should() + .BeEmpty(); + } + + [Fact] + public async Task Test_AsAgent_MatchingResponse_DoesNotCauseExtraTurnAsync() + { + const string CallId = "matching-response-call-id"; + const string FunctionName = "matchingResponseFunction"; + + RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: false); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "Start"), session).ToListAsync(); + FunctionCallContent emittedRequest = firstCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Single(); + + List secondCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]), + session).ToListAsync(); + + int functionCallCount = secondCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Count(c => c.CallId == emittedRequest.CallId); + + functionCallCount.Should().Be(1, "a matching external response should not trigger an extra TurnToken-driven turn"); + } + + [Fact] + public async Task Test_AsAgent_MixedResponseAndRegularMessage_CrossExecutorStartExecutorIsReawakenedAsync() + { + const string StartExecutorId = "start-executor"; + const string KickoffInputText = "Start"; + const string KickoffMessageText = "kickoff downstream"; + const string ResumeRegularText = "resume regular"; + const string ResumeProcessedText = "regular message processed"; + const string CallId = "cross-executor-call-id"; + const string FunctionName = "crossExecutorFunction"; + + RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: true); + ExecutorBinding requestBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + + KickoffOnStartExecutor startExecutor = new( + StartExecutorId, + requestBinding.Id, + KickoffInputText, + KickoffMessageText, + ResumeRegularText, + ResumeProcessedText); + ExecutorBinding startBinding = startExecutor.BindExecutor(); + + Workflow workflow = new WorkflowBuilder(startBinding) + .AddEdge>(startBinding, requestBinding, messages => + messages?.Any(message => message.Contents.OfType().Any(content => content.Text == KickoffMessageText)) == true) + .AddEdge(startBinding, requestBinding, _ => true) + .Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, KickoffInputText), + session).ToListAsync(); + FunctionCallContent emittedRequest = firstCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Single(); + + ChatMessage[] resumeMessages = + [ + new(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]), + new(ChatRole.User, ResumeRegularText) + ]; + + List secondCallUpdates = await agent.RunStreamingAsync(resumeMessages, session).ToListAsync(); + List textContents = [.. secondCallUpdates.SelectMany(update => update.Contents.OfType()).Select(content => content.Text)]; + + textContents.Should().Contain(ResumeProcessedText, "the start executor should receive an explicit TurnToken when the matched response wakes a different executor"); + textContents.Should().Contain("Request processed", "the matched external response should still be delivered to the downstream request owner"); + secondCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Should() + .NotContain(c => c.CallId == emittedRequest.CallId, "the handled external request should not be re-emitted while waking the start executor"); + secondCallUpdates.SelectMany(u => u.Contents.OfType()).Should().BeEmpty(); + } + + [Fact] + public async Task Test_AsAgent_UnmatchedResponse_TriggersTurnAndKeepsProgressingAsync() + { + const string CallId = "unmatched-response-call-id"; + const string FunctionName = "unmatchedResponseFunction"; + + RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: false); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "Start"), session).ToListAsync(); + firstCallUpdates.Should().Contain(u => u.Contents.Any(c => c is FunctionCallContent)); + + List secondCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("different-call-id", "tool output")]), + session).ToListAsync(); + + int functionCallCount = secondCallUpdates + .SelectMany(u => u.Contents.OfType()) + .Count(c => c.CallId == CallId); + + functionCallCount.Should().Be(1, "an unmatched response should be treated as regular input and still drive a TurnToken continuation without workflow errors"); + secondCallUpdates.SelectMany(u => u.Contents.OfType()).Should().BeEmpty(); + } + + /// + /// Tests that when a resume contains only an external response directed at a non-start executor + /// (no regular messages), the start executor still receives a TurnToken and is activated. + /// This is a regression test for the case where the TurnToken was previously skipped because + /// HasRegularMessages was , leaving the start executor dormant. + /// + [Fact] + public async Task Test_AsAgent_ResponseOnlyToNonStartExecutor_StartExecutorIsStillActivatedAsync() + { + // Arrange + const string StartExecutorId = "start-executor"; + const string ActivatedMarker = "start-executor-activated"; + const string CallId = "response-only-call-id"; + const string FunctionName = "responseOnlyFunction"; + + RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: true); + ExecutorBinding requestBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + + TurnTrackingStartExecutor startExecutor = new(StartExecutorId, requestBinding.Id, ActivatedMarker); + ExecutorBinding startBinding = startExecutor.BindExecutor(); + + Workflow workflow = new WorkflowBuilder(startBinding) + .AddEdge>(startBinding, requestBinding, messages => + messages?.Any(m => m.Contents.OfType().Any()) == true) + .AddEdge(startBinding, requestBinding, _ => true) + .Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + // Act 1: First call triggers the downstream FunctionCallContent request + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, "Start"), + session).ToListAsync(); + + FunctionCallContent emittedRequest = firstCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Single(); + + // Act 2: Resume with ONLY the external response (no regular messages) + List secondCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]), + session).ToListAsync(); + + // Assert: Both the downstream and start executor should have been activated + List textContents = [.. secondCallUpdates + .SelectMany(u => u.Contents.OfType()) + .Select(c => c.Text)]; + + textContents.Should().Contain("Request processed", + "the downstream executor should process the external response"); + textContents.Should().Contain(ActivatedMarker, + "the start executor should receive a TurnToken and be activated even when resume contains only an external response"); + secondCallUpdates + .SelectMany(u => u.Contents.OfType()) + .Should() + .BeEmpty(); + } } From 0b2ccd61263cff227af0455c3b2a39d8053c9c80 Mon Sep 17 00:00:00 2001 From: chetantoshniwal Date: Wed, 25 Mar 2026 13:02:16 -0700 Subject: [PATCH 04/31] .NET: Fix ChatOptions mutation in AIContextProviderChatClient across calls (#4891) * Fix UseAIContextProviders tool accumulation across calls (#4864) Clone ChatOptions before mutating it in InvokeProvidersAsync to prevent context provider tools from accumulating when the same ChatOptions instance is reused across multiple API calls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: chetantoshniwal * Apply suggestion from @westey-m --------- Co-authored-by: MAF Dashboard Bot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: westey <164392973+westey-m@users.noreply.github.com> --- .../AIContextProviderChatClient.cs | 2 + .../AIContextProviderChatClientTests.cs | 161 ++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs b/dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs index 305abe0465..bf93832232 100644 --- a/dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs @@ -161,6 +161,8 @@ internal sealed class AIContextProviderChatClient : DelegatingChatClient } // Materialize the accumulated context back into messages and options. + // Clone options to avoid mutating the caller's instance across calls. + options = options?.Clone(); var enrichedMessages = aiContext.Messages ?? []; var tools = aiContext.Tools as IList ?? aiContext.Tools?.ToList(); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs index 3b06bbb772..5e65c4a1a6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs @@ -250,6 +250,129 @@ public class AIContextProviderChatClientTests #endregion + #region Shared Options Tests + + [Fact] + public async Task GetResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync() + { + // Arrange: track tool count seen by the inner client on each call + var toolCountsSeenByInner = new List(); + + var innerClient = CreateMockChatClient( + onGetResponse: (_, options, _) => + { + toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0); + return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")])); + }); + + var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]); + var chatClient = new AIContextProviderChatClient(innerClient, [provider]); + + var sharedOptions = new ChatOptions + { + Tools = new List { new TestAITool() } + }; + + // Act: make 3 calls reusing the same ChatOptions + for (int i = 0; i < 3; i++) + { + await RunWithAgentContextAsync(chatClient, sharedOptions); + } + + // Assert: each call should see exactly 2 tools (1 baseline + 1 injected) + Assert.Equal(3, toolCountsSeenByInner.Count); + Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count)); + } + + [Fact] + public async Task GetResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync() + { + // Arrange + var innerClient = CreateMockChatClient( + onGetResponse: (_, _, _) => + Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]))); + + var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]); + var chatClient = new AIContextProviderChatClient(innerClient, [provider]); + + var baselineTool = new TestAITool(); + var originalTools = new List { baselineTool }; + var sharedOptions = new ChatOptions + { + Tools = originalTools + }; + + // Act + await RunWithAgentContextAsync(chatClient, sharedOptions); + + // Assert: the original list should still contain only the baseline tool + Assert.Single(originalTools); + Assert.Same(baselineTool, originalTools[0]); + Assert.Same(originalTools, sharedOptions.Tools); + Assert.Same(baselineTool, originalTools[0]); + } + + [Fact] + public async Task GetStreamingResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync() + { + // Arrange + var toolCountsSeenByInner = new List(); + + var innerClient = CreateMockStreamingChatClient( + onGetStreamingResponse: (_, options, _) => + { + toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0); + return ToAsyncEnumerableAsync( + new ChatResponseUpdate(ChatRole.Assistant, "Response")); + }); + + var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]); + var chatClient = new AIContextProviderChatClient(innerClient, [provider]); + + var sharedOptions = new ChatOptions + { + Tools = new List { new TestAITool() } + }; + + // Act: make 3 streaming calls reusing the same ChatOptions + for (int i = 0; i < 3; i++) + { + await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions); + } + + // Assert: each call should see exactly 2 tools (1 baseline + 1 injected) + Assert.Equal(3, toolCountsSeenByInner.Count); + Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count)); + } + + [Fact] + public async Task GetStreamingResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync() + { + // Arrange + var innerClient = CreateMockStreamingChatClient( + onGetStreamingResponse: (_, _, _) => ToAsyncEnumerableAsync( + new ChatResponseUpdate(ChatRole.Assistant, "Response"))); + + var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]); + var chatClient = new AIContextProviderChatClient(innerClient, [provider]); + + var baselineTool = new TestAITool(); + var originalTools = new List { baselineTool }; + var sharedOptions = new ChatOptions + { + Tools = originalTools + }; + + // Act + await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions); + + // Assert: the original list should still contain only the baseline tool + Assert.Single(originalTools); + Assert.Same(baselineTool, originalTools[0]); + } + + #endregion + #region Builder Extension Tests [Fact] @@ -341,6 +464,44 @@ public class AIContextProviderChatClientTests await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession); } + /// + /// Runs a chat client within an agent context with the specified options. + /// + private static async Task RunWithAgentContextAsync(AIContextProviderChatClient chatClient, ChatOptions options) + { + var agent = new TestAIAgent + { + RunAsyncFunc = async (messages, session, agentOptions, ct) => + { + var response = await chatClient.GetResponseAsync(messages, options, ct); + return new AgentResponse(response); + } + }; + + await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession); + } + + /// + /// Runs a streaming chat client within an agent context with the specified options. + /// + private static async Task RunStreamingWithAgentContextAsync(AIContextProviderChatClient chatClient, List updates, ChatOptions options) + { + var agent = new TestAIAgent + { + RunAsyncFunc = async (messages, session, agentOptions, ct) => + { + await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options, ct)) + { + updates.Add(update); + } + + return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]); + } + }; + + await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession); + } + private static IChatClient CreateMockChatClient( Func, ChatOptions?, CancellationToken, Task> onGetResponse) { From 0756c457022ce98065f0786db8cdea7f56c859e3 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 25 Mar 2026 16:35:17 -0400 Subject: [PATCH 05/31] .NET: [BREAKING] Update type names and source generator to reduce conflicts (#4903) * refactor: [BREAKING] Config => ExecutorConfig Make the Config name less likely to collide with other classes by renaming to ExecutorConfig. Makes Configured and related classes internal as they do not need to be part of the public surface. * fix: Make RouteBuilder explicit in SourceGen to avoid conflicts --- .../Generation/SourceBuilder.cs | 1 + .../ConfigurationExtensions.cs | 4 +-- .../Configured.cs | 26 +++++++++---------- .../ExecutorBindingExtensions.cs | 4 +-- .../{Config.cs => ExecutorConfig.cs} | 4 +-- 5 files changed, 20 insertions(+), 19 deletions(-) rename dotnet/src/Microsoft.Agents.AI.Workflows/{Config.cs => ExecutorConfig.cs} (88%) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs index 9a74c88447..23d748e629 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs @@ -38,6 +38,7 @@ internal static class SourceBuilder sb.AppendLine("using System.Collections.Generic;"); sb.AppendLine("using Microsoft.Agents.AI.Workflows;"); sb.AppendLine(); + sb.AppendLine("using RouteBuilder = Microsoft.Agents.AI.Workflows.RouteBuilder;"); // Namespace if (!string.IsNullOrWhiteSpace(info.Namespace)) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ConfigurationExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ConfigurationExtensions.cs index e18bae72a5..d6e6df5dbd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ConfigurationExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ConfigurationExtensions.cs @@ -3,9 +3,9 @@ namespace Microsoft.Agents.AI.Workflows; /// -/// Provides extensions methods for creating objects +/// Provides extension methods for creating objects /// -public static class ConfigurationExtensions +internal static class ConfigurationExtensions { /// /// Creates a new configuration that treats the subject as its base type, allowing configuration to be applied at diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Configured.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Configured.cs index 3f876926be..b154bd6ca7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Configured.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Configured.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows; /// /// Provides methods for creating instances. /// -public static class Configured +internal static class Configured { /// /// Creates a instance from an existing subject instance. @@ -50,10 +50,10 @@ public static class Configured /// A representation of a preconfigured, lazy-instantiatable instance of . /// /// The type of the preconfigured subject. -/// A factory to intantiate the subject when desired. +/// A factory to instantiate the subject when desired. /// The unique identifier for the configured subject. /// -public class Configured(Func> factoryAsync, string id, object? raw = null) +internal class Configured(Func> factoryAsync, string id, object? raw = null) { /// /// Gets the raw representation of the configured object, if any. @@ -66,14 +66,14 @@ public class Configured(Func> fact public string Id => id; /// - /// Gets the factory function to create an instance of given a . + /// Gets the factory function to create an instance of given a . /// - public Func> FactoryAsync => factoryAsync; + public Func> FactoryAsync => factoryAsync; /// /// The configuration for this configured instance. /// - public Config Configuration => new(this.Id); + public ExecutorConfig Configuration => new(this.Id); /// /// Gets a "partially" applied factory function that only requires no parameters to create an instance of @@ -87,11 +87,11 @@ public class Configured(Func> fact /// /// The type of the preconfigured subject. /// The type of configuration options for the preconfigured subject. -/// A factory to intantiate the subject when desired. +/// A factory to instantiate the subject when desired. /// The unique identifier for the configured subject. /// Additional configuration options for the subject. /// -public class Configured(Func, string, ValueTask> factoryAsync, string id, TOptions? options = default, object? raw = null) +internal class Configured(Func, string, ValueTask> factoryAsync, string id, TOptions? options = default, object? raw = null) { /// /// The raw representation of the configured object, if any. @@ -109,14 +109,14 @@ public class Configured(Func, string, Value public TOptions? Options => options; /// - /// Gets the factory function to create an instance of given a . + /// Gets the factory function to create an instance of given a . /// - public Func, string, ValueTask> FactoryAsync => factoryAsync; + public Func, string, ValueTask> FactoryAsync => factoryAsync; /// /// The configuration for this configured instance. /// - public Config Configuration => new(this.Id, this.Options); + public ExecutorConfig Configuration => new(this.Id, this.Options); /// /// Gets a "partially" applied factory function that only requires no parameters to create an instance of @@ -124,11 +124,11 @@ public class Configured(Func, string, Value /// internal Func> BoundFactoryAsync => (sessionId) => this.CreateValidatingMemoizedFactory()(this.Configuration, sessionId); - private Func> CreateValidatingMemoizedFactory() + private Func> CreateValidatingMemoizedFactory() { return FactoryAsync; - async ValueTask FactoryAsync(Config configuration, string sessionId) + async ValueTask FactoryAsync(ExecutorConfig configuration, string sessionId) { if (this.Id != configuration.Id) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs index a0170e7757..afca74af5b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs @@ -113,7 +113,7 @@ public static class ExecutorBindingExtensions /// An id for the executor to be instantiated. /// An optional parameter specifying the options. /// An instance that resolves to the result of the factory call when messages get sent to it. - public static ExecutorBinding BindExecutor(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null) + public static ExecutorBinding BindExecutor(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null) where TExecutor : Executor where TOptions : ExecutorOptions { @@ -139,7 +139,7 @@ public static class ExecutorBindingExtensions /// An instance that resolves to the result of the factory call when messages get sent to it. [Obsolete("Use BindExecutor() instead")] [EditorBrowsable(EditorBrowsableState.Never)] - public static ExecutorBinding ConfigureFactory(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null) + public static ExecutorBinding ConfigureFactory(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null) where TExecutor : Executor where TOptions : ExecutorOptions => factoryAsync.BindExecutor(id, options); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Config.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorConfig.cs similarity index 88% rename from dotnet/src/Microsoft.Agents.AI.Workflows/Config.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorConfig.cs index 09792d2a64..48bfd12bb9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Config.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorConfig.cs @@ -6,7 +6,7 @@ namespace Microsoft.Agents.AI.Workflows; /// Represents a configuration for an object with a string identifier. For example, object. /// /// A unique identifier for the configurable object. -public class Config(string id) +public class ExecutorConfig(string id) { /// /// Gets a unique identifier for the configurable object. @@ -23,7 +23,7 @@ public class Config(string id) /// The type of options for the configurable object. /// A unique identifier for the configurable object. /// The options for the configurable object. -public class Config(string id, TOptions? options = default) : Config(id) +public class ExecutorConfig(string id, TOptions? options = default) : ExecutorConfig(id) { /// /// Gets the options for the configured object. From 35f44e854e11415e47a19774446b731ad9bcc43b Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 25 Mar 2026 18:22:13 -0400 Subject: [PATCH 06/31] .NET: fix: HandoffAgentExecutor does not output any response when non-streaming (#4745) * fix: HandoffAgentExecutor does not output any reponse when non-streaming * fix: Ensure Workflow outputs persisted in chat history when hosted AsAgent * fix: Remove duplicate history entry creation and ad test * test: Add streaming tests for AsAgent to smoke tests * feat: Add output configurability to Handoffs --- .../HandoffsWorkflowBuilder.cs | 46 +++- .../Specialized/AIAgentHostExecutor.cs | 17 +- .../Specialized/HandoffAgentExecutor.cs | 19 +- .../WorkflowChatHistoryProvider.cs | 8 +- .../WorkflowHostAgent.cs | 17 +- .../WorkflowSession.cs | 199 ++++++++---------- .../AIAgentHostExecutorTests.cs | 64 +----- .../AIAgentHostingExecutorTestsBase.cs | 79 +++++++ .../HandoffAgentExecutorTests.cs | 71 +++++++ .../WorkflowHostSmokeTests.cs | 68 +++++- 10 files changed, 404 insertions(+), 184 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs index bd0b3114f1..ccb993b188 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs @@ -13,10 +13,18 @@ namespace Microsoft.Agents.AI.Workflows; /// public sealed class HandoffsWorkflowBuilder { - internal const string FunctionPrefix = "handoff_to_"; + /// + /// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}<agent_id>`, + /// where `<agent_id>` is the ID of the target agent to hand off to. + /// + public const string FunctionPrefix = "handoff_to_"; + private readonly AIAgent _initialAgent; private readonly Dictionary> _targets = []; private readonly HashSet _allAgents = new(AIAgentIDEqualityComparer.Instance); + + private bool _emitAgentResponseEvents; + private bool _emitAgentResponseUpdateEvents; private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly; /// @@ -47,9 +55,13 @@ public sealed class HandoffsWorkflowBuilder """; /// - /// Sets additional instructions to provide to an agent that has handoffs about how and when to - /// perform them. + /// Sets instructions to provide to each agent that has handoffs about how and when to perform them. /// + /// + /// In the vast majority of cases, the will be sufficient, and there will be no need to customize. + /// If you do provide alternate instructions, remember to explain the mechanics of the handoff function tool call, using see + /// constant. + /// /// The instructions to provide, or to restore the default instructions. public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions) { @@ -57,6 +69,29 @@ public sealed class HandoffsWorkflowBuilder return this; } + /// + /// Sets a value indicating whether agent streaming update events should be emitted during execution. + /// If , the value will be taken from the + /// + /// + /// + public HandoffsWorkflowBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true) + { + this._emitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents; + return this; + } + + /// + /// Sets a value indicating whether aggregated agent response events should be emitted during execution. + /// + /// + /// + public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true) + { + this._emitAgentResponseEvents = emitAgentResponseEvents; + return this; + } + /// /// Sets the behavior for filtering and contents from /// s flowing through the handoff workflow. Defaults to . @@ -175,7 +210,10 @@ public sealed class HandoffsWorkflowBuilder HandoffsEndExecutor end = new(); WorkflowBuilder builder = new(start); - HandoffAgentExecutorOptions options = new(this.HandoffInstructions, this._toolCallFilteringBehavior); + HandoffAgentExecutorOptions options = new(this.HandoffInstructions, + this._emitAgentResponseEvents, + this._emitAgentResponseUpdateEvents, + this._toolCallFilteringBehavior); // Create an AgentExecutor for each again. Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options)); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs index cf9ddbe3a3..3f3d83fbee 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs @@ -12,6 +12,15 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal record AIAgentHostState(JsonElement? ThreadState, bool? CurrentTurnEmitEvents); +internal static class TurnExtensions +{ + public static bool ShouldEmitStreamingEvents(this TurnToken token, bool? agentSetting) + => token.EmitEvents ?? agentSetting ?? false; + + public static bool ShouldEmitStreamingEvents(bool? turnTokenSetting, bool? agentSetting) + => turnTokenSetting ?? agentSetting ?? false; +} + internal sealed class AIAgentHostExecutor : ChatProtocolExecutor { private readonly AIAgent _agent; @@ -104,9 +113,6 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor }, context, cancellationToken); } - public bool ShouldEmitStreamingEvents(bool? emitEvents) - => emitEvents ?? this._options.EmitAgentUpdateEvents ?? false; - private async ValueTask EnsureSessionAsync(IWorkflowContext context, CancellationToken cancellationToken) => this._session ??= await this._agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); @@ -175,7 +181,10 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor } protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) - => this.ContinueTurnAsync(messages, context, this.ShouldEmitStreamingEvents(emitEvents), cancellationToken); + => this.ContinueTurnAsync(messages, + context, + TurnExtensions.ShouldEmitStreamingEvents(turnTokenSetting: emitEvents, this._options.EmitAgentUpdateEvents), + cancellationToken); private async ValueTask InvokeAgentAsync(IEnumerable messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index d1367b83ad..21519e07ee 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -14,14 +14,20 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal sealed class HandoffAgentExecutorOptions { - public HandoffAgentExecutorOptions(string? handoffInstructions, HandoffToolCallFilteringBehavior toolCallFilteringBehavior) + public HandoffAgentExecutorOptions(string? handoffInstructions, bool emitAgentResponseEvents, bool? emitAgentResponseUpdateEvents, HandoffToolCallFilteringBehavior toolCallFilteringBehavior) { this.HandoffInstructions = handoffInstructions; + this.EmitAgentResponseEvents = emitAgentResponseEvents; + this.EmitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents; this.ToolCallFilteringBehavior = toolCallFilteringBehavior; } public string? HandoffInstructions { get; set; } + public bool EmitAgentResponseEvents { get; set; } + + public bool? EmitAgentResponseUpdateEvents { get; set; } + public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly; } @@ -250,7 +256,14 @@ internal sealed class HandoffAgentExecutor( } } - allMessages.AddRange(updates.ToAgentResponse().Messages); + AgentResponse agentResponse = updates.ToAgentResponse(); + + if (options.EmitAgentResponseEvents) + { + await context.YieldOutputAsync(agentResponse, cancellationToken).ConfigureAwait(false); + } + + allMessages.AddRange(agentResponse.Messages); roleChanges.ResetUserToAssistantForChangedRoles(); @@ -259,7 +272,7 @@ internal sealed class HandoffAgentExecutor( async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken) { updates.Add(update); - if (message.TurnToken.EmitEvents is true) + if (message.TurnToken.ShouldEmitStreamingEvents(options.EmitAgentResponseUpdateEvents)) { await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs index 2815ed99f0..69c09abb27 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs @@ -43,7 +43,7 @@ internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider => this._sessionState.GetOrInitializeState(session).Messages.AddRange(messages); protected override ValueTask> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default) - => new(this._sessionState.GetOrInitializeState(context.Session).Messages); + => new(this._sessionState.GetOrInitializeState(context.Session).Messages.AsReadOnly()); protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) { @@ -62,6 +62,12 @@ internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider } } + public IEnumerable GetAllMessages(AgentSession session) + { + var state = this._sessionState.GetOrInitializeState(session); + return state.Messages.AsReadOnly(); + } + public void UpdateBookmark(AgentSession session) { var state = this._sessionState.GetOrInitializeState(session); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs index 7679123970..295c08ceeb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs @@ -119,13 +119,17 @@ internal sealed class WorkflowHostAgent : AIAgent MessageMerger merger = new(); await foreach (AgentResponseUpdate update in workflowSession.InvokeStageAsync(cancellationToken) - .ConfigureAwait(false) - .WithCancellation(cancellationToken)) + .ConfigureAwait(false) + .WithCancellation(cancellationToken)) { merger.AddUpdate(update); } - return merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name); + AgentResponse response = merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name); + workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages); + workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession); + + return response; } protected override async @@ -138,11 +142,18 @@ internal sealed class WorkflowHostAgent : AIAgent await this.ValidateWorkflowAsync().ConfigureAwait(false); WorkflowSession workflowSession = await this.UpdateSessionAsync(messages, session, cancellationToken).ConfigureAwait(false); + MessageMerger merger = new(); + await foreach (AgentResponseUpdate update in workflowSession.InvokeStageAsync(cancellationToken) .ConfigureAwait(false) .WithCancellation(cancellationToken)) { + merger.AddUpdate(update); yield return update; } + + AgentResponse response = merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name); + workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages); + workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index db3d299ee9..715c232e7d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -131,7 +131,7 @@ internal sealed class WorkflowSession : AgentSession { Throw.IfNullOrEmpty(parts); - AgentResponseUpdate update = new(ChatRole.Assistant, parts) + return new(ChatRole.Assistant, parts) { CreatedAt = DateTimeOffset.UtcNow, MessageId = Guid.NewGuid().ToString("N"), @@ -139,27 +139,19 @@ internal sealed class WorkflowSession : AgentSession ResponseId = responseId, RawRepresentation = raw }; - - this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage()); - - return update; } public AgentResponseUpdate CreateUpdate(string responseId, object raw, ChatMessage message) { Throw.IfNull(message); - AgentResponseUpdate update = new(message.Role, message.Contents) + return new(message.Role, message.Contents) { CreatedAt = message.CreatedAt ?? DateTimeOffset.UtcNow, MessageId = message.MessageId ?? Guid.NewGuid().ToString("N"), ResponseId = responseId, RawRepresentation = raw }; - - this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage()); - - return update; } private async ValueTask CreateOrResumeRunAsync(List messages, CancellationToken cancellationToken = default) @@ -328,111 +320,104 @@ internal sealed class WorkflowSession : AgentSession IAsyncEnumerable InvokeStageAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default) { - try - { - this.LastResponseId = Guid.NewGuid().ToString("N"); - List messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList(); + this.LastResponseId = Guid.NewGuid().ToString("N"); + List messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList(); + + ResumeRunResult resumeResult = + await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false); - ResumeRunResult resumeResult = - await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false); #pragma warning disable CA2007 // Analyzer misfiring. - await using StreamingRun run = resumeResult.Run; + await using StreamingRun run = resumeResult.Run; #pragma warning restore CA2007 - ResumeDispatchInfo dispatchInfo = resumeResult.DispatchInfo; + ResumeDispatchInfo dispatchInfo = resumeResult.DispatchInfo; - // Send a TurnToken to the start executor unless the only activity is an external - // response directed at the start executor itself (which self-emits a TurnToken via - // ContinueTurnAsync). Non-start executors (e.g., RequestInfoExecutor) do not emit - // TurnTokens after processing responses, so the session must always provide one. - bool shouldSendTurnToken = - !dispatchInfo.HasMatchedExternalResponses - || !dispatchInfo.HasMatchedResponseForStartExecutor; - if (shouldSendTurnToken) - { - await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); - } - await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken) + // Send a TurnToken to the start executor unless the only activity is an external + // response directed at the start executor itself (which self-emits a TurnToken via + // ContinueTurnAsync). Non-start executors (e.g., RequestInfoExecutor) do not emit + // TurnTokens after processing responses, so the session must always provide one. + bool shouldSendTurnToken = + !dispatchInfo.HasMatchedExternalResponses + || !dispatchInfo.HasMatchedResponseForStartExecutor; + if (shouldSendTurnToken) + { + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); + } + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken) .ConfigureAwait(false) .WithCancellation(cancellationToken)) - { - switch (evt) - { - case AgentResponseUpdateEvent agentUpdate: - yield return agentUpdate.Update; - break; - - case RequestInfoEvent requestInfo: - AIContent requestContent = CreateRequestContentForDelivery(requestInfo.Request); - - // Track the pending request so we can convert incoming responses back to ExternalResponse. - // External callers respond using the workflow-facing request ID, which is always RequestId. - this.AddPendingRequest(requestInfo.Request.RequestId, requestInfo.Request); - - AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, requestContent); - yield return update; - break; - - case WorkflowErrorEvent workflowError: - Exception? exception = workflowError.Exception; - if (exception is TargetInvocationException tie && tie.InnerException != null) - { - exception = tie.InnerException; - } - - if (exception != null) - { - string message = this._includeExceptionDetails - ? exception.Message - : "An error occurred while executing the workflow."; - - ErrorContent errorContent = new(message); - yield return this.CreateUpdate(this.LastResponseId, evt, errorContent); - } - - break; - - case SuperStepCompletedEvent stepCompleted: - this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint; - goto default; - - case WorkflowOutputEvent output: - IEnumerable? updateMessages = output.Data switch - { - IEnumerable chatMessages => chatMessages, - ChatMessage chatMessage => [chatMessage], - _ => null - }; - - if (!this._includeWorkflowOutputsInResponse || updateMessages == null) - { - goto default; - } - - foreach (ChatMessage message in updateMessages) - { - yield return this.CreateUpdate(this.LastResponseId, evt, message); - } - break; - - default: - // Emit all other workflow events for observability (DevUI, logging, etc.) - yield return new AgentResponseUpdate(ChatRole.Assistant, []) - { - CreatedAt = DateTimeOffset.UtcNow, - MessageId = Guid.NewGuid().ToString("N"), - Role = ChatRole.Assistant, - ResponseId = this.LastResponseId, - RawRepresentation = evt - }; - break; - } - } - } - finally { - // Do we want to try to undo the step, and not update the bookmark? - this.ChatHistoryProvider.UpdateBookmark(this); + switch (evt) + { + case AgentResponseUpdateEvent agentUpdate: + yield return agentUpdate.Update; + break; + + case RequestInfoEvent requestInfo: + AIContent requestContent = CreateRequestContentForDelivery(requestInfo.Request); + + // Track the pending request so we can convert incoming responses back to ExternalResponse. + // External callers respond using the workflow-facing request ID, which is always RequestId. + this.AddPendingRequest(requestInfo.Request.RequestId, requestInfo.Request); + + AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, requestContent); + yield return update; + break; + + case WorkflowErrorEvent workflowError: + Exception? exception = workflowError.Exception; + if (exception is TargetInvocationException tie && tie.InnerException != null) + { + exception = tie.InnerException; + } + + if (exception != null) + { + string message = this._includeExceptionDetails + ? exception.Message + : "An error occurred while executing the workflow."; + + ErrorContent errorContent = new(message); + yield return this.CreateUpdate(this.LastResponseId, evt, errorContent); + } + + break; + + case SuperStepCompletedEvent stepCompleted: + this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint; + goto default; + + case WorkflowOutputEvent output: + IEnumerable? updateMessages = output.Data switch + { + IEnumerable chatMessages => chatMessages, + ChatMessage chatMessage => [chatMessage], + _ => null + }; + + if (!this._includeWorkflowOutputsInResponse || updateMessages == null) + { + goto default; + } + + foreach (ChatMessage message in updateMessages) + { + yield return this.CreateUpdate(this.LastResponseId, evt, message); + } + break; + + default: + // Emit all other workflow events for observability (DevUI, logging, etc.) + yield return new AgentResponseUpdate(ChatRole.Assistant, []) + { + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + Role = ChatRole.Assistant, + ResponseId = this.LastResponseId, + RawRepresentation = evt + }; + break; + } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs index 063bd77cda..9cd9eb45e6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs @@ -10,20 +10,8 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.UnitTests; -public class AIAgentHostExecutorTests +public class AIAgentHostExecutorTests : AIAgentHostingExecutorTestsBase { - private const string TestAgentId = nameof(TestAgentId); - private const string TestAgentName = nameof(TestAgentName); - - private static readonly string[] s_messageStrings = [ - "", - "Hello world!", - "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", - "Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus." - ]; - - private static List TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings); - [Theory] [InlineData(null, null)] [InlineData(null, true)] @@ -50,30 +38,7 @@ public class AIAgentHostExecutorTests bool expectingEvents = turnSetting ?? executorSetting ?? false; AgentResponseUpdateEvent[] updates = testContext.Events.OfType().ToArray(); - if (expectingEvents) - { - // The way TestReplayAgent is set up, it will emit one update per non-empty AIContent - List expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList(); - - updates.Should().HaveCount(expectedUpdateContents.Count); - for (int i = 0; i < updates.Length; i++) - { - AgentResponseUpdateEvent updateEvent = updates[i]; - AIContent expectedUpdateContent = expectedUpdateContents[i]; - - updateEvent.ExecutorId.Should().Be(agent.GetDescriptiveId()); - - AgentResponseUpdate update = updateEvent.Update; - update.AuthorName.Should().Be(TestAgentName); - update.AgentId.Should().Be(TestAgentId); - update.Contents.Should().HaveCount(1); - update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent); - } - } - else - { - updates.Should().BeEmpty(); - } + CheckResponseUpdateEventsAgainstTestMessages(updates, expectingEvents, agent.GetDescriptiveId()); } [Theory] @@ -92,30 +57,7 @@ public class AIAgentHostExecutorTests // Assert AgentResponseEvent[] updates = testContext.Events.OfType().ToArray(); - if (executorSetting) - { - updates.Should().HaveCount(1); - - AgentResponseEvent responseEvent = updates[0]; - responseEvent.ExecutorId.Should().Be(agent.GetDescriptiveId()); - - AgentResponse response = responseEvent.Response; - response.AgentId.Should().Be(TestAgentId); - response.Messages.Should().HaveCount(TestMessages.Count - 1); - - for (int i = 0; i < response.Messages.Count; i++) - { - ChatMessage responseMessage = response.Messages[i]; - ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message - - responseMessage.AuthorName.Should().Be(TestAgentName); - responseMessage.Text.Should().Be(expectedMessage.Text); - } - } - else - { - updates.Should().BeEmpty(); - } + CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId()); } private static ChatMessage UserMessage => new(ChatRole.User, "Hello from User!") { AuthorName = "User" }; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs new file mode 100644 index 0000000000..2285074ce3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public abstract class AIAgentHostingExecutorTestsBase +{ + protected const string TestAgentId = nameof(TestAgentId); + protected const string TestAgentName = nameof(TestAgentName); + + private static readonly string[] s_messageStrings = [ + "", + "Hello world!", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus." + ]; + + protected static List TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings); + + protected static void CheckResponseUpdateEventsAgainstTestMessages(AgentResponseUpdateEvent[] updates, bool expectingEvents, string expectedExecutorId) + { + if (expectingEvents) + { + // The way TestReplayAgent is set up, it will emit one update per non-empty AIContent + List expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList(); + + updates.Should().HaveCount(expectedUpdateContents.Count); + for (int i = 0; i < updates.Length; i++) + { + AgentResponseUpdateEvent updateEvent = updates[i]; + AIContent expectedUpdateContent = expectedUpdateContents[i]; + + updateEvent.ExecutorId.Should().Be(expectedExecutorId); + + AgentResponseUpdate update = updateEvent.Update; + update.AuthorName.Should().Be(TestAgentName); + update.AgentId.Should().Be(TestAgentId); + update.Contents.Should().HaveCount(1); + update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent); + } + } + else + { + updates.Should().BeEmpty(); + } + } + + protected static void CheckResponseEventsAgainstTestMessages(AgentResponseEvent[] updates, bool expectingResponse, string expectedExecutorId) + { + if (expectingResponse) + { + updates.Should().HaveCount(1); + + AgentResponseEvent responseEvent = updates[0]; + responseEvent.ExecutorId.Should().Be(expectedExecutorId); + + AgentResponse response = responseEvent.Response; + response.AgentId.Should().Be(TestAgentId); + response.Messages.Should().HaveCount(TestMessages.Count - 1); + + for (int i = 0; i < response.Messages.Count; i++) + { + ChatMessage responseMessage = response.Messages[i]; + ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message + + responseMessage.AuthorName.Should().Be(TestAgentName); + responseMessage.Text.Should().Be(expectedMessage.Text); + } + } + else + { + updates.Should().BeEmpty(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs new file mode 100644 index 0000000000..8bdbe23c5f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Specialized; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase +{ + [Theory] + [InlineData(null, null)] + [InlineData(null, true)] + [InlineData(null, false)] + [InlineData(true, null)] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, null)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task Test_HandoffAgentExecutor_EmitsStreamingUpdatesIFFConfiguredAsync(bool? executorSetting, bool? turnSetting) + { + // Arrange + TestRunContext testContext = new(); + TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); + + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: false, + emitAgentResponseUpdateEvents: executorSetting, + HandoffToolCallFilteringBehavior.None); + + HandoffAgentExecutor executor = new(agent, options); + testContext.ConfigureExecutor(executor); + + // Act + HandoffState message = new(new(turnSetting), null, []); + await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id)); + + // Assert + bool expectingStreamingUpdates = turnSetting ?? executorSetting ?? false; + + AgentResponseUpdateEvent[] updates = testContext.Events.OfType().ToArray(); + CheckResponseUpdateEventsAgainstTestMessages(updates, expectingStreamingUpdates, agent.GetDescriptiveId()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Test_HandoffAgentExecutor_EmitsResponseIFFConfiguredAsync(bool executorSetting) + { + // Arrange + TestRunContext testContext = new(); + TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); + + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: executorSetting, + emitAgentResponseUpdateEvents: false, + HandoffToolCallFilteringBehavior.None); + + HandoffAgentExecutor executor = new(agent, options); + testContext.ConfigureExecutor(executor); + + // Act + HandoffState message = new(new(false), null, []); + await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id)); + + // Assert + AgentResponseEvent[] updates = testContext.Events.OfType().ToArray(); + CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index 1920a57cb1..7bf00c4fb9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -206,7 +206,7 @@ internal sealed class TurnTrackingStartExecutor : ChatProtocolExecutor } } -public class WorkflowHostSmokeTests +public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase { private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent { @@ -731,4 +731,70 @@ public class WorkflowHostSmokeTests .Should() .BeEmpty(); } + + private async Task Run_AsAgent_OutgoingMessagesInHistoryAsync(Workflow workflow, bool runAsync) + { + // Arrange + AIAgent workflowAgent = workflow.AsAIAgent(); + + // Act + AgentSession session = await workflowAgent.CreateSessionAsync(); + AgentResponse response; + if (runAsync) + { + List updates = []; + await foreach (AgentResponseUpdate update in workflowAgent.RunStreamingAsync(session)) + { + // Skip WorkflowEvent updates, which do not get persisted in ChatHistory; we cannot skip + // them after because of a deleterious interaction with .ToAgentResponse() due to the + // empty initial message (which is created without a MessageId). When running through the + // message merger, it does the right thing internally. + if (!string.IsNullOrEmpty(update.Text)) + { + updates.Add(update); + } + } + + response = updates.ToAgentResponse(); + } + else + { + response = await workflowAgent.RunAsync(session); + } + + // Assert + WorkflowSession workflowSession = session.Should().BeOfType().Subject; + + ChatMessage[] responseMessages = response.Messages.Where(message => message.Contents.Any()) + .ToArray(); + + ChatMessage[] sessionMessages = workflowSession.ChatHistoryProvider.GetAllMessages(workflowSession) + .ToArray(); + + // Since we never sent an incoming message, the expectation is that there should be nothing in the session + // except the response + responseMessages.Should().BeEquivalentTo(sessionMessages, options => options.WithStrictOrdering()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public Task Test_SingleAgent_AsAgent_OutgoingMessagesInHistoryAsync(bool runAsync) + { + // Arrange + TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); + Workflow singleAgentWorkflow = new WorkflowBuilder(agent).Build(); + return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(singleAgentWorkflow, runAsync); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public Task Test_Handoffs_AsAgent_OutgoingMessagesInHistoryAsync(bool runAsync) + { + // Arrange + TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); + Workflow handoffWorkflow = new HandoffsWorkflowBuilder(agent).Build(); + return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(handoffWorkflow, runAsync); + } } From 0bdcaa5c074caf680e75c1987539eb9af87d6a6d Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 25 Mar 2026 18:53:44 -0400 Subject: [PATCH 07/31] fix: Re-enable the "retrieve object" check in StateManager (#1881) --- .../Microsoft.Agents.AI.Workflows/Execution/StateManager.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs index 81ffedc6af..3e34797bf0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs @@ -106,8 +106,7 @@ internal sealed class StateManager if (typeof(T) == typeof(object)) { // Reading as object will break across serialize/deserialize boundaries, e.g. checkpointing, distributed runtime, etc. - // Disabled pending upstream updates for this change; see https://github.com/microsoft/agent-framework/issues/1369 - //throw new NotSupportedException("Reading state as 'object' is not supported. Use 'PortableValue' instead for variants."); + throw new NotSupportedException("Reading state as 'object' is not supported. Use 'PortableValue' instead for variants."); } Throw.IfNullOrEmpty(key); From c1435ac2010002fba57c60cbb8227e27c94bc92f Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:08:47 -0700 Subject: [PATCH 08/31] Python: Fix A2AAgent to surface message content from in-progress TaskStatusUpdateEvents (#4798) * Fix A2AAgent dropping message content from in-progress TaskStatusUpdateEvents (#4783) _updates_from_task() returned [] for working-state tasks when background=False, silently discarding all intermediate message content from task.status.message. Now extracts and yields message parts from in-progress status updates during streaming. Also fixed MockA2AClient.send_message to yield all queued responses (enabling multi-event streaming tests) and added text parameter to add_in_progress_task_response for tests that need status messages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix: gate intermediate status updates behind emit_intermediate flag and add missing test coverage - Add emit_intermediate parameter to _updates_from_task and _map_a2a_stream - Thread stream flag from run() so only streaming callers see intermediate updates - Add IN_PROGRESS_TASK_STATES guard to emit_intermediate condition - Add role parameter to test helper add_in_progress_task_response - Add clarifying comment on MockA2AClient.send_message batch semantics - Add tests for user role mapping, background precedence, non-streaming behavior, terminal task with no artifacts, and empty parts edge case Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../a2a/agent_framework_a2a/_agent.py | 55 +++++- python/packages/a2a/tests/test_a2a_agent.py | 157 +++++++++++++++++- 2 files changed, 202 insertions(+), 10 deletions(-) diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index d016caae7c..4b6d9cc19b 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -313,6 +313,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): self._map_a2a_stream( a2a_stream, background=background, + emit_intermediate=stream, session=provider_session, session_context=session_context, ), @@ -327,6 +328,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): a2a_stream: AsyncIterable[A2AStreamItem], *, background: bool = False, + emit_intermediate: bool = False, session: AgentSession | None = None, session_context: SessionContext | None = None, ) -> AsyncIterable[AgentResponseUpdate]: @@ -339,6 +341,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): background: When False, in-progress task updates are silently consumed (the stream keeps iterating until a terminal state). When True, they are yielded with a continuation token. + emit_intermediate: When True, in-progress status updates that + carry message content are yielded to the caller. Typically + set for streaming callers so non-streaming consumers only + receive terminal task outputs. session: The agent session for context providers. session_context: The session context for context providers. """ @@ -373,7 +379,11 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): yield update elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], Task): task, _update_event = item - for update in self._updates_from_task(task, background=background): + for update in self._updates_from_task( + task, + background=background, + emit_intermediate=emit_intermediate, + ): all_updates.append(update) yield update else: @@ -389,15 +399,26 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): # Task helpers # ------------------------------------------------------------------ - def _updates_from_task(self, task: Task, *, background: bool = False) -> list[AgentResponseUpdate]: + def _updates_from_task( + self, + task: Task, + *, + background: bool = False, + emit_intermediate: bool = False, + ) -> list[AgentResponseUpdate]: """Convert an A2A Task into AgentResponseUpdate(s). Terminal tasks produce updates from their artifacts/history. - In-progress tasks produce a continuation token update only when - ``background=True``; otherwise they are silently skipped so the - caller keeps consuming the stream until completion. + In-progress tasks produce a continuation token update when + ``background=True``. When ``emit_intermediate=True`` (typically + set for streaming callers), any message content attached to an + in-progress status update is surfaced; otherwise the update is + silently skipped so the caller keeps consuming the stream until + completion. """ - if task.status.state in TERMINAL_TASK_STATES: + status = task.status + + if status.state in TERMINAL_TASK_STATES: task_messages = self._parse_messages_from_task(task) if task_messages: return [ @@ -412,7 +433,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): ] return [AgentResponseUpdate(contents=[], role="assistant", response_id=task.id, raw_representation=task)] - if background and task.status.state in IN_PROGRESS_TASK_STATES: + if background and status.state in IN_PROGRESS_TASK_STATES: token = self._build_continuation_token(task) return [ AgentResponseUpdate( @@ -424,6 +445,26 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): ) ] + # Surface message content from in-progress status updates (e.g. working state) + # Only emitted when the caller opts in (streaming), so non-streaming + # consumers keep receiving only terminal task outputs. + if ( + emit_intermediate + and status.state in IN_PROGRESS_TASK_STATES + and status.message is not None + and status.message.parts + ): + contents = self._parse_contents_from_a2a(status.message.parts) + if contents: + return [ + AgentResponseUpdate( + contents=contents, + role="assistant" if status.message.role == A2ARole.agent else "user", + response_id=task.id, + raw_representation=task, + ) + ] + return [] @staticmethod diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index b8633938fc..0d81179cd1 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -91,9 +91,18 @@ class MockA2AClient: task_id: str, context_id: str = "test-context", state: TaskState = TaskState.working, + text: str | None = None, + role: A2ARole = A2ARole.agent, ) -> None: """Add a mock in-progress Task response (non-terminal).""" - status = TaskStatus(state=state, message=None) + message = None + if text is not None: + message = A2AMessage( + message_id=str(uuid4()), + role=role, + parts=[Part(root=TextPart(text=text))], + ) + status = TaskStatus(state=state, message=message) task = Task(id=task_id, context_id=context_id, status=status) client_event = (task, None) self.responses.append(client_event) @@ -102,9 +111,10 @@ class MockA2AClient: """Mock send_message method that yields responses.""" self.call_count += 1 - if self.responses: - response = self.responses.pop(0) + # All queued responses are delivered as a single streaming batch per call. + for response in self.responses: yield response + self.responses.clear() async def resubscribe(self, request: Any) -> AsyncIterator[Any]: """Mock resubscribe method that yields responses.""" @@ -1039,3 +1049,144 @@ async def test_run_with_continuation_token_does_not_require_messages(mock_a2a_cl # endregion + +# region Streaming with in-progress message content + + +async def test_streaming_working_updates_yield_message_content( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that streaming working updates with status.message yield content.""" + mock_a2a_client.add_in_progress_task_response("task-w", context_id="ctx-w", text="Processing step 1...") + mock_a2a_client.add_in_progress_task_response("task-w", context_id="ctx-w", text="Processing step 2...") + mock_a2a_client.add_task_response("task-w", [{"id": "art-w", "content": "Final result"}]) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 3 + assert updates[0].contents[0].text == "Processing step 1..." + assert updates[1].contents[0].text == "Processing step 2..." + assert updates[2].contents[0].text == "Final result" + + +async def test_streaming_single_working_update_with_message( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that a single working update with message content is not dropped.""" + mock_a2a_client.add_in_progress_task_response("task-s", context_id="ctx-s", text="Thinking...") + mock_a2a_client.add_task_response("task-s", [{"id": "art-s", "content": "Done"}]) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 2 + assert updates[0].contents[0].text == "Thinking..." + assert updates[0].role == "assistant" + assert updates[1].contents[0].text == "Done" + + +async def test_streaming_working_update_without_message_is_skipped( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that working updates without status.message are still silently skipped.""" + mock_a2a_client.add_in_progress_task_response("task-n", context_id="ctx-n") + mock_a2a_client.add_task_response("task-n", [{"id": "art-n", "content": "Result"}]) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 1 + assert updates[0].contents[0].text == "Result" + + +async def test_streaming_working_update_user_role_mapping(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """Test that A2ARole.user in status message maps to role='user'.""" + mock_a2a_client.add_in_progress_task_response("task-u", context_id="ctx-u", text="User echo", role=A2ARole.user) + mock_a2a_client.add_task_response("task-u", [{"id": "art-u", "content": "Done"}]) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 2 + assert updates[0].contents[0].text == "User echo" + assert updates[0].role == "user" + + +async def test_background_with_status_message_yields_continuation_token( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that background=True takes precedence over status message content.""" + mock_a2a_client.add_in_progress_task_response("task-bg", context_id="ctx-bg", text="Should be ignored") + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True, background=True): + updates.append(update) + + assert len(updates) == 1 + assert updates[0].continuation_token is not None + assert updates[0].continuation_token["task_id"] == "task-bg" + assert updates[0].contents == [] + + +async def test_non_streaming_does_not_surface_intermediate_messages( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that run(stream=False) does not include intermediate status messages.""" + mock_a2a_client.add_in_progress_task_response("task-ns", context_id="ctx-ns", text="Intermediate") + mock_a2a_client.add_task_response("task-ns", [{"id": "art-ns", "content": "Final"}]) + + response = await a2a_agent.run("Hello") + + assert len(response.messages) == 1 + assert response.messages[0].text == "Final" + + +async def test_terminal_no_artifacts_after_working_with_content( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that a terminal task with no artifacts after working-state messages does not re-emit the working content.""" + mock_a2a_client.add_in_progress_task_response("task-t", context_id="ctx-t", text="Working on it...") + # Terminal task with no artifacts and no history + status = TaskStatus(state=TaskState.completed, message=None) + task = Task(id="task-t", context_id="ctx-t", status=status) + mock_a2a_client.responses.append((task, None)) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 2 + assert updates[0].contents[0].text == "Working on it..." + # Terminal task with no artifacts yields an empty-contents update + assert updates[1].contents == [] + + +async def test_streaming_working_update_with_empty_parts_is_skipped( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that a working update with status.message but empty parts list is skipped.""" + # Construct a message with an empty parts list (distinct from message=None) + message = A2AMessage( + message_id=str(uuid4()), + role=A2ARole.agent, + parts=[], + ) + status = TaskStatus(state=TaskState.working, message=message) + task = Task(id="task-ep", context_id="ctx-ep", status=status) + mock_a2a_client.responses.append((task, None)) + mock_a2a_client.add_task_response("task-ep", [{"id": "art-ep", "content": "Result"}]) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 1 + assert updates[0].contents[0].text == "Result" + + +# endregion From dc27740f1a752360426b8ef29efd8ec259325e1b Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:09:22 -0700 Subject: [PATCH 09/31] Python: Fix streaming path to emit mcp_server_tool_result on output_item.done instead of output_item.added (#4821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix streaming path to deliver mcp_server_tool_result content (#4814) Remove premature mcp_server_tool_result emission from the response.output_item.added/mcp_call handler — at that point the MCP server has not yet responded and output is always None. Add a handler for response.mcp_call.completed that emits mcp_server_tool_result with the actual tool output, matching the non-streaming path behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix streaming path to deliver mcp_server_tool_result content (#4814) Stop eagerly emitting mcp_server_tool_result on response.output_item.added (when output is always None). Instead, handle response.output_item.done for mcp_call items, which carries the full McpCall with populated output. This matches the non-streaming path which guards with 'if item.output is not None' before emitting the result. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix test docstring to match actual implementation event name Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: call_id fallback and raw_representation consistency (#4814) - Add call_id fallback in response.output_item.done mcp_call handler to match the output_item.added handler pattern - Use done_item instead of event for raw_representation to keep consistent with other output_item branches and non-streaming path - Add test for call_id fallback when id attribute is missing - Add raw_representation assertions to existing done handler tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: call_id fallback for non-streaming path and test coverage (#4814) - Apply defensive call_id fallback (getattr with id/call_id/empty) to non-streaming mcp_call path for consistency with streaming path - Add raw_representation assertion to call_id fallback test - Add test for empty-string fallback when neither id nor call_id exist Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_openai/_chat_client.py | 39 +++--- .../tests/openai/test_openai_chat_client.py | 129 ++++++++++++++++-- 2 files changed, 136 insertions(+), 32 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index e1e40c87c0..86af86895e 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -1728,7 +1728,7 @@ class RawOpenAIChatClient( # type: ignore[misc] ) ) case "mcp_call": - call_id = item.id + call_id = getattr(item, "id", None) or getattr(item, "call_id", None) or "" contents.append( Content.from_mcp_server_tool_call( call_id=call_id, @@ -2118,27 +2118,7 @@ class RawOpenAIChatClient( # type: ignore[misc] raw_representation=event_item, ) ) - result_output = ( - getattr(event_item, "result", None) - or getattr(event_item, "output", None) - or getattr(event_item, "outputs", None) - ) - parsed_output: list[Content] | None = None - if result_output: - normalized = ( # pyright: ignore[reportUnknownVariableType] - result_output - if isinstance(result_output, Sequence) - and not isinstance(result_output, (str, bytes, MutableMapping)) - else [result_output] - ) - parsed_output = [Content.from_dict(output_item) for output_item in normalized] # pyright: ignore[reportArgumentType,reportUnknownVariableType] - contents.append( - Content.from_mcp_server_tool_result( - call_id=call_id, - output=parsed_output, - raw_representation=event_item, - ) - ) + # Result deferred to response.output_item.done case "code_interpreter_call": # ResponseOutputCodeInterpreterCall call_id = getattr(event_item, "call_id", None) or getattr(event_item, "id", None) outputs: list[Content] = [] @@ -2408,6 +2388,21 @@ class RawOpenAIChatClient( # type: ignore[misc] ) else: logger.debug("Unparsed annotation type in streaming: %s", ann_type) + case "response.output_item.done": + done_item = event.item + if getattr(done_item, "type", None) == "mcp_call": + call_id = getattr(done_item, "id", None) or getattr(done_item, "call_id", None) or "" + output_text = getattr(done_item, "output", None) + parsed_output: list[Content] | None = ( + [Content.from_text(text=output_text)] if isinstance(output_text, str) else None + ) + contents.append( + Content.from_mcp_server_tool_result( + call_id=call_id, + output=parsed_output, + raw_representation=done_item, + ) + ) case _: logger.debug("Unparsed event of type: %s: %s", event.type, event) diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index a29fbf443a..897fe5f913 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -1184,11 +1184,13 @@ def test_parse_response_from_openai_with_mcp_server_tool_result() -> None: assert result_content.output is not None -def test_parse_chunk_from_openai_with_mcp_call_result() -> None: - """Test _parse_chunk_from_openai with MCP call output.""" +def test_parse_chunk_from_openai_with_mcp_call_added_defers_result() -> None: + """Test that response.output_item.added for mcp_call emits only the call, not the result. + + The result is deferred to response.output_item.done. + """ client = OpenAIChatClient(model="test-model", api_key="test-key") - # Mock event with MCP call that has output mock_event = MagicMock() mock_event.type = "response.output_item.added" @@ -1199,8 +1201,9 @@ def test_parse_chunk_from_openai_with_mcp_call_result() -> None: mock_item.name = "fetch_resource" mock_item.server_label = "ResourceServer" mock_item.arguments = {"resource_id": "123"} - # Use proper content structure that _parse_content can handle - mock_item.result = [{"type": "text", "text": "test result"}] + mock_item.result = None + mock_item.output = None + mock_item.outputs = None mock_event.item = mock_item mock_event.output_index = 0 @@ -1209,18 +1212,124 @@ def test_parse_chunk_from_openai_with_mcp_call_result() -> None: update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids) - # Should have both call and result in contents - assert len(update.contents) == 2 - call_content, result_content = update.contents + # Should have only the call content — result is deferred + assert len(update.contents) == 1 + call_content = update.contents[0] assert call_content.type == "mcp_server_tool_call" assert call_content.call_id in ["mcp_call_456", "call_456"] assert call_content.tool_name == "fetch_resource" + # No result should be emitted at this point + result_contents = [c for c in update.contents if c.type == "mcp_server_tool_result"] + assert len(result_contents) == 0 + + +def test_parse_chunk_from_openai_with_mcp_output_item_done() -> None: + """Test that response.output_item.done for mcp_call emits mcp_server_tool_result with output.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.done" + + mock_item = MagicMock() + mock_item.type = "mcp_call" + mock_item.id = "mcp_call_456" + mock_item.output = "The weather in Seattle is 72F and sunny." + mock_event.item = mock_item + + function_call_ids: dict[int, tuple[str, str]] = {} + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids) + + assert len(update.contents) == 1 + result_content = update.contents[0] + assert result_content.type == "mcp_server_tool_result" - assert result_content.call_id in ["mcp_call_456", "call_456"] - # Verify the output was parsed + assert result_content.call_id == "mcp_call_456" assert result_content.output is not None + assert len(result_content.output) == 1 + assert result_content.output[0].text == "The weather in Seattle is 72F and sunny." + assert result_content.raw_representation is mock_item + + +def test_parse_chunk_from_openai_with_mcp_output_item_done_no_output() -> None: + """Test that response.output_item.done for mcp_call with no output emits result with None output.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.done" + + mock_item = MagicMock() + mock_item.type = "mcp_call" + mock_item.id = "mcp_call_789" + mock_item.output = None + mock_event.item = mock_item + + function_call_ids: dict[int, tuple[str, str]] = {} + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids) + + assert len(update.contents) == 1 + result_content = update.contents[0] + + assert result_content.type == "mcp_server_tool_result" + assert result_content.call_id == "mcp_call_789" + assert result_content.output is None + assert result_content.raw_representation is mock_item + + +def test_parse_chunk_from_openai_with_mcp_output_item_done_call_id_fallback() -> None: + """Test that response.output_item.done for mcp_call falls back to call_id when id is missing.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.done" + + mock_item = MagicMock(spec=[]) + mock_item.type = "mcp_call" + mock_item.call_id = "mcp_fallback_123" + mock_item.output = "fallback result" + mock_event.item = mock_item + + function_call_ids: dict[int, tuple[str, str]] = {} + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids) + + assert len(update.contents) == 1 + result_content = update.contents[0] + + assert result_content.type == "mcp_server_tool_result" + assert result_content.call_id == "mcp_fallback_123" + assert result_content.output is not None + assert result_content.output[0].text == "fallback result" + assert result_content.raw_representation is mock_item + + +def test_parse_chunk_from_openai_with_mcp_output_item_done_no_id_fallback() -> None: + """Test that response.output_item.done for mcp_call falls back to empty string when neither id nor call_id exist.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.done" + + mock_item = MagicMock(spec=[]) + mock_item.type = "mcp_call" + mock_item.output = "some result" + mock_event.item = mock_item + + function_call_ids: dict[int, tuple[str, str]] = {} + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids) + + assert len(update.contents) == 1 + result_content = update.contents[0] + + assert result_content.type == "mcp_server_tool_result" + assert result_content.call_id == "" + assert result_content.output is not None + assert result_content.output[0].text == "some result" + assert result_content.raw_representation is mock_item def test_prepare_message_for_openai_with_function_approval_response() -> None: From dd3d085539375c38927e6101ac60688b69fa08c1 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:56:10 +0900 Subject: [PATCH 10/31] Python: Include reasoning messages in MESSAGES_SNAPSHOT events (#4844) * Include reasoning messages in MESSAGES_SNAPSHOT (#4843) FlowState now tracks reasoning messages emitted during a run. _emit_text_reasoning() persists reasoning (including encrypted_value) into flow.reasoning_messages, and _build_messages_snapshot() appends them to the final MESSAGES_SNAPSHOT event. Changes: - Add reasoning_messages field to FlowState - Update _emit_text_reasoning() to accept optional flow parameter - Include reasoning_messages in _build_messages_snapshot() - Add 'reasoning' to ALLOWED_AGUI_ROLES so normalize_agui_role() preserves the role through snapshot round-trips - Skip reasoning messages in agui_messages_to_agent_framework() since they are UI-only state and should not be forwarded to LLM providers - Add regression tests for snapshot emission, encrypted value preservation, and multi-turn round-trip with reasoning Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Include reasoning messages in MESSAGES_SNAPSHOT events Fixes #4843 * Fix PR review feedback for reasoning persistence (#4843) - Accumulate reasoning text per message_id (append deltas) instead of storing only the current chunk, matching flow.accumulated_text pattern - Use camelCase encryptedValue in snapshot JSON to match AG-UI protocol conventions (toolCallId, encryptedValue) - Normalize snake_case encrypted_value to encryptedValue in agui_messages_to_snapshot_format for input compatibility - Update normalize_agui_role docstring to include reasoning role - Add tests for incremental reasoning accumulation and key normalization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4843: Python: agent-framework-ag-ui: include reasoning messages in MESSAGES_SNAPSHOT --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ag-ui/agent_framework_ag_ui/_agent_run.py | 8 +- .../_message_adapters.py | 9 + .../agent_framework_ag_ui/_run_common.py | 40 ++++- .../ag-ui/agent_framework_ag_ui/_utils.py | 4 +- .../tests/ag_ui/test_message_adapters.py | 91 ++++++++++ python/packages/ag-ui/tests/ag_ui/test_run.py | 155 ++++++++++++++++++ .../packages/ag-ui/tests/ag_ui/test_utils.py | 1 + 7 files changed, 303 insertions(+), 5 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py index 4b00330283..e9ce610b10 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py @@ -684,6 +684,10 @@ def _build_messages_snapshot( } ) + # Add reasoning messages so frontends that reconcile state from + # MESSAGES_SNAPSHOT retain reasoning content after streaming ends. + all_messages.extend(flow.reasoning_messages) + return MessagesSnapshotEvent(messages=all_messages) # type: ignore[arg-type] @@ -1061,7 +1065,9 @@ async def run_agent_stream( # Emit MessagesSnapshotEvent if we have tool calls or results # Feature #5: Suppress intermediate snapshots for predictive tools without confirmation - should_emit_snapshot = flow.pending_tool_calls or flow.tool_results or flow.accumulated_text + should_emit_snapshot = ( + flow.pending_tool_calls or flow.tool_results or flow.accumulated_text or flow.reasoning_messages + ) if should_emit_snapshot: # Check if we should suppress for predictive tool last_tool_name = None diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py index 2e5294a6b6..5e4fced97c 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py @@ -604,6 +604,10 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Mes # Handle standard tool result messages early (role="tool") to preserve provider invariants # This path maps AG‑UI tool messages to function_result content with the correct tool_call_id role_str = normalize_agui_role(msg.get("role", "user")) + if role_str == "reasoning": + # Reasoning messages are UI-only state carried in MESSAGES_SNAPSHOT. + # They should not be forwarded to the LLM provider. + continue if role_str == "tool": # Prefer explicit tool_call_id fields; fall back to backend fields only if necessary tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId") @@ -1020,6 +1024,11 @@ def agui_messages_to_snapshot_format(messages: list[dict[str, Any]]) -> list[dic elif "toolCallId" not in normalized_msg: normalized_msg["toolCallId"] = "" + # Normalize encrypted_value to encryptedValue for reasoning messages + if normalized_msg.get("role") == "reasoning" and "encrypted_value" in normalized_msg: + normalized_msg["encryptedValue"] = normalized_msg["encrypted_value"] + del normalized_msg["encrypted_value"] + result.append(normalized_msg) return result diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py index 0a9f4cea9c..155f559a94 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py @@ -126,6 +126,8 @@ class FlowState: tool_results: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] tool_calls_ended: set[str] = field(default_factory=set) # pyright: ignore[reportUnknownVariableType] interrupts: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + reasoning_messages: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + accumulated_reasoning: dict[str, str] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType] def get_tool_name(self, call_id: str | None) -> str | None: """Get tool name by call ID.""" @@ -460,7 +462,7 @@ def _emit_mcp_tool_result( return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler) -def _emit_text_reasoning(content: Content) -> list[BaseEvent]: +def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> list[BaseEvent]: """Emit AG-UI reasoning events for text_reasoning content. Uses the protocol-defined reasoning event types so that AG-UI consumers @@ -470,6 +472,10 @@ def _emit_text_reasoning(content: Content) -> list[BaseEvent]: ``content.protected_data`` is present it is emitted as a ``ReasoningEncryptedValueEvent`` so that consumers can persist encrypted reasoning for state continuity without conflating it with display text. + + When *flow* is provided the reasoning message is persisted into + ``flow.reasoning_messages`` so that ``_build_messages_snapshot`` can + include it in the final ``MESSAGES_SNAPSHOT``. """ text = content.text or "" if not text and content.protected_data is None: @@ -498,6 +504,36 @@ def _emit_text_reasoning(content: Content) -> list[BaseEvent]: events.append(ReasoningEndEvent(message_id=message_id)) + # Persist reasoning into flow state for MESSAGES_SNAPSHOT. + # Accumulate reasoning text per message_id, similar to flow.accumulated_text, + # so that incremental deltas build the full reasoning string. + if flow is not None: + if text: + previous_text = flow.accumulated_reasoning.get(message_id, "") + flow.accumulated_reasoning[message_id] = previous_text + text + full_text = flow.accumulated_reasoning.get(message_id, text or "") + + # Update existing reasoning entry for this message_id if present; otherwise append a new one. + existing_entry: dict[str, Any] | None = None + for entry in flow.reasoning_messages: + if isinstance(entry, dict) and entry.get("id") == message_id: + existing_entry = entry + break + + if existing_entry is None: + reasoning_entry: dict[str, Any] = { + "id": message_id, + "role": "reasoning", + "content": full_text, + } + if content.protected_data is not None: + reasoning_entry["encryptedValue"] = content.protected_data + flow.reasoning_messages.append(reasoning_entry) + else: + existing_entry["content"] = full_text + if content.protected_data is not None: + existing_entry["encryptedValue"] = content.protected_data + return events @@ -527,6 +563,6 @@ def _emit_content( if content_type == "mcp_server_tool_result": return _emit_mcp_tool_result(content, flow, predictive_handler) if content_type == "text_reasoning": - return _emit_text_reasoning(content) + return _emit_text_reasoning(content, flow) logger.debug("Skipping unsupported content type in AG-UI emitter: %s", content_type) return [] diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_utils.py b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py index bfda3948ec..c68301f7d2 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_utils.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py @@ -27,7 +27,7 @@ FRAMEWORK_TO_AGUI_ROLE: dict[str, str] = { "system": "system", } -ALLOWED_AGUI_ROLES: set[str] = {"user", "assistant", "system", "tool"} +ALLOWED_AGUI_ROLES: set[str] = {"user", "assistant", "system", "tool", "reasoning"} def generate_event_id() -> str: @@ -82,7 +82,7 @@ def normalize_agui_role(raw_role: Any) -> str: raw_role: Raw role value from AG-UI message Returns: - Normalized role string (user, assistant, system, or tool) + Normalized role string (user, assistant, system, tool, or reasoning) """ if not isinstance(raw_role, str): return "user" diff --git a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py index cc4f1230df..9508b53085 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py +++ b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py @@ -1669,3 +1669,94 @@ def test_agui_fresh_approval_is_still_processed(): assert len(approval_contents) == 1, "Fresh approval should produce function_approval_response" assert approval_contents[0].approved is True assert approval_contents[0].function_call.name == "get_datetime" + + +class TestReasoningRoundTrip: + """Tests for reasoning message handling in inbound/outbound adapters.""" + + def test_reasoning_skipped_on_inbound(self): + """Reasoning messages from prior snapshot are not forwarded to the LLM.""" + messages_input = [ + {"id": "u1", "role": "user", "content": "Hello"}, + {"id": "r1", "role": "reasoning", "content": "Thinking..."}, + {"id": "a1", "role": "assistant", "content": "Hi there"}, + ] + + result = agui_messages_to_agent_framework(messages_input) + + roles = [m.role if hasattr(m.role, "value") else str(m.role) for m in result] + assert "reasoning" not in roles + assert len(result) == 2 + + def test_reasoning_preserved_in_snapshot_format(self): + """Reasoning messages retain their role through snapshot normalization.""" + messages_input = [ + {"id": "u1", "role": "user", "content": "Hello"}, + {"id": "r1", "role": "reasoning", "content": "Thinking about this..."}, + {"id": "a1", "role": "assistant", "content": "Answer"}, + ] + + result = agui_messages_to_snapshot_format(messages_input) + + reasoning_msgs = [m for m in result if m.get("role") == "reasoning"] + assert len(reasoning_msgs) == 1 + assert reasoning_msgs[0]["content"] == "Thinking about this..." + + def test_reasoning_with_encrypted_value_in_snapshot_format(self): + """Reasoning with encryptedValue passes through snapshot normalization.""" + messages_input = [ + { + "id": "r1", + "role": "reasoning", + "content": "visible", + "encryptedValue": "secret-data", + }, + ] + + result = agui_messages_to_snapshot_format(messages_input) + + assert len(result) == 1 + assert result[0]["role"] == "reasoning" + assert result[0]["encryptedValue"] == "secret-data" + + def test_reasoning_encrypted_value_snake_case_normalized(self): + """Snake-case encrypted_value is normalized to encryptedValue in snapshot format.""" + messages_input = [ + { + "id": "r1", + "role": "reasoning", + "content": "visible", + "encrypted_value": "snake-case-data", + }, + ] + + result = agui_messages_to_snapshot_format(messages_input) + + assert len(result) == 1 + assert result[0]["encryptedValue"] == "snake-case-data" + assert "encrypted_value" not in result[0] + + def test_multi_turn_with_reasoning_in_prior_snapshot(self): + """Second turn with reasoning from prior snapshot does not corrupt messages.""" + messages_input = [ + {"id": "u1", "role": "user", "content": "First question"}, + {"id": "r1", "role": "reasoning", "content": "Prior reasoning"}, + {"id": "a1", "role": "assistant", "content": "First answer"}, + {"id": "u2", "role": "user", "content": "Follow-up question"}, + ] + + result = agui_messages_to_agent_framework(messages_input) + + roles = [m.role if hasattr(m.role, "value") else str(m.role) for m in result] + # Reasoning is filtered out, other messages preserved in order + assert roles == ["user", "assistant", "user"] + # Content not corrupted + texts = [] + for m in result: + for c in m.contents or []: + if hasattr(c, "text") and c.text: + texts.append(c.text) + assert "First question" in texts + assert "First answer" in texts + assert "Follow-up question" in texts + assert "Prior reasoning" not in texts diff --git a/python/packages/ag-ui/tests/ag_ui/test_run.py b/python/packages/ag-ui/tests/ag_ui/test_run.py index ae8c5e85b0..0e5c329ce9 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run.py @@ -1346,3 +1346,158 @@ class TestEmitContentMcpRouting: assert len(events) == 5 assert isinstance(events[0], ReasoningStartEvent) + + +class TestReasoningInSnapshot: + """Tests for reasoning message inclusion in MESSAGES_SNAPSHOT.""" + + def test_reasoning_persisted_to_flow_state(self): + """_emit_text_reasoning with flow persists reasoning into flow.reasoning_messages.""" + flow = FlowState() + content = Content.from_text_reasoning( + id="reason_persist", + text="Let me think step by step.", + ) + + _emit_text_reasoning(content, flow) + + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["id"] == "reason_persist" + assert flow.reasoning_messages[0]["role"] == "reasoning" + assert flow.reasoning_messages[0]["content"] == "Let me think step by step." + assert "encryptedValue" not in flow.reasoning_messages[0] + + def test_reasoning_with_encrypted_value_persisted(self): + """Reasoning with protected_data preserves encryptedValue in flow state.""" + flow = FlowState() + content = Content.from_text_reasoning( + id="reason_enc", + text="visible reasoning", + protected_data="encrypted-data-123", + ) + + _emit_text_reasoning(content, flow) + + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["encryptedValue"] == "encrypted-data-123" + + def test_snapshot_includes_reasoning(self): + """_build_messages_snapshot includes reasoning messages from flow state.""" + from agent_framework_ag_ui._agent_run import _build_messages_snapshot + + flow = FlowState() + flow.accumulated_text = "Here is my answer." + flow.reasoning_messages = [ + {"id": "r1", "role": "reasoning", "content": "Thinking..."}, + ] + + snapshot = _build_messages_snapshot(flow, []) + + roles = [m.get("role") if isinstance(m, dict) else getattr(m, "role", None) for m in snapshot.messages] + assert "reasoning" in roles + + def test_snapshot_preserves_reasoning_encrypted_value(self): + """Snapshot reasoning with encryptedValue is preserved end-to-end.""" + from agent_framework_ag_ui._agent_run import _build_messages_snapshot + + flow = FlowState() + content = Content.from_text_reasoning( + id="reason_e2e", + text="visible", + protected_data="secret-data", + ) + _emit_text_reasoning(content, flow) + + text_content = Content.from_text("Final answer.") + _emit_text(text_content, flow) + + snapshot = _build_messages_snapshot(flow, []) + + reasoning_msgs = [ + m + for m in snapshot.messages + if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) == "reasoning" + ] + assert len(reasoning_msgs) == 1 + msg = reasoning_msgs[0] + if isinstance(msg, dict): + assert msg["content"] == "visible" + assert msg["encryptedValue"] == "secret-data" + + def test_emit_content_routes_reasoning_with_flow(self): + """_emit_content passes flow to _emit_text_reasoning for persistence.""" + flow = FlowState() + content = Content.from_text_reasoning(text="routed reasoning") + + _emit_content(content, flow) + + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["content"] == "routed reasoning" + + def test_reasoning_without_flow_does_not_error(self): + """Calling _emit_text_reasoning without flow still works (backward compat).""" + content = Content.from_text_reasoning(text="no flow") + + events = _emit_text_reasoning(content) + + assert len(events) == 5 + assert isinstance(events[0], ReasoningStartEvent) + + def test_snapshot_reasoning_ordering(self): + """Reasoning messages appear after assistant text in snapshot.""" + from agent_framework_ag_ui._agent_run import _build_messages_snapshot + + flow = FlowState() + reasoning_content = Content.from_text_reasoning(id="r1", text="Thinking...") + _emit_text_reasoning(reasoning_content, flow) + + text_content = Content.from_text("Answer") + _emit_text(text_content, flow) + + snapshot = _build_messages_snapshot(flow, [{"id": "u1", "role": "user", "content": "Hi"}]) + + # user -> assistant text -> reasoning + assert len(snapshot.messages) == 3 + roles = [m.get("role") if isinstance(m, dict) else getattr(m, "role", None) for m in snapshot.messages] + assert roles == ["user", "assistant", "reasoning"] + + def test_reasoning_accumulates_incremental_deltas(self): + """Multiple reasoning deltas with the same id accumulate into one entry.""" + flow = FlowState() + content1 = Content.from_text_reasoning(id="reason_inc", text="First ") + content2 = Content.from_text_reasoning(id="reason_inc", text="second ") + content3 = Content.from_text_reasoning(id="reason_inc", text="third.") + + _emit_text_reasoning(content1, flow) + _emit_text_reasoning(content2, flow) + _emit_text_reasoning(content3, flow) + + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["id"] == "reason_inc" + assert flow.reasoning_messages[0]["content"] == "First second third." + + def test_reasoning_accumulates_distinct_message_ids(self): + """Reasoning entries with different ids are stored separately.""" + flow = FlowState() + content_a = Content.from_text_reasoning(id="a", text="alpha") + content_b = Content.from_text_reasoning(id="b", text="beta") + + _emit_text_reasoning(content_a, flow) + _emit_text_reasoning(content_b, flow) + + assert len(flow.reasoning_messages) == 2 + assert flow.reasoning_messages[0]["content"] == "alpha" + assert flow.reasoning_messages[1]["content"] == "beta" + + def test_reasoning_encrypted_value_updated_on_later_delta(self): + """encryptedValue is set even when it arrives with a later delta.""" + flow = FlowState() + content1 = Content.from_text_reasoning(id="enc_late", text="part1 ") + content2 = Content.from_text_reasoning(id="enc_late", text="part2", protected_data="encrypted-payload") + + _emit_text_reasoning(content1, flow) + _emit_text_reasoning(content2, flow) + + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["content"] == "part1 part2" + assert flow.reasoning_messages[0]["encryptedValue"] == "encrypted-payload" diff --git a/python/packages/ag-ui/tests/ag_ui/test_utils.py b/python/packages/ag-ui/tests/ag_ui/test_utils.py index 0f453132f7..f353d2f0a7 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_utils.py +++ b/python/packages/ag-ui/tests/ag_ui/test_utils.py @@ -450,6 +450,7 @@ def test_normalize_agui_role_valid(): assert normalize_agui_role("assistant") == "assistant" assert normalize_agui_role("system") == "system" assert normalize_agui_role("tool") == "tool" + assert normalize_agui_role("reasoning") == "reasoning" def test_normalize_agui_role_invalid(): From efb14cedb12b8e7f7b868921ddec1713e9e21972 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 26 Mar 2026 08:33:19 +0100 Subject: [PATCH 11/31] Python: Support structuredContent in MCP tool results and fix sampling options type (#4763) * Support MCP sampling tools capability (#4625) Forward systemPrompt, tools, and toolChoice from MCP sampling requests to the chat client's get_response() call. Also advertise the sampling.tools capability to MCP servers when a client is configured. - Pass SamplingCapability with tools support to ClientSession - Convert systemPrompt to instructions in options - Convert MCP Tool objects to FunctionTool instances for options - Map MCP ToolChoice.mode to tool_choice in options - Add tests for all new behaviors and update existing sampling tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix #4625: Support MCP sampling tool with proper typing and structured content - Fix mypy error by typing sampling callback options as ChatOptions[None] instead of dict[str, Any], and importing ChatOptions from _types - Handle structuredContent from CallToolResult in _parse_tool_result_from_mcp, serializing it as JSON text Content when present - Add tests for structuredContent parsing (with and without regular content) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix lint: add author to TODO comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4625: remove default=str, add edge-case tests - Remove default=str from json.dumps for structuredContent to fail fast on non-JSON-serializable values instead of silently converting - Add test for non-JSON-serializable structuredContent (TypeError) - Add tests for empty systemPrompt ('') and empty tools list ([]) edge cases in sampling callback - Expand TODO comment noting list[Content] return type constraint for future result_type support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Sanitize sampling callback error to avoid leaking internals (#4625) Log exception details at DEBUG level instead of including them in the ErrorData message returned to the MCP server, which may be untrusted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4625: move params to options, restore error info - Remove stale TODO comment about response_format (ChatOptions already has it) - Restore {ex} in sampling callback error message for useful debugging info - Set structuredContent as additional_property on Content for structured access - Move temperature, max_tokens, stop into options dict (not top-level kwargs) - Only set temperature when provided (not all models support it) - Add tests for generation params in options and temperature omission Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix MCP sampling callback and structured content error handling (#4625) - Guard max_tokens like temperature: only set when not None, so options can properly evaluate to None when all params are absent - Wrap json.dumps of structuredContent in try/except to fall back to str() for non-serializable values instead of propagating TypeError - Extract test_connect_sampling_capabilities_with_client into its own test function so pytest can discover it independently - Add test for max_tokens=None omission from options - Update structured content non-serializable test to expect fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4625: review comment fixes * Fix MCP and Azure validation regressions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/agent_framework/_mcp.py | 41 +- python/packages/core/tests/core/test_mcp.py | 366 +++++++++++++++++- .../openai/agent_framework_openai/_shared.py | 4 +- .../packages/openai/tests/openai/conftest.py | 2 + ...est_openai_chat_completion_client_azure.py | 3 +- 5 files changed, 409 insertions(+), 7 deletions(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index c7db6177da..267e176ee8 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -18,7 +18,11 @@ from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast from opentelemetry import propagate from ._tools import FunctionTool -from ._types import Content, Message +from ._types import ( + ChatOptions, + Content, + Message, +) from .exceptions import ToolException, ToolExecutionException if sys.version_info >= (3, 11): @@ -640,6 +644,7 @@ class MCPTool: raise ToolException(error_msg, inner_exception=ex) from ex try: try: + from mcp import types from mcp.client.session import ClientSession as runtime_client_session except ModuleNotFoundError as ex: await self._safe_close_exit_stack() @@ -647,6 +652,12 @@ class MCPTool: "MCP support requires `mcp`. Please install `mcp`.", inner_exception=ex, ) from ex + + sampling_capabilities = None + if self.client is not None: + sampling_capabilities = types.SamplingCapability( + tools=types.SamplingToolsCapability(), + ) session = await self._exit_stack.enter_async_context( runtime_client_session( read_stream=transport[0], @@ -657,6 +668,7 @@ class MCPTool: message_handler=self.message_handler, logging_callback=self.logging_callback, sampling_callback=self.sampling_callback, + sampling_capabilities=sampling_capabilities, ) ) except Exception as ex: @@ -733,14 +745,35 @@ class MCPTool: messages: list[Message] = [] for msg in params.messages: messages.append(self._parse_message_from_mcp(msg)) + + options: ChatOptions[None] = {} + if params.systemPrompt is not None: + options["instructions"] = params.systemPrompt + if params.tools is not None: + options["tools"] = [ + FunctionTool( + name=tool.name, + description=tool.description or "", + input_model=tool.inputSchema, + ) + for tool in params.tools + ] + if params.toolChoice is not None and params.toolChoice.mode is not None: + options["tool_choice"] = params.toolChoice.mode + + if params.temperature is not None: + options["temperature"] = params.temperature + options["max_tokens"] = params.maxTokens + if params.stopSequences is not None: + options["stop"] = params.stopSequences + try: response = await self.client.get_response( messages, - temperature=params.temperature, - max_tokens=params.maxTokens, - stop=params.stopSequences, + options=options or None, ) except Exception as ex: + logger.debug("Sampling callback error: %s", ex, exc_info=True) return types.ErrorData( code=types.INTERNAL_ERROR, message=f"Failed to get chat message content: {ex}", diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 3c340a13b8..eb233eea99 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -1696,12 +1696,15 @@ async def test_mcp_tool_sampling_callback_chat_client_exception(): params.temperature = None params.maxTokens = None params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None result = await tool.sampling_callback(Mock(), params) assert isinstance(result, types.ErrorData) assert result.code == types.INTERNAL_ERROR - assert "Failed to get chat message content: Chat client error" in result.message + assert "Failed to get chat message content" in result.message async def test_mcp_tool_sampling_callback_no_valid_content(): @@ -1739,6 +1742,9 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): params.temperature = None params.maxTokens = None params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None result = await tool.sampling_callback(Mock(), params) @@ -1757,6 +1763,9 @@ async def test_mcp_tool_sampling_callback_no_response_and_successful_message_cre params.temperature = None params.maxTokens = None params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None tool.client.get_response.return_value = None no_response = await tool.sampling_callback(Mock(), params) @@ -1787,6 +1796,361 @@ async def test_mcp_tool_logging_callback_logs_at_requested_level() -> None: mock_log.assert_called_once_with(logging.WARNING, "be careful") +async def test_mcp_tool_sampling_callback_forwards_system_prompt(): + """Test sampling callback passes systemPrompt as instructions in options.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + params.systemPrompt = "You are a helpful assistant" + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options.get("instructions") == "You are a helpful assistant" + + +async def test_mcp_tool_sampling_callback_forwards_tools(): + """Test sampling callback converts MCP tools to FunctionTools and passes them in options.""" + from agent_framework import FunctionTool, Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + mcp_tool = types.Tool( + name="get_weather", + description="Get weather", + inputSchema={"type": "object", "properties": {"city": {"type": "string"}}}, + ) + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + params.systemPrompt = None + params.tools = [mcp_tool] + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + tools = options.get("tools") + assert tools is not None + assert len(tools) == 1 + assert isinstance(tools[0], FunctionTool) + assert tools[0].name == "get_weather" + assert tools[0].description == "Get weather" + + +async def test_mcp_tool_sampling_callback_forwards_tool_choice(): + """Test sampling callback passes toolChoice mode in options.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = types.ToolChoice(mode="required") + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options.get("tool_choice") == "required" + + +async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt(): + """Test sampling callback forwards empty string systemPrompt as instructions.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + params.systemPrompt = "" + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options.get("instructions") == "" + + +async def test_mcp_tool_sampling_callback_forwards_empty_tools_list(): + """Test sampling callback forwards empty tools list in options.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + params.systemPrompt = None + params.tools = [] + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options.get("tools") == [] + + +async def test_mcp_tool_sampling_callback_forwards_generation_params_in_options(): + """Test sampling callback passes temperature, max_tokens, and stop in options.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = 0.7 + params.maxTokens = 256 + params.stopSequences = ["STOP"] + params.systemPrompt = None + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options.get("temperature") == 0.7 + assert options.get("max_tokens") == 256 + assert options.get("stop") == ["STOP"] + # These should not be passed as top-level kwargs + assert "temperature" not in call_kwargs.kwargs + assert "max_tokens" not in call_kwargs.kwargs + assert "stop" not in call_kwargs.kwargs + + +async def test_mcp_tool_sampling_callback_omits_temperature_when_none(): + """Test sampling callback does not set temperature in options when it is None.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = 100 + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert "temperature" not in options + assert options.get("max_tokens") == 100 + assert "stop" not in options + + +async def test_mcp_tool_sampling_callback_always_passes_max_tokens(): + """Test sampling callback always sets max_tokens in options since maxTokens is a required int field.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = 200 + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options["max_tokens"] == 200 + + +async def test_connect_sampling_capabilities_with_client(): + """Test connect() passes sampling_capabilities to ClientSession when client is set.""" + tool = MCPStdioTool(name="test", command="test-command", load_tools=False, load_prompts=False) + tool.client = Mock() + + mock_transport = (Mock(), Mock()) + mock_context_manager = Mock() + mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport) + mock_context_manager.__aexit__ = AsyncMock(return_value=None) + tool.get_mcp_client = Mock(return_value=mock_context_manager) + + with patch("mcp.client.session.ClientSession") as mock_session_class: + mock_session = AsyncMock() + mock_session._request_id = 1 + + session_cm = AsyncMock() + session_cm.__aenter__ = AsyncMock(return_value=mock_session) + session_cm.__aexit__ = AsyncMock(return_value=None) + mock_session_class.return_value = session_cm + + await tool.connect() + + call_kwargs = mock_session_class.call_args.kwargs + sampling_caps = call_kwargs.get("sampling_capabilities") + assert sampling_caps is not None + assert isinstance(sampling_caps, types.SamplingCapability) + assert sampling_caps.tools is not None + assert isinstance(sampling_caps.tools, types.SamplingToolsCapability) + + +async def test_connect_no_sampling_capabilities_without_client(): + """Test connect() does not pass sampling_capabilities when no client is set.""" + tool = MCPStdioTool(name="test", command="test-command", load_tools=False, load_prompts=False) + # No client set + + mock_transport = (Mock(), Mock()) + mock_context_manager = Mock() + mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport) + mock_context_manager.__aexit__ = AsyncMock(return_value=None) + tool.get_mcp_client = Mock(return_value=mock_context_manager) + + with patch("mcp.client.session.ClientSession") as mock_session_class: + mock_session = AsyncMock() + mock_session._request_id = 1 + + session_cm = AsyncMock() + session_cm.__aenter__ = AsyncMock(return_value=mock_session) + session_cm.__aexit__ = AsyncMock(return_value=None) + mock_session_class.return_value = session_cm + + await tool.connect() + + call_kwargs = mock_session_class.call_args.kwargs + assert call_kwargs.get("sampling_capabilities") is None + + # Test error handling in connect() method diff --git a/python/packages/openai/agent_framework_openai/_shared.py b/python/packages/openai/agent_framework_openai/_shared.py index 900592da17..c3c280d950 100644 --- a/python/packages/openai/agent_framework_openai/_shared.py +++ b/python/packages/openai/agent_framework_openai/_shared.py @@ -216,7 +216,9 @@ def load_openai_service_settings( openai_settings["model"] = resolved_model break - if not openai_settings.get("api_version"): + if api_version is not None: + openai_settings["api_version"] = api_version + else: resolved_api_version = _get_setting_from_alias( "AZURE_OPENAI_API_VERSION", dotenv_values_by_name=dotenv_values_by_name, diff --git a/python/packages/openai/tests/openai/conftest.py b/python/packages/openai/tests/openai/conftest.py index d2a89a6742..1ef52baf81 100644 --- a/python/packages/openai/tests/openai/conftest.py +++ b/python/packages/openai/tests/openai/conftest.py @@ -48,6 +48,7 @@ def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # "OPENAI_AUDIO_TO_TEXT_MODEL_ID", "OPENAI_TEXT_TO_AUDIO_MODEL_ID", "OPENAI_REALTIME_MODEL_ID", + "OPENAI_API_VERSION", "OPENAI_BASE_URL", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL", @@ -101,6 +102,7 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic "OPENAI_AUDIO_TO_TEXT_MODEL_ID", "OPENAI_TEXT_TO_AUDIO_MODEL_ID", "OPENAI_REALTIME_MODEL_ID", + "OPENAI_API_VERSION", "OPENAI_BASE_URL", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL", diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py index eb27312684..148fcb68b9 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py @@ -79,7 +79,8 @@ def test_init_auto_detects_azure_env(azure_openai_unit_test_env: dict[str, str]) @pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True) -def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None: +def test_init_uses_default_azure_api_version(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_VERSION", "preview") client = _create_azure_chat_completion_client() assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] From bfda595e5651970655cb9597085608984b28bedb Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:10:33 +0000 Subject: [PATCH 12/31] Add ADR to decide consistency of Chat History Persistence (#4816) * Add ADR to decide consitency of Chat History Persistence * Add example * Update ADR with review results * Remove unecessary clarification * Rename ADR to no 22 --- ...22-chat-history-persistence-consistency.md | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 docs/decisions/0022-chat-history-persistence-consistency.md diff --git a/docs/decisions/0022-chat-history-persistence-consistency.md b/docs/decisions/0022-chat-history-persistence-consistency.md new file mode 100644 index 0000000000..2c760e6614 --- /dev/null +++ b/docs/decisions/0022-chat-history-persistence-consistency.md @@ -0,0 +1,116 @@ +--- +status: accepted +contact: westey-m +date: 2026-03-23 +deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub +consulted: +informed: +--- + +# Chat History Persistence Consistency + +## Context and Problem Statement + +When using `ChatClientAgent` with tools, the `FunctionInvokingChatClient` (FIC) loops multiple times — service call → tool execution → service call → … — before producing a final response. There are two points of discrepancy between how chat history is stored by the framework's `ChatHistoryProvider` and how the underlying AI service stores chat history (e.g., OpenAI Responses with `store=true`): + +1. **Persistence timing**: The AI service persists messages after *each* service call within the FIC loop. The `ChatHistoryProvider` currently persists messages only once, at the *end* of the full agent run (after all FIC loop iterations complete). + +2. **Trailing `FunctionResultContent` storage**: When tool calling is terminated mid-loop (e.g., via `FunctionInvokingChatClient` termination filters), the final response from the agent may contain `FunctionResultContent` that was never sent to a subsequent service call. The AI service never stores this trailing `FunctionResultContent`, but the `ChatHistoryProvider` currently stores all response content, including the trailing `FunctionResultContent`. + +These discrepancies mean that a `ChatHistoryProvider`-managed conversation and a service-managed conversation can diverge in content and structure, even when processing the same interactions. + +### Practical Impact: Resuming After Tool-Call Termination + +Today, users of `AIAgent` get different behaviors depending on whether chat history is stored service-side or in a `ChatHistoryProvider`. This creates concrete challenges — for example, when the function call loop is terminated and the user wants to resume the conversation in a subsequent run. With service-stored history, the trailing `FunctionResultContent` is never persisted, so the last stored message is the `FunctionCallContent` from the service. With `ChatHistoryProvider`-stored history, the trailing `FunctionResultContent` *is* persisted. The user cannot know whether the last `FunctionResultContent` is in the chat history or not without inspecting the storage mechanism, making it difficult to write resumption logic that works correctly regardless of the storage backend. + +### Relationship Between the Two Discrepancies + +The persistence timing and `FunctionResultContent` trimming behaviors are interrelated: + +- **Per-service-call persistence**: When messages are persisted after each individual service call, trailing `FunctionResultContent` trimming is unnecessary. If tool calling is terminated, the `FunctionResultContent` from the terminated call was never sent to a subsequent service call, so it is never persisted. The per-service-call approach naturally matches the service's behavior. + +- **Per-run persistence**: When messages are batched and persisted at the end of the full run, trailing `FunctionResultContent` trimming becomes necessary to match the service's behavior. Without trimming, the stored history contains `FunctionResultContent` that the service would never have stored. + +This means the trimming feature (introduced in [PR #4792](https://github.com/microsoft/agent-framework/pull/4792)) is primarily needed as a complement to per-run persistence. The `PersistChatHistoryAtEndOfRun` setting (introduced in [PR #4762](https://github.com/microsoft/agent-framework/pull/4762)) inverts the default so that per-service-call persistence is the standard behavior, and per-run persistence is opt-in. + +## Decision Drivers + +- **A. Consistency**: The default behavior of `ChatHistoryProvider` should produce stored history that closely matches what the underlying AI service would store, minimizing surprise when switching between framework-managed and service-managed chat history. +- **B. Atomicity**: A run that fails mid-way through a multi-step tool-calling loop should not leave chat history in a partially-updated state, unless the user explicitly opts into that behavior. +- **C. Recoverability**: For long-running tool-calling loops, it should be possible to recover intermediate progress if the process is interrupted, rather than losing all work from the current run. +- **D. Simplicity**: The default behavior should be easy to understand and predict for most users, without requiring knowledge of the FIC loop internals. +- **E. Flexibility**: Regardless of the chosen default, users should be able to opt into the alternative behavior. + +## Considered Options + +- Option 1: Default to per-run persistence with `FunctionResultContent` trimming (opt-in to per-service-call) +- Option 2: Default to per-service-call persistence (opt-in to per-run) + +## Pros and Cons of the Options + +### Option 1: Default to per-run persistence with `FunctionResultContent` trimming + +Keep the current default behavior of persisting chat history only at the end of the full agent run. Add `FunctionResultContent` trimming as the default to improve consistency with service storage. Provide an opt-in setting for users who want per-service-call persistence. + +Settings: +- `PersistChatHistoryAtEndOfRun` = `true` + +- Good, because runs are atomic — chat history is only updated when the full run succeeds, satisfying driver B. +- Good, because the mental model is simple: one run = one history update, satisfying driver D. +- Good, because trimming trailing `FunctionResultContent` improves consistency with service storage, partially satisfying driver A. +- Good, because users can opt in to per-service-call persistence for checkpointing/recovery scenarios, satisfying drivers C and E. +- Bad, because the default persistence timing still differs from the service's behavior (per-run vs. per-service-call), only partially satisfying driver A. +- Bad, because if the process crashes mid-loop, all intermediate progress from the current run is lost, not satisfying driver C by default. + +### Option 2: Default to per-service-call persistence + +Change the default to persist chat history after each individual service call within the FIC loop, matching the AI service's behavior. Trailing `FunctionResultContent` trimming is unnecessary with this approach (it is naturally handled). Provide an opt-in setting for users who want per-run atomicity with trimming. + +Settings: +- `PersistChatHistoryAtEndOfRun` = `false` (default) + +- Good, because the stored history matches the service's behavior by default for both timing and content, fully satisfying driver A. +- Good, because intermediate progress is preserved if the process is interrupted, satisfying driver C. +- Good, because no separate `FunctionResultContent` trimming logic is needed, reducing complexity. +- Bad, because chat history may be left in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), not satisfying driver B. A subsequent run cannot proceed without manually providing the missing `FunctionResultContent`. +- Bad, because the mental model is more complex: a single run may produce multiple history updates, partially failing driver D. +- Neutral, because users can opt out to per-run persistence if they prefer atomicity, satisfying driver E. + +## Decision Outcome + +Chosen option: **Option 2 — Default to per-service-call persistence**, because it fully satisfies the consistency driver (A), naturally handles `FunctionResultContent` trimming without additional logic, and provides better recoverability for long-running tool-calling loops. Per-run persistence remains available via the `PersistChatHistoryAtEndOfRun` setting for users who prefer atomic run semantics. + +### Configuration Matrix + +The behavior depends on the combination of `UseProvidedChatClientAsIs` and `PersistChatHistoryAtEndOfRun`: + +| `UseProvidedChatClientAsIs` | `PersistChatHistoryAtEndOfRun` | Behavior | +|---|---|---| +| `false` (default) | `false` (default) | **Per-service-call persistence.** A `ChatHistoryPersistingChatClient` middleware is automatically injected into the chat client pipeline between `FunctionInvokingChatClient` and the leaf `IChatClient`. Messages are persisted after each service call. | +| `true` | `false` | **User responsibility.** No middleware is injected because the user has provided a custom chat client stack. The user is responsible for ensuring correct persistence behavior (e.g., by including their own persisting middleware). | +| `false` | `true` | **Per-run persistence with marking.** A `ChatHistoryPersistingChatClient` middleware is injected, but configured to *mark* messages with metadata rather than store them immediately. At the end of the run, marked messages are stored. Trailing `FunctionResultContent` is trimmed. | +| `true` | `true` | **Per-run persistence with warning.** The system checks whether the custom chat client stack includes a `ChatHistoryPersistingChatClient`. If not, a warning is emitted (particularly relevant for workflow handoff scenarios where trimming cannot be guaranteed). If no `ChatHistoryPersistingChatClient` is preset, all messages are stored at the end of the run, otherwise marked messages are stored. | + +### Consequences + +- Good, because the stored history matches the service's behavior by default for both timing and content, fully satisfying consistency (driver A). +- Good, because intermediate progress is preserved if the process is interrupted, satisfying recoverability (driver C). +- Good, because no separate `FunctionResultContent` trimming logic is needed in the default path, reducing complexity. +- Good, because marking persisted messages with metadata enables deduplication and aids debugging. +- Good, because warnings for custom chat client configurations without the persisting middleware help prevent silent failures in workflow handoff scenarios. +- Bad, because chat history may be left in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), requiring manual recovery in rare cases. +- Bad, because the mental model is more complex for the default path: a single run may produce multiple history updates. +- Neutral, because users who prefer atomic run semantics can opt in to per-run persistence via `PersistChatHistoryAtEndOfRun = true`. +- Neutral, because increased write frequency from per-service-call persistence may impact performance for some storage backends; this can be mitigated with a caching decorator. + +### Implementation Notes + +#### Conversation ID Consistency + +The `ChatHistoryPersistingChatClient` middleware must also update the session's `ConversationId` consistently for both response-based and conversation-based service interactions, ensuring the session always reflects the latest service-provided identifier. + +## More Information + +- [PR #4762: Persist messages during function call loop](https://github.com/microsoft/agent-framework/pull/4762) — introduces `PersistChatHistoryAfterEachServiceCall` option and `ChatHistoryPersistingChatClient` decorator +- [PR #4792: Trim final FRC to match service storage](https://github.com/microsoft/agent-framework/pull/4792) — introduces `StoreFinalFunctionResultContent` option and `FilterFinalFunctionResultContent` logic +- [Issue #2889](https://github.com/microsoft/agent-framework/issues/2889) — original issue tracking chat history persistence during function call loops From 6626565f7a6041eb8e81c9ded17181f6182a495a Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:19:09 +0000 Subject: [PATCH 13/31] ADR to support a multi-source architecture for agent skills (#4787) * add adr suggesting a new design to support a multi-source architecture for agent skills * add deciders * move the adr to the decisions folder * remove unnecessary section * describe adding a custom skill source * update * address comments * add constructor overloads to inline skill resource and script * consider ai-function as an alternative for skill script and skill resource model classes * update decision outcome section and sync adr with latest changes in the code --- docs/decisions/0021-agent-skills-design.md | 960 +++++++++++++++++++++ 1 file changed, 960 insertions(+) create mode 100644 docs/decisions/0021-agent-skills-design.md diff --git a/docs/decisions/0021-agent-skills-design.md b/docs/decisions/0021-agent-skills-design.md new file mode 100644 index 0000000000..d63f38c734 --- /dev/null +++ b/docs/decisions/0021-agent-skills-design.md @@ -0,0 +1,960 @@ +status: proposed +date: 2026-03-23 +contact: sergeymenshykh +deciders: rbarreto, westey-m, eavanvalkenburg +--- + +# Agent Skills: Multi-Source Architecture + +## Context and Problem Statement + +The Agent Framework needs a skills system that lets agents discover and use domain-specific knowledge, reference documents, and executable scripts. Skills can originate from different sources — filesystem directories (SKILL.md files), inline C# code, or reusable class libraries — and the framework must support all three uniformly while allowing extensibility, composition, and filtering. + +## Decision Drivers + +- Skills must be definable from multiple sources: filesystem, inline code, reusable classes, etc +- Common abstractions are needed so the provider and builder work uniformly regardless of skill origin +- File-based scripts must support user-defined executors, enabling custom runtimes and languages; code/class-based scripts execute in-process as C# delegates +- Skills must be filterable so consumers can include or exclude specific skills based on defined criteria +- Multiple skill sources must be composable into a single provider +- It must be possible to add custom skill sources (e.g., databases, REST APIs, package registries) by implementing a common abstraction + +## Architecture + +### Model-Facing Tools + +Skills are presented to the model as up to three tools that progressively disclose skill content. The system prompt lists available skill names and descriptions; the model then calls these tools on demand: + +- **`load_skill(skillName)`** — returns the full skill body (instructions, listed resources, listed scripts) +- **`read_skill_resource(skillName, resourceName)`** — reads a supplementary resource (file-based or code-defined) associated with a skill +- **`run_skill_script(skillName, scriptName, arguments?)`** — executes a script associated with a skill; only registered when at least one skill contains scripts + +Each tool delegates to the corresponding method on the resolved `AgentSkill` — calling `Resource.ReadAsync()` or `Script.RunAsync()` respectively. + +If skills have no scripts defined, the `run_skill_script` tool is **not advertised** to the model and instructions related to script execution are **not included** in the default skills instructions. + +### Abstract Base Types + +The architecture defines four abstract base types that all skill variants implement: + +```csharp +public abstract class AgentSkill +{ + public abstract AgentSkillFrontmatter Frontmatter { get; } + public abstract string Content { get; } + public abstract IReadOnlyList? Resources { get; } + public abstract IReadOnlyList? Scripts { get; } +} + +public abstract class AgentSkillResource +{ + public string Name { get; } + public string? Description { get; } + public abstract Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default); +} + +public abstract class AgentSkillScript +{ + public string Name { get; } + public string? Description { get; } + public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); +} + +public abstract class AgentSkillsSource +{ + public abstract Task> GetSkillsAsync(CancellationToken cancellationToken = default); +} +``` + +Skill metadata is captured via `AgentSkillFrontmatter`: + +```csharp +public sealed class AgentSkillFrontmatter +{ + public AgentSkillFrontmatter(string name, string description) { ... } + + public string Name { get; } + public string Description { get; } + public string? License { get; set; } + public string? Compatibility { get; set; } + public string? AllowedTools { get; set; } + public AdditionalPropertiesDictionary? Metadata { get; set; } +} +``` + +The type hierarchy at a glance: + +``` +AgentSkill (abstract) AgentSkillsSource (abstract) +├── AgentFileSkill ├── AgentFileSkillsSource (public) +└── [Programmatic] ├── AgentInMemorySkillsSource (public) + ├── AgentInlineSkill ├── AggregatingAgentSkillsSource (public) + └── AgentClassSkill (abstract) └── DelegatingAgentSkillsSource (abstract, public) + ├── FilteringAgentSkillsSource (public) +AgentSkillResource (abstract) ├── CachingAgentSkillsSource (public) +├── AgentFileSkillResource └── DeduplicatingAgentSkillsSource (public) +└── AgentInlineSkillResource + AgentSkillScript (abstract) + ├── AgentFileSkillScript + └── AgentInlineSkillScript +``` + +There are two top-level categories of skills: + +1. **File-Based Skills** — discovered from `SKILL.md` files on the filesystem. Resources and scripts are files in subdirectories. +2. **Programmatic Skills** — defined in C# code. These are further divided into: + - **Inline Skills** — built at runtime via the `AgentInlineSkill` class and its fluent API. Ideal for quick, agent-specific skill definitions. + - **Class-Based Skills** — defined as reusable C# classes that subclass `AgentClassSkill`. Ideal for packaging skills as shared libraries or NuGet packages. + +Both programmatic skill types use `AgentInlineSkillResource` and `AgentInlineSkillScript` for their resources and scripts. They are typically served by `AgentInMemorySkillsSource`, which accepts any `AgentSkill` and is not limited to programmatic skills. + +### File-Based Skills + +File-based skills are authored as `SKILL.md` files on disk. Resources and scripts are discovered from corresponding subfolders within the skill directory. + +**`AgentFileSkill`** — A filesystem-based skill discovered from a directory containing a `SKILL.md` file. Parsed from YAML frontmatter; content is the raw markdown body. Resources and scripts are discovered from files in corresponding subfolders: + +```csharp +public sealed class AgentFileSkill : AgentSkill +{ + internal AgentFileSkill( + AgentSkillFrontmatter frontmatter, string content, string path, + IReadOnlyList? resources = null, + IReadOnlyList? scripts = null) { ... } +} +``` + +**`AgentFileSkillResource`** — A file-based skill resource. Reads content from a file on disk relative to the skill directory: + +```csharp +internal sealed class AgentFileSkillResource : AgentSkillResource +{ + public AgentFileSkillResource(string name, string fullPath) { ... } + + public string FullPath { get; } + + public override Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { + return File.ReadAllTextAsync(FullPath, Encoding.UTF8, cancellationToken); + } +} +``` + +**`AgentFileSkillScript`** — A file-based skill script that represents a script file on disk. Delegates execution to an external `AgentFileSkillScriptRunner` callback (e.g., runs Python/shell via `Process.Start`). Throws `NotSupportedException` if no executor is configured: + +```csharp +public delegate Task AgentFileSkillScriptRunner( + AgentFileSkill skill, AgentFileSkillScript script, + AIFunctionArguments arguments, CancellationToken cancellationToken); + +public sealed class AgentFileSkillScript : AgentSkillScript +{ + private readonly AgentFileSkillScriptRunner _executor; + + internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptRunner executor) + : base(name) { ... } + + public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, ...) + { + + return await _executor(fileSkill, this, arguments, cancellationToken); + } +} +``` + +The executor can be provided at the **provider level** via `AgentSkillsProviderBuilder.UseFileScriptRunner(executor)` and optionally overridden for a **particular file skill** or for a **set of skills** at the file skill source level, giving fine-grained control over how different scripts are executed. + +**`AgentFileSkillsSource`** — A skill source that discovers skills from filesystem directories containing `SKILL.md` files. Recursively scans directories (max 2 levels), validates frontmatter, and enforces path traversal and symlink security checks: + +```csharp +public sealed partial class AgentFileSkillsSource : AgentSkillsSource +{ + public AgentFileSkillsSource( + IEnumerable skillPaths, + AgentFileSkillScriptRunner scriptRunner, + AgentFileSkillsSourceOptions? options = null, + ILoggerFactory? loggerFactory = null) { ... } +} +``` + +**`AgentFileSkillsSourceOptions`** — Configuration options for `AgentFileSkillsSource`. Allows customizing the allowed file extensions for resources and scripts without adding constructor parameters: + +```csharp +public sealed class AgentFileSkillsSourceOptions +{ + public IEnumerable? AllowedResourceExtensions { get; set; } + public IEnumerable? AllowedScriptExtensions { get; set; } +} +``` + +**Example** — A file-based skill on disk and how it is added to a source: + +``` +skills/ +└── unit-converter/ + ├── SKILL.md # frontmatter + instructions + ├── resources/ + │ └── conversion-table.csv # discovered as a resource + └── scripts/ + └── convert.py # discovered as a script +``` + +```csharp +var source = new AgentFileSkillsSource(skillPaths: ["./skills"], scriptRunner: SubprocessScriptRunner.RunAsync); + +var provider = new AgentSkillsProvider(source); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +### Programmatic Skills + +Programmatic skills are defined in C# code rather than discovered from the filesystem. There are two kinds: **inline** and **class-based**. Both use `AgentInlineSkillResource` and `AgentInlineSkillScript` for resources and scripts, and are held by a single `AgentInMemorySkillsSource`. + +**`AgentInMemorySkillsSource`** — A general-purpose skill source that holds any `AgentSkill` instances in memory. Although commonly used for programmatic skills (`AgentInlineSkill` and `AgentClassSkill`), it accepts any `AgentSkill` subclass and is not restricted to code-defined skills: + +```csharp +public sealed class AgentInMemorySkillsSource : AgentSkillsSource +{ + public AgentInMemorySkillsSource( + IEnumerable skills, + ILoggerFactory? loggerFactory = null) { ... } +} +``` + +#### Inline Skills + +Inline skills are built at runtime via the `AgentInlineSkill` class and its fluent API. They are ideal for quick, agent-specific skill definitions where a full class hierarchy would be overkill. + +**`AgentInlineSkill`** — A skill defined entirely in code. Resources can be static values or functions; scripts are always functions. Constructed with name, description, and instructions, then extended with resources and scripts: + +```csharp +public sealed class AgentInlineSkill : AgentSkill +{ + public AgentInlineSkill(string name, string description, string instructions, string? license = null, string? compatibility = null, ...) { ... } + public AgentInlineSkill(AgentSkillFrontmatter frontmatter, string instructions) { ... } + + public AgentInlineSkill AddResource(object value, string name, string? description = null); + public AgentInlineSkill AddResource(Delegate handler, string name, string? description = null); + public AgentInlineSkill AddScript(Delegate handler, string name, string? description = null); +} +``` + +**`AgentInlineSkillResource`** — A skill resource that wraps a static value: + +```csharp +public sealed class AgentInlineSkillResource : AgentSkillResource +{ + public AgentInlineSkillResource(object value, string name, string? description = null) + : base(name, description) + { + _value = value; + } + + public override Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(_value); + } +} +``` + +**`AgentInlineSkillResource`** — A skill resource backed by a delegate. The delegate is invoked via an `AIFunction` each time `ReadAsync` is called, producing a dynamic (computed) value: + +```csharp +public sealed class AgentInlineSkillResource : AgentSkillResource +{ + public AgentInlineSkillResource(Delegate handler, string name, string? description = null) + : base(name, description) + { + _function = AIFunctionFactory.Create(handler, name: name); + } + + public override async Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { + return await _function.InvokeAsync(new AIFunctionArguments() { Services = serviceProvider }, cancellationToken); + } +} +``` + +**`AgentInlineSkillScript`** — A skill script backed by a delegate via an `AIFunction`: + +```csharp +public sealed class AgentInlineSkillScript : AgentSkillScript +{ + private readonly AIFunction _function; + + public AgentInlineSkillScript(Delegate handler, string name, string? description = null) + : base(name, description) + { + _function = AIFunctionFactory.Create(handler, name: name); + } + + public JsonElement? ParametersSchema => _function.JsonSchema; + + public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, ...) + { + return await _function.InvokeAsync(arguments, cancellationToken); + } +} +``` + +**Example** — Creating an inline skill with a resource and script, then adding it to a source: + +```csharp +var skill = new AgentInlineSkill( + name: "unit-converter", + description: "Converts between measurement units.", + instructions: """ + Use this skill to convert values between metric and imperial units. + Refer to the conversion-table resource for supported unit pairs. + Run the convert script to perform conversions. + """ + ) + .AddResource("kg=2.205lb, m=3.281ft, L=0.264gal", "conversion-table", "Supported unit pairs") + .AddScript(Convert, "convert", "Converts a value between units"); + +var source = new AgentInMemorySkillsSource([skill]); + +var provider = new AgentSkillsProvider(source); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); + +static string Convert(double value, double factor) + => JsonSerializer.Serialize(new { result = Math.Round(value * factor, 4) }); +``` + +#### Class-Based Skills + +Class-based skills are designed for packaging skills as reusable libraries. Users subclass `AgentClassSkill` and override properties. Unlike inline skills, class-based skills are self-contained, can live in shared libraries or NuGet packages, and are well-suited for dependency injection. + +**`AgentClassSkill`** — An abstract base class for defining skills as reusable C# classes that bundle all skill components (frontmatter, instructions, resources, scripts) together. Designed for packaging skills as distributable libraries: + +```csharp +public abstract class AgentClassSkill : AgentSkill +{ + public abstract string Instructions { get; } + + // Content is auto-synthesized from Frontmatter + Instructions + Resources + Scripts + public override string Content => + SkillContentBuilder.BuildContent(Frontmatter.Name, Frontmatter.Description, + SkillContentBuilder.BuildBody(Instructions, Resources, Scripts)); +} +``` + +**Example** — Defining a class-based skill and adding it to a source: + +```csharp +public class UnitConverterSkill : AgentClassSkill +{ + public override AgentSkillFrontmatter Frontmatter { get; } = + new("unit-converter", "Converts between measurement units."); + + public override string Instructions => """ + Use this skill to convert values between metric and imperial units. + Refer to the conversion-table resource for supported unit pairs. + Run the convert script to perform conversions. + """; + + public override IReadOnlyList? Resources { get; } = + [ + new AgentInlineSkillResource("kg=2.205lb, m=3.281ft", "conversion-table"), + ]; + + public override IReadOnlyList? Scripts { get; } = + [ + new AgentInlineSkillScript(Convert, "convert"), + ]; + + private static string Convert(double value, double factor) + => JsonSerializer.Serialize(new { result = Math.Round(value * factor, 4) }); +} + +var source = new AgentInMemorySkillsSource([new UnitConverterSkill()]); + +var provider = new AgentSkillsProvider(source); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +## Filtering, Caching, and Deduplication + +The following subsections present alternative approaches for handling filtering, caching, and deduplication of skills across multiple sources. + +### Via Composition + +In this approach, the `AgentSkillsProvider` accepts a **single** `AgentSkillsSource`. Multiple sources are composed externally via an aggregate source, and cross-cutting concerns like filtering, caching, and deduplication are implemented as **source decorators** — subclasses of `DelegatingAgentSkillsSource` that intercept `GetSkillsAsync()`. + +**`FilteringAgentSkillsSource`** — A decorator that applies filter logic before returning results. The decorator pattern keeps filtering orthogonal to source implementations and allows composing multiple filters: + +```csharp +public sealed class FilteringAgentSkillsSource : DelegatingAgentSkillsSource +{ + private readonly Func _predicate; + + public FilteringAgentSkillsSource(AgentSkillsSource innerSource, Func predicate) + : base(innerSource) + { + _predicate = predicate; + } + + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var skills = await this.InnerSource.GetSkillsAsync(cancellationToken); + return skills.Where(_predicate).ToList(); + } +} +``` + +**`CachingAgentSkillsSource`** — A decorator that caches skills after the first load, keeping the provider stateless and giving consumers control over caching granularity per source. For example, file-based skills (expensive to discover) can be cached while code-defined skills remain uncached: + +```csharp +public sealed class CachingAgentSkillsSource : DelegatingAgentSkillsSource +{ + private IList? _cached; + + public CachingAgentSkillsSource(AgentSkillsSource innerSource) + : base(innerSource) + { + } + + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + return _cached ??= await this.InnerSource.GetSkillsAsync(cancellationToken); + } +} +``` + +**Deduplication** is similarly implemented as a decorator (`DeduplicatingAgentSkillsSource`) that deduplicates by name (case-insensitive, first-one-wins) and logs a warning for skipped duplicates. + +**Example** — Combining file-based and code-defined sources with filtering and caching: + +```csharp +var fileSource = new CachingAgentSkillsSource(new AgentFileSkillsSource(["./skills"])); +var codeSource = new AgentInMemorySkillsSource([myCodeSkill]); + +var compositeSource = new FilteringAgentSkillsSource( + new AggregatingAgentSkillsSource([fileSource, codeSource]), + filter: s => s.Frontmatter.Name != "internal"); + +var provider = new AgentSkillsProvider(compositeSource); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +**Pros:** +- Clean single-responsibility: the provider serves skills, sources provide them. +- Caching, filtering, and deduplication are composable as source decorators — each concern is a separate, testable wrapper. + +**Cons:** +- DI is less flexible: multiple `AgentSkillsSource` implementations registered in the container cannot be auto-injected into the provider. The consumer must manually compose them via an aggregate source. +- Increased public API surface: requires additional public classes (aggregate source, caching decorators, filtering decorators) that consumers need to learn and use. + +### Via AgentSkillsProvider + +In this approach, the `AgentSkillsProvider` accepts **`IEnumerable`** and handles aggregation, filtering, caching, and deduplication internally. + +The provider aggregates skills from all registered sources, deduplicates by name (case-insensitive, first-one-wins), caches the result after the first load, and optionally applies filtering via a predicate on `AgentSkillsProviderOptions`. Duplicate skill names are logged as warnings. + +**Example** — Registering multiple sources directly with the provider: + +```csharp +// Conceptual example — in practice, use AgentSkillsProviderBuilder +var fileSource = new AgentFileSkillsSource(["./skills"]); +var codeSource = new AgentInMemorySkillsSource([myCodeSkill]); + +var provider = new AgentSkillsProvider( + sources: [fileSource, codeSource], + options: new AgentSkillsProviderOptions + { + Filter = s => s.Frontmatter.Name != "internal", + }); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +**Pros:** +- DI-friendly: register multiple `AgentSkillsSource` implementations in the container, and they are all auto-injected into `AgentSkillsProvider` via `IEnumerable`. +- Smaller public API surface: no need for aggregate source, caching decorators, or filtering decorator classes — these concerns are handled internally by the provider. + +**Cons:** +- The provider takes on multiple responsibilities — aggregation, caching, deduplication, and filtering. +- Less granular caching control: caching is all-or-nothing across sources rather than per-source as with decorators. +- Less extensible: new behaviors (e.g., ordering, TTL expiration) require modifying the provider rather than adding a decorator. + +### Builder Pattern + +**`AgentSkillsProviderBuilder`** provides a fluent API for composing skills from multiple sources. The builder centralizes configuration — script executors, approval callbacks, prompt templates, and filtering — so consumers don't need to know the underlying source types. + +The builder internally decides how to wire up the object graph: it creates the appropriate source instances, applies caching and filtering, and returns a fully configured `AgentSkillsProvider`. This keeps the setup code concise while still allowing fine-grained control when needed. + +**Example** — Using the builder to combine multiple source types with configuration: + +```csharp +var provider = new AgentSkillsProviderBuilder() + .UseFileSkill("./skills") // file-based source + .UseInlineSkills(codeSkill) // code-defined source + .UseClassSkills(new ClassSkill()) // class-based source + .UseFileScriptRunner(SubprocessScriptRunner.RunAsync) // script runner + .UseScriptApproval() // optional human-in-the-loop + .UsePromptTemplate(customTemplate) // optional prompt customization + .UseFilter(s => s.Frontmatter.Name != "internal") // optional skill filtering + .Build(); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +## Adding a Custom Skill Type + +The skills framework is designed for extensibility. While file-based and inline skills cover common +scenarios, you can introduce entirely new skill types by subclassing the four base classes: + +| Base class | Purpose | +|-----------------------|-----------------------------------------------------| +| `AgentSkillsSource` | Discovers and loads skills from a particular origin | +| `AgentSkill` | Holds metadata, content, resources, and scripts | +| `AgentSkillResource` | Provides supplementary content to a skill | +| `AgentSkillScript` | Represents an executable action within a skill | + +The example below implements a **cloud-based skill type** where skills, resources, and scripts are +all stored in and executed through a remote cloud service (e.g., Azure Blob Storage + Azure Functions). + +### Step 1 — Define a custom resource + +A `CloudSkillResource` reads resource content from a cloud storage endpoint instead of the local +filesystem: + +```csharp +/// +/// A skill resource backed by a cloud storage endpoint. +/// +public sealed class CloudSkillResource : AgentSkillResource +{ + private readonly HttpClient _httpClient; + + public CloudSkillResource(string name, Uri blobUri, HttpClient httpClient, string? description = null) + : base(name, description) + { + BlobUri = blobUri ?? throw new ArgumentNullException(nameof(blobUri)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + /// + /// Gets the URI of the cloud blob that holds this resource's content. + /// + public Uri BlobUri { get; } + + /// + public override async Task ReadAsync( + IServiceProvider? serviceProvider = null, + CancellationToken cancellationToken = default) + { + return await _httpClient.GetStringAsync(BlobUri, cancellationToken).ConfigureAwait(false); + } +} +``` + +### Step 2 — Define a custom script + +A `CloudSkillScript` executes a script by calling a cloud function endpoint, passing arguments as +the request body: + +```csharp +/// +/// A skill script executed via a cloud function endpoint. +/// +public sealed class CloudSkillScript : AgentSkillScript +{ + private readonly HttpClient _httpClient; + + public CloudSkillScript(string name, Uri functionUri, HttpClient httpClient, string? description = null) + : base(name, description) + { + FunctionUri = functionUri ?? throw new ArgumentNullException(nameof(functionUri)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + /// + /// Gets the URI of the cloud function that runs this script. + /// + public Uri FunctionUri { get; } + + /// + public override async Task RunAsync( + AgentSkill skill, + AIFunctionArguments arguments, + CancellationToken cancellationToken = default) + { + var json = JsonSerializer.Serialize(arguments); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync(FunctionUri, content, cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } +} +``` + +### Step 3 — Define a custom skill + +A `CloudSkill` bundles cloud-specific metadata (e.g., the base endpoint) with the standard skill +shape: + +```csharp +/// +/// An whose content, resources, and scripts are stored in a cloud service. +/// +public sealed class CloudSkill : AgentSkill +{ + public CloudSkill( + AgentSkillFrontmatter frontmatter, + string content, + Uri endpoint, + IReadOnlyList? resources = null, + IReadOnlyList? scripts = null) + { + Frontmatter = frontmatter ?? throw new ArgumentNullException(nameof(frontmatter)); + Content = content ?? throw new ArgumentNullException(nameof(content)); + Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint)); + Resources = resources; + Scripts = scripts; + } + + /// + public override AgentSkillFrontmatter Frontmatter { get; } + + /// + public override string Content { get; } + + /// + /// Gets the base cloud endpoint for this skill. + /// + public Uri Endpoint { get; } + + /// + public override IReadOnlyList? Resources { get; } + + /// + public override IReadOnlyList? Scripts { get; } +} +``` + +### Step 4 — Define a custom source + +A `CloudSkillsSource` discovers skills from a cloud catalog API and constructs `CloudSkill` +instances with their associated resources and scripts: + +```csharp +/// +/// A skill source that discovers and loads skills from a cloud catalog API. +/// +public sealed class CloudSkillsSource : AgentSkillsSource +{ + private readonly Uri _catalogUri; + private readonly HttpClient _httpClient; + + public CloudSkillsSource(Uri catalogUri, HttpClient httpClient) + { + _catalogUri = catalogUri ?? throw new ArgumentNullException(nameof(catalogUri)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + /// + public override async Task> GetSkillsAsync( + CancellationToken cancellationToken = default) + { + // Fetch the skill catalog from the cloud service. + var json = await _httpClient.GetStringAsync(_catalogUri, cancellationToken) + .ConfigureAwait(false); + var catalog = JsonSerializer.Deserialize(json)!; + + var skills = new List(); + + foreach (var entry in catalog.Skills) + { + var frontmatter = new AgentSkillFrontmatter(entry.Name, entry.Description); + + // Build cloud-backed resources. + var resources = entry.Resources + .Select(r => new CloudSkillResource(r.Name, r.BlobUri, _httpClient, r.Description)) + .ToList(); + + // Build cloud-backed scripts. + var scripts = entry.Scripts + .Select(s => new CloudSkillScript(s.Name, s.FunctionUri, _httpClient, s.Description)) + .ToList(); + + skills.Add(new CloudSkill(frontmatter, entry.Content, entry.Endpoint, resources, scripts)); + } + + return skills; + } +} +``` + +### Step 5 — Register with the builder + +Use `UseSource` to wire the custom source into the provider: + +```csharp +var httpClient = new HttpClient(); + +var provider = new AgentSkillsProviderBuilder() + .UseSource(new CloudSkillsSource( + new Uri("https://my-service.example.com/skills/catalog"), + httpClient)) + // Mix with other source types if needed: + .UseFileSkill("/local/skills", scriptRunner) + .UseInlineSkills(someInlineSkill) + .Build(); +``` + +The `AgentSkillsProvider` handles all skill types uniformly — any combination of file-based, inline, +class-based, and custom skills can coexist in the same provider. Custom skills automatically +participate in the model-facing tools (`load_skill`, `read_skill_resource`, `run_skill_script`), +filtering, deduplication, and caching — no additional integration work is required. + +## Script Representation: `AgentSkillScript` vs `AIFunction` + +Two approaches were considered for representing executable scripts within skills: + +### Option A — Custom `AgentSkillScript` abstract base class (original design) + +Scripts are modeled as a custom `AgentSkillScript` abstract class with `Name`, `Description`, and +`RunAsync(AgentSkill, AIFunctionArguments, CancellationToken)`. Concrete implementations: +`AgentInlineSkillScript` (wraps a delegate/`AIFunction`) and `AgentFileSkillScript` (wraps a file path + executor delegate). + +```csharp +// Base type +public abstract class AgentSkillScript +{ + public string Name { get; } + public string? Description { get; } + public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); +} + +// AgentSkill exposes scripts as: +public abstract IReadOnlyList? Scripts { get; } + +// Inline script wraps an AIFunction internally +var script = new AgentInlineSkillScript(ConvertUnits, "convert"); + +// Pre-built AIFunction must be wrapped +var script = new AgentInlineSkillScript(myAIFunction); + +// Class-based skill declares scripts as: +public override IReadOnlyList? Scripts { get; } = +[ + new AgentInlineSkillScript(ConvertUnits, "convert"), +]; + +// Provider executes scripts by passing the owning skill: +await script.RunAsync(skill, arguments, cancellationToken); +``` + +**Pros:** + +- **Explicit skill context at execution time.** `RunAsync` receives the owning `AgentSkill`, so any script can access skill metadata or resources during execution without requiring construction-time wiring. +- **Self-contained abstraction.** A dedicated type communicates clearly that scripts are a skills-framework concept, separate from general-purpose AI functions. +- **Easier extensibility for custom script types.** Third-party implementations can subclass `AgentSkillScript` and access the owning skill in `RunAsync` without special setup. + +**Cons:** + +- **Wrapper overhead.** `AgentInlineSkillScript` is a thin pass-through around `AIFunction` — it adds a class, a constructor, and an indirection layer for no behavioral difference. +- **Parallel abstraction.** `AgentSkillScript` and `AIFunction` serve overlapping purposes (named callable with arguments), creating two parallel hierarchies for the same concept. +- **Friction for consumers.** Users who already have `AIFunction` instances must wrap them in `AgentInlineSkillScript` to use them as scripts, adding ceremony. + +### Option B — Reuse `AIFunction` directly + +Scripts are represented as `AIFunction` (from `Microsoft.Extensions.AI`). `AgentSkill.Scripts` returns +`IReadOnlyList?`. `AgentInlineSkillScript` is eliminated entirely — callers use +`AIFunctionFactory.Create(delegate, name: ...)` or pass `AIFunction` instances directly. +`AgentFileSkillScript` becomes an `AIFunction` subclass that captures its owning `AgentFileSkill` via +an internal back-reference set during construction. + +```csharp +// AgentSkill exposes scripts as AIFunction directly: +public abstract IReadOnlyList? Scripts { get; } + +// Inline scripts use AIFunctionFactory — no wrapper class needed +var skill = new AgentInlineSkill("my-skill", "desc", "instructions"); +skill.AddScript(ConvertUnits, "convert"); // delegate +skill.AddScript(myAIFunction); // pre-built AIFunction — no wrapping + +// Class-based skill declares scripts as: +public override IReadOnlyList? Scripts { get; } = +[ + AIFunctionFactory.Create(ConvertUnits, name: "convert"), +]; + +// Provider executes scripts via standard AIFunction invocation: +await script.InvokeAsync(arguments, cancellationToken); + +// File-based scripts extend AIFunction and capture the owning skill internally: +public sealed class AgentFileSkillScript : AIFunction +{ + internal AgentFileSkill? Skill { get; set; } // set by AgentFileSkill constructor + + protected override async ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await _executor(Skill!, this, arguments, cancellationToken); + } +} +``` + +**Pros:** + +- **Fewer types.** Eliminates `AgentSkillScript` and `AgentInlineSkillScript`, reducing the public API surface by two classes. +- **Seamless interop.** Any `AIFunction` — whether from `AIFunctionFactory`, a custom subclass, or an external library — can be used as a skill script with zero wrapping. +- **Consistent with `Microsoft.Extensions.AI` ecosystem.** Scripts share the same type as tool functions used by `IChatClient` and `FunctionInvokingChatClient`, reducing conceptual overhead for developers already familiar with the ecosystem. + +**Cons:** + +- **No owning-skill context in invocation signature.** `AIFunction.InvokeAsync` does not accept an `AgentSkill` parameter, so `AgentFileSkillScript` must capture its owning skill via an internal setter during construction. This adds a construction-order dependency: the skill must set the back-reference on its scripts. +- **Custom script types lose automatic skill access.** Third-party `AIFunction` subclasses that need the owning skill must implement their own mechanism (e.g., constructor injection, closure capture) instead of receiving it as a method parameter. +- **Semantic overloading.** `AIFunction` now means both "a tool the model can call" and "a script within a skill", which could blur the distinction for framework users. + +## Resource Representation: `AgentSkillResource` vs `AIFunction` + +Two approaches were considered for representing skill resources (supplementary content such as references, assets, or dynamic data): + +### Option A — Custom `AgentSkillResource` abstract base class (original design) + +Resources are modeled as a custom `AgentSkillResource` abstract class with `Name`, `Description`, and +`ReadAsync(IServiceProvider?, CancellationToken)`. Concrete implementations: +`AgentInlineSkillResource` (static value, delegate, or `AIFunction` wrapper) and `AgentFileSkillResource` (reads file content from disk). + +```csharp +// Base type +public abstract class AgentSkillResource +{ + public string Name { get; } + public string? Description { get; } + public abstract Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default); +} + +// AgentSkill exposes resources as: +public abstract IReadOnlyList? Resources { get; } + +// Static resource +var resource = new AgentInlineSkillResource("static content", "my-resource"); + +// Dynamic resource (delegate) +var resource = new AgentInlineSkillResource((IServiceProvider sp) => GetData(sp), "my-resource"); + +// Pre-built AIFunction must be wrapped +var resource = new AgentInlineSkillResource(myAIFunction); + +// Class-based skill declares resources as: +public override IReadOnlyList? Resources { get; } = +[ + new AgentInlineSkillResource("# Conversion Tables\n...", "conversion-table"), +]; + +// Provider reads resources via: +await resource.ReadAsync(serviceProvider, cancellationToken); +``` + +**Pros:** + +- **Clear semantic distinction.** A dedicated `AgentSkillResource` type distinguishes resources (data providers) from scripts (executable actions), making the API self-documenting. +- **Purpose-built API.** `ReadAsync` communicates intent better than `InvokeAsync` for a data-access operation. + +**Cons:** + +- **Wrapper overhead.** `AgentInlineSkillResource` wraps `AIFunction` internally for delegate/function cases — adding a class and indirection for no behavioral difference. +- **Parallel abstraction.** `AgentSkillResource` and `AIFunction` serve overlapping purposes (named callable that returns data), creating two parallel hierarchies. +- **Friction for consumers.** Users who already have `AIFunction` instances must wrap them in `AgentInlineSkillResource`, adding ceremony. + +### Option B — Reuse `AIFunction` directly + +Resources are represented as `AIFunction`. `AgentSkill.Resources` returns `IReadOnlyList?`. +`AgentInlineSkillResource` becomes an `AIFunction` subclass (retained as a convenience for the static-value +pattern: `new AgentInlineSkillResource("data", "name")`). `AgentFileSkillResource` becomes an `AIFunction` +subclass that reads file content. + +```csharp +// AgentSkill exposes resources as AIFunction directly: +public abstract IReadOnlyList? Resources { get; } + +// Static resource — AgentInlineSkillResource is retained as a convenience AIFunction subclass +var resource = new AgentInlineSkillResource("static content", "my-resource"); + +// Dynamic resource — AgentInlineSkillResource wraps delegate as AIFunction +var resource = new AgentInlineSkillResource((IServiceProvider sp) => GetData(sp), "my-resource"); + +// Pre-built AIFunction can be used directly — no wrapping needed +skill.AddResource(myAIFunction); + +// Class-based skill declares resources as: +public override IReadOnlyList? Resources { get; } = +[ + new AgentInlineSkillResource("# Conversion Tables\n...", "conversion-table"), +]; + +// Provider reads resources via standard AIFunction invocation: +await resource.InvokeAsync(arguments, cancellationToken); + +// File-based resources extend AIFunction directly: +internal sealed class AgentFileSkillResource : AIFunction +{ + public string FullPath { get; } + + protected override async ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await File.ReadAllTextAsync(FullPath, Encoding.UTF8, cancellationToken); + } +} +``` + +**Pros:** + +- **Fewer base types.** Eliminates the `AgentSkillResource` abstract class, reducing the public API surface. +- **Seamless interop.** Any `AIFunction` can be used as a skill resource with zero wrapping. + +**Cons:** + +- **Loss of semantic distinction.** Resources and scripts are now both `AIFunction`, which could make it less obvious which list a function belongs to when reading code. +- **Static values require a wrapper.** Unlike the original `ReadAsync` which could return a stored value directly, `AIFunction.InvokeAsync` implies invocation. `AgentInlineSkillResource` is retained as a convenience subclass to handle the static-value case, so this is not eliminated — just moved to a different class. + +## Decision Outcome + +### 1. Keep `AgentSkillResource` and `AgentSkillScript` (Option A for both sections) + +We are staying with the custom `AgentSkillResource` and `AgentSkillScript` model classes instead of reusing `AIFunction`: + +- **Resources have no parameters.** If a consumer provides an `AIFunction` with parameters, those parameters will never be advertised to the LLM, and the resulting call will fail. +- **Approval breaks for `AIFunction`-based representations.** When a resource or script represented by an `AIFunction` is configured with approval, the second approval invocation will not work correctly. +- **Injecting the owning skill into an `AIFunction`-based script is problematic.** Constructor injection would introduce a circular reference between the skill and the script. An internal property setter is possible but adds coupling. + +### 2. Make all agent skill classes internal + +All agent-skill-related classes are made `internal` to minimize the public API surface while the feature matures. We can reconsider and promote types to `public` later based on community signal. + +This leaves two public entry points: + +- **`AgentSkillsProvider`** — use directly when all skills come from a single source and filtering is not needed. +- **`AgentSkillsProviderBuilder`** — use when mixing skill types or when filtering support is required. + +### 3. Caching at provider level + +Caching of tools and instructions is implemented inside `AgentSkillsProvider` rather than as an external decorator. Recreating tools and instructions on every provider call is wasteful, and a caching decorator sitting outside the provider would not have the information needed to cache them effectively. From 84fe6c46ab89ddfbc21fc5939f3c4d3c42aab6cb Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:19:32 +0000 Subject: [PATCH 14/31] .NET: Add AsIChatClientWithStoredOutputDisabled for ProjectResponsesClient (#4911) * Add AsIChatClientWithStoredOutputDisabled for ProjectResponsesClient Add extension method on ProjectResponsesClient in Microsoft.Agents.AI.AzureAI package (Azure.AI.Extensions.OpenAI namespace) mirroring the existing extension on ResponsesClient in the OpenAI package. This enables Azure AI consumers to disable server-side response storage without depending on the OpenAI package. - New ProjectResponsesClientExtensions class with AsIChatClientWithStoredOutputDisabled - Optional deploymentName parameter (model is no longer required) - Updated OpenAI counterpart doc to remove 'Required' wording for model param - Added unit tests covering null guard, inner client accessibility, StoredOutputEnabled=false, and reasoning encrypted content inclusion/exclusion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Preserve existing RawRepresentationFactory when disabling stored output Address PR review feedback: wrap/chain the existing factory instead of replacing it, so upstream configuration (e.g., deploymentName/model defaults from AsIChatClient) is preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ProjectResponsesClientExtensions.cs | 60 +++++++ .../OpenAIResponseClientExtensions.cs | 2 +- .../ProjectResponsesClientExtensionsTests.cs | 169 ++++++++++++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.AzureAI/ProjectResponsesClientExtensions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/ProjectResponsesClientExtensionsTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/ProjectResponsesClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/ProjectResponsesClientExtensions.cs new file mode 100644 index 0000000000..5a899d5076 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/ProjectResponsesClientExtensions.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; +using OpenAI.Responses; + +namespace Azure.AI.Extensions.OpenAI; + +/// +/// Provides extension methods for +/// to simplify the creation of AI agents that work with Azure AI services. +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public static class ProjectResponsesClientExtensions +{ + /// + /// Gets an for use with this that does not store responses for later retrieval. + /// + /// + /// This corresponds to setting the "store" property in the JSON representation to false. + /// + /// The client. + /// Optional deployment name (model) to use for requests. + /// + /// Includes an encrypted version of reasoning tokens in reasoning item outputs. + /// This enables reasoning items to be used in multi-turn conversations when using the Responses API statelessly + /// (like when the store parameter is set to false, or when an organization is enrolled in the zero data retention program). + /// Defaults to . + /// + /// An that can be used to converse via the that does not store responses for later retrieval. + /// is . + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] + public static IChatClient AsIChatClientWithStoredOutputDisabled(this ProjectResponsesClient responseClient, string? deploymentName = null, bool includeReasoningEncryptedContent = true) + { + return Throw.IfNull(responseClient) + .AsIChatClient(deploymentName) + .AsBuilder() + .ConfigureOptions(x => + { + var previousFactory = x.RawRepresentationFactory; + x.RawRepresentationFactory = state => + { + var responseOptions = previousFactory?.Invoke(state) as CreateResponseOptions ?? new CreateResponseOptions(); + + responseOptions.StoredOutputEnabled = false; + + if (includeReasoningEncryptedContent && + !responseOptions.IncludedProperties.Contains(IncludedResponseProperty.ReasoningEncryptedContent)) + { + responseOptions.IncludedProperties.Add(IncludedResponseProperty.ReasoningEncryptedContent); + } + + return responseOptions; + }; + }) + .Build(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs index 5aee8eb046..4ceff75743 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs @@ -105,7 +105,7 @@ public static class OpenAIResponseClientExtensions /// This corresponds to setting the "store" property in the JSON representation to false. /// /// The client. - /// Optional default model ID to use for requests. Required when using a plain (not via Azure OpenAI). + /// Optional default model ID to use for requests. /// /// Includes an encrypted version of reasoning tokens in reasoning item outputs. /// This enables reasoning items to be used in multi-turn conversations when using the Responses API statelessly diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/ProjectResponsesClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/ProjectResponsesClientExtensionsTests.cs new file mode 100644 index 0000000000..d10ef861c9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/ProjectResponsesClientExtensionsTests.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Reflection; +using Azure.AI.Extensions.OpenAI; +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.AzureAI.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class ProjectResponsesClientExtensionsTests +{ + private static ProjectResponsesClient CreateTestClient() + { + return new ProjectResponsesClient(new FakeAuthenticationTokenProvider()); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled throws ArgumentNullException when client is null. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_WithNullClient_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => + ((ProjectResponsesClient)null!).AsIChatClientWithStoredOutputDisabled()); + + Assert.Equal("responseClient", exception.ParamName); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled wraps the original ProjectResponsesClient, + /// which remains accessible via the service chain. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_InnerResponsesClientIsAccessible() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(); + + // Assert - the inner ProjectResponsesClient should be accessible via GetService + var innerClient = chatClient.GetService(); + Assert.NotNull(innerClient); + Assert.Same(responseClient, innerClient); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent false + /// wraps the original ProjectResponsesClient, which remains accessible via the service chain. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningFalse_InnerResponsesClientIsAccessible() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: false); + + // Assert - the inner ProjectResponsesClient should be accessible via GetService + var innerClient = chatClient.GetService(); + Assert.NotNull(innerClient); + Assert.Same(responseClient, innerClient); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled with default parameter (includeReasoningEncryptedContent = true) + /// configures StoredOutputEnabled to false and includes ReasoningEncryptedContent in IncludedProperties. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_Default_ConfiguresStoredOutputDisabledWithReasoningEncryptedContent() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(); + + // Assert + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient); + Assert.NotNull(createResponseOptions); + Assert.False(createResponseOptions.StoredOutputEnabled); + Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent explicitly set to true + /// configures StoredOutputEnabled to false and includes ReasoningEncryptedContent in IncludedProperties. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningTrue_ConfiguresStoredOutputDisabledWithReasoningEncryptedContent() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: true); + + // Assert + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient); + Assert.NotNull(createResponseOptions); + Assert.False(createResponseOptions.StoredOutputEnabled); + Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent set to false + /// configures StoredOutputEnabled to false and does not include ReasoningEncryptedContent in IncludedProperties. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningFalse_ConfiguresStoredOutputDisabledWithoutReasoningEncryptedContent() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: false); + + // Assert + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient); + Assert.NotNull(createResponseOptions); + Assert.False(createResponseOptions.StoredOutputEnabled); + Assert.DoesNotContain(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled works with an optional deployment name. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_WithDeploymentName_ConfiguresStoredOutputDisabled() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(deploymentName: "my-deployment"); + + // Assert + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient); + Assert.NotNull(createResponseOptions); + Assert.False(createResponseOptions.StoredOutputEnabled); + Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); + } + + /// + /// Extracts the produced by the ConfigureOptions pipeline + /// by using reflection to access the configure action and invoking it on a test . + /// + private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient) + { + var configureField = chatClient.GetType().GetField("_configureOptions", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(configureField); + + var configureAction = configureField.GetValue(chatClient) as Action; + Assert.NotNull(configureAction); + + var options = new ChatOptions(); + configureAction(options); + + Assert.NotNull(options.RawRepresentationFactory); + return options.RawRepresentationFactory(chatClient) as CreateResponseOptions; + } +} From d2977d63dad06f06c01504a7b4b7312cf176779b Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:35:16 +0000 Subject: [PATCH 15/31] .NET: Add integration test validating OpenAPI tools with AsAIAgent(agentVersion) (#4931) * .NET: Add integration test for OpenAPI tools with AsAIAgent(agentVersion) Validates end-to-end flow creating a Foundry agent with an OpenAPI tool definition via native Azure.AI.Projects SDK types and wrapping it with AsAIAgent(agentVersion). The test confirms the server-side OpenAPI function is invoked correctly through RunAsync. Addresses #4883 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review: RetryFact, PascalCase naming, stronger tool assertion - Use RetryFact with Skip for manual testing (flaky due to external API) - Fix agentName -> AgentName to match PascalCase convention in file - Strengthen tool invocation assertion: require >= 3 Eurozone countries - Add comment explaining server-side OpenAPI tools don't surface as FunctionCallContent in the MEAI abstraction Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AIProjectClientCreateTests.cs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs index e1cef1f1aa..425197853e 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs @@ -188,6 +188,134 @@ public class AIProjectClientCreateTests } } + /// + /// Validates that an agent version created with an OpenAPI tool definition via the native + /// Azure.AI.Projects SDK and then wrapped with AsAIAgent(agentVersion) correctly + /// invokes the server-side OpenAPI function through RunAsync. + /// Regression test for https://github.com/microsoft/agent-framework/issues/4883. + /// + [RetryFact(Constants.RetryCount, Constants.RetryDelay, Skip = "For manual testing only")] + public async Task AsAIAgent_WithOpenAPITool_NativeSDKCreation_InvokesServerSideToolAsync() + { + // Arrange — create agent version with OpenAPI tool using native Azure.AI.Projects SDK types. + string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("OpenAPITestAgent"); + const string AgentInstructions = "You are a helpful assistant that can use the countries API to retrieve information about countries by their currency code."; + + const string CountriesOpenApiSpec = """ + { + "openapi": "3.1.0", + "info": { + "title": "REST Countries API", + "description": "Retrieve information about countries by currency code", + "version": "v3.1" + }, + "servers": [ + { + "url": "https://restcountries.com/v3.1" + } + ], + "paths": { + "/currency/{currency}": { + "get": { + "description": "Get countries that use a specific currency code (e.g., USD, EUR, GBP)", + "operationId": "GetCountriesByCurrency", + "parameters": [ + { + "name": "currency", + "in": "path", + "description": "Currency code (e.g., USD, EUR, GBP)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response with list of countries", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "404": { + "description": "No countries found for the currency" + } + } + } + } + } + } + """; + + // Step 1: Create the OpenAPI function definition and agent version using native SDK types. + var openApiFunction = new OpenApiFunctionDefinition( + "get_countries", + BinaryData.FromString(CountriesOpenApiSpec), + new OpenAPIAnonymousAuthenticationDetails()) + { + Description = "Retrieve information about countries by currency code" + }; + + var definition = new PromptAgentDefinition(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) + { + Instructions = AgentInstructions, + Tools = { (ResponseTool)AgentTool.CreateOpenApiTool(openApiFunction) } + }; + + AgentVersionCreationOptions creationOptions = new(definition); + AgentVersion agentVersion = await this._client.Agents.CreateAgentVersionAsync(AgentName, creationOptions); + + try + { + // Step 2: Wrap the agent version using AsAIAgent extension. + ChatClientAgent agent = this._client.AsAIAgent(agentVersion); + + // Assert the agent was created correctly and retains version metadata. + Assert.NotNull(agent); + Assert.Equal(AgentName, agent.Name); + var retrievedVersion = agent.GetService(); + Assert.NotNull(retrievedVersion); + + // Step 3: Call RunAsync to trigger the server-side OpenAPI function. + var result = await agent.RunAsync("What countries use the Euro (EUR) as their currency? Please list them."); + + // Step 4: Validate the OpenAPI tool was invoked server-side. + // Note: Server-side OpenAPI tools (executed within the Responses API via AgentReference) + // do not surface as FunctionCallContent in the MEAI abstraction — the API handles the full + // tool loop internally. We validate tool invocation by asserting the response contains + // multiple specific country names that the model would need API data to enumerate accurately. + var text = result.ToString(); + Assert.NotEmpty(text); + + // The response must mention multiple well-known Eurozone countries — requiring several + // correct entries makes it highly unlikely the model answered purely from parametric knowledge. + int matchCount = 0; + foreach (var country in new[] { "Germany", "France", "Italy", "Spain", "Portugal", "Netherlands", "Belgium", "Austria", "Ireland", "Finland" }) + { + if (text.Contains(country, StringComparison.OrdinalIgnoreCase)) + { + matchCount++; + } + } + + Assert.True( + matchCount >= 3, + $"Expected response to list at least 3 Eurozone countries from the OpenAPI tool, but found {matchCount}. Response: {text}"); + } + finally + { + // Cleanup. + await this._client.Agents.DeleteAgentAsync(AgentName); + } + } + [Theory] [InlineData("CreateWithChatClientAgentOptionsAsync")] public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism) From 9691c9c27100bb9de5ff721d78dc9e7473f6060e Mon Sep 17 00:00:00 2001 From: AkiKurisu <2683987717@qq.com> Date: Fri, 27 Mar 2026 00:13:00 +0800 Subject: [PATCH 16/31] .NET: Fix role assignment in ChatMessage construction (#4290) * Use actual message role when creating ChatMessage Replace hard-coded ChatRole.User with a ChatRole constructed from the message's Role. The change ensures ToChatMessage and FunctionMessage use the original role (new ChatRole(this.Role)) for both text and contents branches, fixing incorrect role assignment when constructing ChatMessage instances. * Update changes * Fix formatting in ToChatMessage tests --------- Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --- .../Models/ChatCompletionRequestMessage.cs | 10 +- ...pletionRequestMessageToChatMessageTests.cs | 115 ++++++++++++++++++ 2 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ChatCompletionRequestMessageToChatMessageTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs index 3e9483c616..2433eacbe0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs @@ -39,14 +39,16 @@ internal abstract record ChatCompletionRequestMessage /// Thrown when the content is neither text nor AI contents. public virtual ChatMessage ToChatMessage() { + var role = new ChatRole(this.Role); + if (this.Content.IsText) { - return new(ChatRole.User, this.Content.Text); + return new(role, this.Content.Text); } else if (this.Content.IsContents) { var aiContents = this.Content.Contents.Select(MessageContentPartConverter.ToAIContent).Where(c => c is not null).ToList(); - return new ChatMessage(ChatRole.User, aiContents!); + return new ChatMessage(role, aiContents!); } throw new InvalidOperationException("MessageContent has no value"); @@ -165,9 +167,11 @@ internal sealed record FunctionMessage : ChatCompletionRequestMessage /// Thrown when the content is not text. public override ChatMessage ToChatMessage() { + var role = new ChatRole(this.Role); + if (this.Content.IsText) { - return new(ChatRole.User, this.Content.Text); + return new(role, this.Content.Text); } throw new InvalidOperationException("FunctionMessage Content must be text"); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ChatCompletionRequestMessageToChatMessageTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ChatCompletionRequestMessageToChatMessageTests.cs new file mode 100644 index 0000000000..406f0a32e1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ChatCompletionRequestMessageToChatMessageTests.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Text.Json; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Tests for ChatCompletionRequestMessage.ToChatMessage() role preservation. +/// Verifies that each message type correctly maps its role to the corresponding ChatRole. +/// +public sealed class ChatCompletionRequestMessageToChatMessageTests +{ + [Theory] + [InlineData("system", """{"role":"system","content":"You are a helpful assistant."}""")] + [InlineData("developer", """{"role":"developer","content":"Follow these rules."}""")] + [InlineData("user", """{"role":"user","content":"Hello!"}""")] + [InlineData("assistant", """{"role":"assistant","content":"Hi there!"}""")] + [InlineData("tool", """{"role":"tool","content":"result","tool_call_id":"call_123"}""")] + public void ToChatMessage_PreservesRole_ForTextContent(string expectedRole, string json) + { + // Arrange + ChatCompletionRequestMessage message = JsonSerializer.Deserialize( + json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!; + + // Act + ChatMessage chatMessage = message.ToChatMessage(); + + // Assert + Assert.Equal(expectedRole, message.Role); + Assert.Equal(new ChatRole(expectedRole), chatMessage.Role); + } + + [Fact] + public void ToChatMessage_FunctionMessage_PreservesRole() + { + // Arrange + const string Json = """{"role":"function","name":"get_weather","content":"sunny"}"""; + ChatCompletionRequestMessage message = JsonSerializer.Deserialize( + Json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!; + + // Act + ChatMessage chatMessage = message.ToChatMessage(); + + // Assert + Assert.Equal("function", message.Role); + Assert.Equal(new ChatRole("function"), chatMessage.Role); + } + + [Theory] + [InlineData("system")] + [InlineData("developer")] + [InlineData("user")] + [InlineData("assistant")] + public void ToChatMessage_PreservesRole_ForMultiPartContent(string expectedRole) + { + // Arrange + string json = $$"""{"role":"{{expectedRole}}","content":[{"type":"text","text":"Hello!"}]}"""; + ChatCompletionRequestMessage message = JsonSerializer.Deserialize( + json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!; + + // Act + ChatMessage chatMessage = message.ToChatMessage(); + + // Assert + Assert.Equal(expectedRole, message.Role); + Assert.Equal(new ChatRole(expectedRole), chatMessage.Role); + } + + [Fact] + public void ToChatMessage_MultiTurnConversation_PreservesAllRoles() + { + // Arrange - simulate a multi-turn conversation + string[] jsons = + [ + """{"role":"system","content":"You are a helpful assistant."}""", + """{"role":"user","content":"Hello!"}""", + """{"role":"assistant","content":"Hi there! How can I help?"}""", + """{"role":"user","content":"What did I just say?"}""" + ]; + + string[] expectedRoles = ["system", "user", "assistant", "user"]; + + // Act + ChatMessage[] chatMessages = jsons + .Select(j => JsonSerializer.Deserialize( + j, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!) + .Select(m => m.ToChatMessage()) + .ToArray(); + + // Assert + Assert.Equal(expectedRoles.Length, chatMessages.Length); + for (int i = 0; i < expectedRoles.Length; i++) + { + Assert.Equal(new ChatRole(expectedRoles[i]), chatMessages[i].Role); + } + } + + [Fact] + public void ToChatMessage_PreservesTextContent() + { + // Arrange + const string Json = """{"role":"system","content":"You are a helpful assistant."}"""; + ChatCompletionRequestMessage message = JsonSerializer.Deserialize( + Json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!; + + // Act + ChatMessage chatMessage = message.ToChatMessage(); + + // Assert + Assert.Contains(chatMessage.Contents, c => c is TextContent tc && tc.Text == "You are a helpful assistant."); + } +} From 3b8b56e6ea86a6d351248826f2097ac0f5bcecdb Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:45:01 +0000 Subject: [PATCH 17/31] Samples fix (#4932) --- .../Program.cs | 20 ++++++++++++++---- .../Agent_Step08_UsingImages.csproj | 6 ++++++ .../Assets/walkway.jpg | Bin 0 -> 37970 bytes .../Agent_Step08_UsingImages/Program.cs | 2 +- .../Program.cs | 2 +- 5 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Assets/walkway.jpg diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs index 603f8b8e7b..a8dc73839a 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs @@ -73,16 +73,28 @@ foreach (ClientResult result in getConversationItemsResults.GetRawPages()) using JsonDocument getConversationItemsResultAsJson = JsonDocument.Parse(result.GetRawResponse().Content.ToString()); foreach (JsonElement element in getConversationItemsResultAsJson.RootElement.GetProperty("data").EnumerateArray()) { + // Skip non-message items (e.g. tool calls, reasoning) that lack a "role" property + if (!element.TryGetProperty("role"u8, out var roleElement)) + { + continue; + } + string messageId = element.GetProperty("id"u8).ToString(); - string messageRole = element.GetProperty("role"u8).ToString(); + string messageRole = roleElement.ToString(); Console.WriteLine($" Message ID: {messageId}"); Console.WriteLine($" Message Role: {messageRole}"); - foreach (var content in element.GetProperty("content").EnumerateArray()) + if (element.TryGetProperty("content"u8, out var contentElement)) { - string messageContentText = content.GetProperty("text"u8).ToString(); - Console.WriteLine($" Message Text: {messageContentText}"); + foreach (var content in contentElement.EnumerateArray()) + { + if (content.TryGetProperty("text"u8, out var textElement)) + { + Console.WriteLine($" Message Text: {textElement}"); + } + } } + Console.WriteLine(); } } diff --git a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj index 73a41005f1..2b01c47354 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj +++ b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj @@ -16,5 +16,11 @@ + + + + Always + + diff --git a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Assets/walkway.jpg b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Assets/walkway.jpg new file mode 100644 index 0000000000000000000000000000000000000000..13ef1e184087e7ac9fdb086d523a2bac486ce3f4 GIT binary patch literal 37970 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 literal 0 HcmV?d00001 diff --git a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs index 984a9e3b5c..08e5b63139 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs @@ -22,7 +22,7 @@ var agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential( ChatMessage message = new(ChatRole.User, [ new TextContent("What do you see in this image?"), - new UriContent("https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", "image/jpeg") + await DataContent.LoadFromAsync("Assets/walkway.jpg"), ]); var session = await agent.CreateSessionAsync(); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs index d44d62df51..d810c8046a 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs @@ -24,7 +24,7 @@ AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: VisionName, model ChatMessage message = new(ChatRole.User, [ new TextContent("What do you see in this image?"), - await DataContent.LoadFromAsync("assets/walkway.jpg"), + await DataContent.LoadFromAsync("Assets/walkway.jpg"), ]); AgentSession session = await agent.CreateSessionAsync(); From 63dee91a5fa08eab6e6c92009a613d117ba0b3aa Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Thu, 26 Mar 2026 10:33:22 -0700 Subject: [PATCH 18/31] .NET: Improve observability sample (#4917) * Improve .Net observability sample * Update dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs b/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs index 69d71e7b88..8e1f4245b6 100644 --- a/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs +++ b/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs @@ -18,6 +18,7 @@ using OpenTelemetry.Trace; #region Setup Telemetry +// Source name for this sample's custom ActivitySource and Meter; other instrumentation uses their own sources/categories. const string SourceName = "OpenTelemetryAspire.ConsoleApp"; const string ServiceName = "AgentOpenTelemetry"; @@ -40,7 +41,6 @@ var resource = ResourceBuilder.CreateDefault() var tracerProviderBuilder = Sdk.CreateTracerProviderBuilder() .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0")) .AddSource(SourceName) // Our custom activity source - .AddSource("*Microsoft.Agents.AI") // Agent Framework telemetry .AddHttpClientInstrumentation() // Capture HTTP calls to OpenAI .AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint)); @@ -54,8 +54,7 @@ using var tracerProvider = tracerProviderBuilder.Build(); // Setup metrics with resource and instrument name filtering using var meterProvider = Sdk.CreateMeterProviderBuilder() .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0")) - .AddMeter(SourceName) // Our custom meter - .AddMeter("*Microsoft.Agents.AI") // Agent Framework metrics + .AddMeter(SourceName) // Our custom meter source .AddHttpClientInstrumentation() // HTTP client metrics .AddRuntimeInstrumentation() // .NET runtime metrics .AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint)) @@ -128,7 +127,7 @@ var agent = new ChatClientAgent(instrumentedChatClient, instructions: "You are a helpful assistant that provides concise and informative responses.", tools: [AIFunctionFactory.Create(GetWeatherAsync)]) .AsBuilder() - .UseOpenTelemetry(SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level + .UseOpenTelemetry(sourceName: SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level .Build(); var session = await agent.CreateSessionAsync(); From 3585581c7a6415db08832736b1d7a09cae6fcf22 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:45:46 +0000 Subject: [PATCH 19/31] .NET: Fix bug with per-service-call persistence and approvals (#4933) * Fix bug with per-service-call persistence and approvals * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ChatClient/ChatClientAgent.cs | 27 ++ .../ChatHistoryPersistingChatClient.cs | 38 +++ .../ChatClient/ChatClientAgentTestHelper.cs | 259 +++++++++++++++ .../ChatClient/ChatClientAgentTests.cs | 23 +- .../ChatClientAgent_ApprovalsTests.cs | 306 ++++++++++++++++++ ...tClientAgent_ChatHistoryManagementTests.cs | 154 +++++++++ ...ChatClientAgent_ChatOptionsMergingTests.cs | 13 +- .../ChatHistoryPersistingChatClientTests.cs | 167 ++++++++++ 8 files changed, 971 insertions(+), 16 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 6722bd8738..05caff4d83 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -788,6 +788,13 @@ public sealed partial class ChatClientAgent : AIAgent chatOptions.ConversationId = typedSession.ConversationId; } + // When per-service-call persistence is active, set a sentinel conversation ID so that + // FunctionInvokingChatClient treats locally-persisted history the same as service-managed + // history. This prevents it from adding duplicate FunctionCallContent messages into the + // request when processing approval responses — the loaded history already contains them. + // ChatHistoryPersistingChatClient strips the sentinel before forwarding to the inner client. + chatOptions = this.SetLocalHistoryConversationIdIfNeeded(chatOptions); + // Materialize the accumulated messages once at the end of the provider pipeline, reusing the existing list if possible. List messagesList = inputMessagesForChatClient as List ?? inputMessagesForChatClient.ToList(); @@ -929,6 +936,26 @@ public sealed partial class ChatClientAgent : AIAgent } } + /// + /// Sets the sentinel on + /// when per-service-call persistence is active and no real + /// conversation ID is present. + /// + /// + /// The (possibly new) with the sentinel set, or the original + /// if no sentinel is needed. + /// + private ChatOptions? SetLocalHistoryConversationIdIfNeeded(ChatOptions? chatOptions) + { + if (this.PersistsChatHistoryPerServiceCall && string.IsNullOrWhiteSpace(chatOptions?.ConversationId)) + { + chatOptions ??= new ChatOptions(); + chatOptions.ConversationId = ChatHistoryPersistingChatClient.LocalHistoryConversationId; + } + + return chatOptions; + } + /// /// Gets a value indicating whether the agent has a /// decorator in mark-only mode, which marks messages for later persistence at the end of the run. diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs index 0085afbdd5..e733b778eb 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs @@ -50,6 +50,26 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient /// internal const string PersistedMarkerKey = "_chatHistoryPersisted"; + /// + /// A sentinel value set on by + /// when per-service-call persistence is active and no real conversation ID exists. + /// + /// + /// + /// This signals to that the chat history is being managed + /// externally (by this decorator), which prevents it from adding duplicate + /// messages into the request during approval-response processing. Without this sentinel, + /// would reconstruct function-call messages from approval + /// responses and append them to the original messages — but the loaded history already contains + /// those same function calls, causing duplicate tool-call entries that the model rejects. + /// + /// + /// This decorator strips the sentinel before forwarding requests to the inner client, so the + /// underlying model never sees it. + /// + /// + internal const string LocalHistoryConversationId = "_agent_local_history"; + /// /// Initializes a new instance of the class. /// @@ -87,6 +107,7 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient CancellationToken cancellationToken = default) { var (agent, session) = GetRequiredAgentAndSession(); + options = StripLocalHistoryConversationId(options); ChatResponse response; try @@ -130,6 +151,7 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient [EnumeratorCancellation] CancellationToken cancellationToken = default) { var (agent, session) = GetRequiredAgentAndSession(); + options = StripLocalHistoryConversationId(options); List responseUpdates = []; @@ -310,4 +332,20 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient } } } + + /// + /// If the carry the sentinel, + /// returns a clone with the conversation ID cleared so the inner client never sees it. + /// Otherwise returns the original unchanged. + /// + private static ChatOptions? StripLocalHistoryConversationId(ChatOptions? options) + { + if (options?.ConversationId == LocalHistoryConversationId) + { + options = options.Clone(); + options.ConversationId = null; + } + + return options; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs new file mode 100644 index 0000000000..a3d2bd0c6a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Shared test helper for integration tests that verify +/// end-to-end behavior with and +/// . +/// +internal static class ChatClientAgentTestHelper +{ + /// + /// Represents an expected service call during a test: an optional input verifier and the response to return. + /// + /// The the mock service should return for this call. + /// Optional callback to verify the messages sent to the service on this call. +#pragma warning disable CA1812 // Instantiated by test classes + public sealed record ServiceCallExpectation( + ChatResponse Response, + Action>? VerifyInput = null); +#pragma warning restore CA1812 + + /// + /// Describes the expected shape of a message in the persisted history for structural comparison. + /// + /// The expected role of the message. + /// Optional substring that the message text should contain. + /// Optional array of expected types in the message. +#pragma warning disable CA1812 // Instantiated by test classes + public sealed record ExpectedMessage( + ChatRole Role, + string? TextContains = null, + Type[]? ContentTypes = null); +#pragma warning restore CA1812 + + /// + /// The result of a RunAsync invocation, containing the response, session, agent, + /// captured service inputs, and call counts for detailed verification. + /// + public sealed record RunResult( + AgentResponse Response, + ChatClientAgentSession Session, + ChatClientAgent Agent, + Mock MockService, + int TotalServiceCalls, + List> CapturedServiceInputs); + + /// + /// Creates a mock that returns responses in sequence, + /// captures input messages, and optionally verifies inputs. + /// + /// The ordered sequence of expected service calls. + /// Shared call index counter (allows reuse across multiple RunAsync calls). + /// List that captured service inputs are appended to. + /// The configured mock. + public static Mock CreateSequentialMock( + List expectations, + Ref callIndex, + List> capturedInputs) + { + Mock mock = new(); + mock.Setup(s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns, ChatOptions?, CancellationToken>((msgs, _, _) => + { + int idx = callIndex.Value++; + var messageList = msgs.ToList(); + capturedInputs.Add(messageList); + + if (idx >= expectations.Count) + { + throw new InvalidOperationException( + $"Mock received unexpected service call #{idx + 1}. Only {expectations.Count} call(s) were expected."); + } + + var expectation = expectations[idx]; + expectation.VerifyInput?.Invoke(messageList); + return Task.FromResult(expectation.Response); + }); + return mock; + } + + /// + /// Runs the agent with the given inputs, automatically verifying service call count + /// and optional expected history, and returns the result for further assertions. + /// + /// Messages to pass to RunAsync. + /// Ordered service call expectations for the mock. + /// Options for configuring the agent. If null, defaults are used. + /// An existing session to reuse (for multi-turn tests). If null, a new session is created. + /// An existing agent to reuse (for multi-turn tests). If null, a new agent is created. + /// An existing mock to reuse (for multi-turn tests). If null, a new mock is created. + /// Shared call index for multi-turn tests. If null, a new counter is created. + /// Shared captured inputs list for multi-turn tests. If null, a new list is created. + /// Optional initial chat history to pre-populate in . + /// Optional to pass to RunAsync. + /// + /// If provided, asserts the total number of service calls matches. + /// For multi-turn tests, pass null and verify after the final turn. + /// + /// + /// If provided, asserts that the persisted history matches these expected messages. + /// For multi-turn tests, pass null and verify after the final turn. + /// + /// A containing the response, session, agent, mock, and captured inputs. + public static async Task RunAsync( + List inputMessages, + List serviceCallExpectations, + ChatClientAgentOptions? agentOptions = null, + ChatClientAgentSession? existingSession = null, + ChatClientAgent? existingAgent = null, + Mock? existingMock = null, + Ref? callIndex = null, + List>? capturedInputs = null, + List? initialChatHistory = null, + AgentRunOptions? runOptions = null, + int? expectedServiceCallCount = null, + List? expectedHistory = null) + { + callIndex ??= new Ref(0); + capturedInputs ??= []; + var mock = existingMock ?? CreateSequentialMock(serviceCallExpectations, callIndex, capturedInputs); + agentOptions ??= new ChatClientAgentOptions(); + + var agent = existingAgent ?? new ChatClientAgent( + mock.Object, + options: agentOptions, + services: new ServiceCollection().BuildServiceProvider()); + + var session = existingSession ?? (await agent.CreateSessionAsync() as ChatClientAgentSession)!; + + // Pre-populate initial chat history if provided. + if (initialChatHistory is not null) + { + (agent.ChatHistoryProvider as InMemoryChatHistoryProvider) + ?.SetMessages(session, new List(initialChatHistory)); + } + + var response = await agent.RunAsync(inputMessages, session, runOptions); + + var result = new RunResult(response, session, agent, mock, callIndex.Value, capturedInputs); + + // Auto-verify service call count if specified. + if (expectedServiceCallCount.HasValue) + { + Assert.Equal(expectedServiceCallCount.Value, callIndex.Value); + } + + // Auto-verify persisted history if specified. + if (expectedHistory is not null) + { + var history = GetPersistedHistory(agent, session); + AssertMessagesMatch(history, expectedHistory); + } + + return result; + } + + /// + /// Asserts that the actual message list matches the expected message patterns structurally. + /// Checks message count, roles, optional text content, and optional content types. + /// + /// The actual messages to verify. + /// The expected message patterns. + public static void AssertMessagesMatch(List actual, List expected) + { + Assert.True( + expected.Count == actual.Count, + $"Expected {expected.Count} message(s) but found {actual.Count}.\nActual messages:\n{FormatMessages(actual)}"); + + for (int i = 0; i < expected.Count; i++) + { + var exp = expected[i]; + var act = actual[i]; + + Assert.True( + exp.Role == act.Role, + $"Message [{i}]: expected role {exp.Role} but found {act.Role}.\nActual messages:\n{FormatMessages(actual)}"); + + if (exp.TextContains is not null) + { + Assert.Contains(exp.TextContains, act.Text, StringComparison.Ordinal); + } + + if (exp.ContentTypes is not null) + { + AssertContentTypes(act.Contents, exp.ContentTypes, i); + } + } + } + + /// + /// Gets the persisted chat history from the agent's . + /// + /// The agent whose history provider to query. + /// The session to get history for. + /// The list of persisted messages, or an empty list if no provider is available. + public static List GetPersistedHistory(ChatClientAgent agent, AgentSession session) + { + var provider = agent.ChatHistoryProvider as InMemoryChatHistoryProvider; + return provider?.GetMessages(session) ?? []; + } + + /// + /// Formats the contents of a message list as a diagnostic string for test failure messages. + /// + /// The messages to format. + /// A human-readable representation of the messages. + public static string FormatMessages(IEnumerable messages) + { + var sb = new StringBuilder(); + int index = 0; + foreach (var msg in messages) + { + sb.AppendLine($" [{index}] Role={msg.Role}, Text=\"{msg.Text}\", Contents=[{string.Join(", ", msg.Contents.Select(c => c.GetType().Name))}]"); + index++; + } + + return sb.ToString(); + } + + /// + /// A simple mutable reference wrapper for value types, allowing shared state across callbacks. + /// + public sealed class Ref(T value) where T : struct + { + public T Value { get; set; } = value; + } + + /// + /// Asserts that a message's content collection contains the expected content types. + /// + private static void AssertContentTypes(IList contents, Type[] expectedTypes, int messageIndex) + { + Assert.True( + contents.Count >= expectedTypes.Length, + $"Message [{messageIndex}]: expected at least {expectedTypes.Length} content(s) but found {contents.Count}. " + + $"Actual types: [{string.Join(", ", contents.Select(c => c.GetType().Name))}]"); + + foreach (var expectedType in expectedTypes) + { + Assert.True( + contents.Any(c => expectedType.IsInstanceOfType(c)), + $"Message [{messageIndex}]: expected content of type {expectedType.Name} but found [{string.Join(", ", contents.Select(c => c.GetType().Name))}]"); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 2b3cfe43e8..7241ca763e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -379,18 +379,23 @@ public partial class ChatClientAgentTests } /// - /// Verify that RunAsync passes null ChatOptions when using regular AgentRunOptions. + /// Verify that RunAsync passes ChatOptions with null ConversationId when using regular AgentRunOptions. + /// When per-service-call persistence is active (default), the sentinel conversation ID is set on ChatOptions + /// and then stripped by ChatHistoryPersistingChatClient before reaching the inner client. /// [Fact] - public async Task RunAsyncPassesNullChatOptionsWhenUsingRegularAgentRunOptionsAsync() + public async Task RunAsyncPassesChatOptionsWithNullConversationIdWhenUsingRegularAgentRunOptionsAsync() { // Arrange + ChatOptions? capturedOptions = null; Mock mockService = new(); mockService.Setup( s => s.GetResponseAsync( It.IsAny>(), - null, - It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); ChatClientAgent agent = new(mockService.Object); var runOptions = new AgentRunOptions(); @@ -398,13 +403,9 @@ public partial class ChatClientAgentTests // Act await agent.RunAsync([new(ChatRole.User, "test")], options: runOptions); - // Assert - mockService.Verify( - x => x.GetResponseAsync( - It.IsAny>(), - null, - It.IsAny()), - Times.Once); + // Assert — the inner client receives ChatOptions with null ConversationId (sentinel was stripped) + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions!.ConversationId); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs new file mode 100644 index 0000000000..6300942c9d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs @@ -0,0 +1,306 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Contains unit tests that verify the end-to-end approval flow behavior of the +/// class with , +/// ensuring that chat history is correctly persisted across multi-turn approval interactions. +/// +public class ChatClientAgent_ApprovalsTests +{ + #region Per-Service-Call Persistence Approval Tests + + /// + /// Verifies that with per-service-call persistence and an approval-required tool, + /// a two-turn approval flow persists the correct final history: + /// Turn 1: user asks → model returns FCC → FICC converts to ToolApprovalRequestContent → returned to caller. + /// Turn 2: caller sends ToolApprovalResponseContent → FICC processes approval, invokes function, calls model again. + /// Final history: [user, assistant(FCC), tool(FRC), assistant(final)]. + /// + [Fact] + public async Task RunAsync_ApprovalRequired_PerServiceCallPersistence_PersistsCorrectHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + var approvalTool = new ApprovalRequiredAIFunction(tool); + + var callIndex = new ChatClientAgentTestHelper.Ref(0); + var capturedInputs = new List>(); + var serviceExpectations = new List + { + // Turn 1: model returns a function call (FICC will convert to approval request) + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + // Turn 2: after approval, FICC invokes the function and calls the model again + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])), + }; + + // Act — Turn 1: initial request + var result1 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: serviceExpectations, + agentOptions: new() + { + ChatOptions = new() { Tools = [approvalTool] }, + PersistChatHistoryAtEndOfRun = false, + }, + callIndex: callIndex, + capturedInputs: capturedInputs); + + // Verify Turn 1 returns exactly one approval request + var approvalRequests = result1.Response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + Assert.Single(approvalRequests); + Assert.Equal(1, result1.TotalServiceCalls); + + // Verify service received user message on first call + Assert.Single(capturedInputs); + Assert.Contains(capturedInputs[0], m => m.Role == ChatRole.User && m.Text == "What's the weather?"); + + // Act — Turn 2: send approval response + var approvalResponseMessages = approvalRequests.ConvertAll(req => + new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)])); + + await ChatClientAgentTestHelper.RunAsync( + inputMessages: approvalResponseMessages, + serviceCallExpectations: serviceExpectations, + existingSession: result1.Session, + existingAgent: result1.Agent, + existingMock: result1.MockService, + callIndex: callIndex, + capturedInputs: capturedInputs, + expectedServiceCallCount: 2, + expectedHistory: + [ + new(ChatRole.User, TextContains: "What's the weather?"), + new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]), + new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]), + new(ChatRole.Assistant, TextContains: "sunny and 22°C"), + ]); + + // Verify second service call received the full conversation (user + FCC + FRC) + Assert.Equal(2, capturedInputs.Count); + Assert.Contains(capturedInputs[1], m => m.Contents.OfType().Any()); + Assert.Contains(capturedInputs[1], m => m.Contents.OfType().Any()); + } + + #endregion + + #region End-of-Run Persistence Approval Tests + + /// + /// Verifies that with end-of-run persistence and an approval-required tool, + /// a two-turn approval flow persists the correct final history. + /// + [Fact] + public async Task RunAsync_ApprovalRequired_EndOfRunPersistence_PersistsCorrectHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + var approvalTool = new ApprovalRequiredAIFunction(tool); + + var callIndex = new ChatClientAgentTestHelper.Ref(0); + var capturedInputs = new List>(); + var serviceExpectations = new List + { + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])), + }; + + // Act — Turn 1 + var result1 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: serviceExpectations, + agentOptions: new() + { + ChatOptions = new() { Tools = [approvalTool] }, + PersistChatHistoryAtEndOfRun = true, + }, + callIndex: callIndex, + capturedInputs: capturedInputs); + + var approvalRequests = result1.Response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + Assert.Single(approvalRequests); + + // Act — Turn 2 + var approvalResponseMessages = approvalRequests.ConvertAll(req => + new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)])); + + var result2 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: approvalResponseMessages, + serviceCallExpectations: serviceExpectations, + existingSession: result1.Session, + existingAgent: result1.Agent, + existingMock: result1.MockService, + callIndex: callIndex, + capturedInputs: capturedInputs, + expectedServiceCallCount: 2, + expectedHistory: + [ + // End-of-run persistence retains the approval request from Turn 1 + new(ChatRole.User, TextContains: "What's the weather?"), + new(ChatRole.Assistant, ContentTypes: [typeof(ToolApprovalRequestContent)]), + new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]), + new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]), + new(ChatRole.Assistant, TextContains: "sunny and 22°C"), + ]); + } + + #endregion + + #region Service-Stored History Approval Tests + + /// + /// Verifies that with service-stored history (ConversationId returned) and an approval-required tool, + /// the two-turn approval flow completes without errors and the session gets the ConversationId. + /// + [Fact] + public async Task RunAsync_ApprovalRequired_ServiceStoredHistory_CompletesWithoutErrorAsync() + { + // Arrange + const string ConversationId = "thread-456"; + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + var approvalTool = new ApprovalRequiredAIFunction(tool); + + var callIndex = new ChatClientAgentTestHelper.Ref(0); + var capturedInputs = new List>(); + var serviceExpectations = new List + { + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])]) + { + ConversationId = ConversationId, + }), + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")]) + { + ConversationId = ConversationId, + }), + }; + + // Act — Turn 1 + var result1 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: serviceExpectations, + agentOptions: new() + { + ChatOptions = new() { Tools = [approvalTool] }, + PersistChatHistoryAtEndOfRun = false, + }, + callIndex: callIndex, + capturedInputs: capturedInputs); + + var approvalRequests = result1.Response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + Assert.Single(approvalRequests); + Assert.Equal(ConversationId, result1.Session.ConversationId); + + // Act — Turn 2 + var approvalResponseMessages = approvalRequests.ConvertAll(req => + new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)])); + + var result2 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: approvalResponseMessages, + serviceCallExpectations: serviceExpectations, + existingSession: result1.Session, + existingAgent: result1.Agent, + existingMock: result1.MockService, + callIndex: callIndex, + capturedInputs: capturedInputs, + expectedServiceCallCount: 2); + + // Assert — session should retain the ConversationId, response should be correct + Assert.Equal(ConversationId, result2.Session.ConversationId); + Assert.Contains(result2.Response.Messages, m => m.Text == "The weather in Amsterdam is sunny and 22°C."); + } + + #endregion + + #region Approval Rejected Tests + + /// + /// Verifies that when an approval is rejected, the rejection result is persisted in the history + /// and the model receives the rejection information. + /// + [Fact] + public async Task RunAsync_ApprovalRejected_PersistsRejectionInHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + var approvalTool = new ApprovalRequiredAIFunction(tool); + + var callIndex = new ChatClientAgentTestHelper.Ref(0); + var capturedInputs = new List>(); + var serviceExpectations = new List + { + // Turn 1: model requests function call + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + // Turn 2: after rejection, model gets the rejection info and responds accordingly + new(new ChatResponse([new(ChatRole.Assistant, "I'm sorry, I cannot check the weather without your approval.")])), + }; + + // Act — Turn 1 + var result1 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: serviceExpectations, + agentOptions: new() + { + ChatOptions = new() { Tools = [approvalTool] }, + PersistChatHistoryAtEndOfRun = false, + }, + callIndex: callIndex, + capturedInputs: capturedInputs); + + var approvalRequests = result1.Response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + Assert.Single(approvalRequests); + + // Act — Turn 2: reject the approval + var rejectionMessages = approvalRequests.ConvertAll(req => + new ChatMessage(ChatRole.User, [req.CreateResponse(approved: false, reason: "User declined")])); + + var result2 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: rejectionMessages, + serviceCallExpectations: serviceExpectations, + existingSession: result1.Session, + existingAgent: result1.Agent, + existingMock: result1.MockService, + callIndex: callIndex, + capturedInputs: capturedInputs, + expectedServiceCallCount: 2); + + // Assert — history should contain the rejection result (FRC with rejection) + var history = ChatClientAgentTestHelper.GetPersistedHistory(result2.Agent, result2.Session); + Assert.True( + history.Count >= 3, + $"Expected at least 3 messages in history, got {history.Count}.\n{ChatClientAgentTestHelper.FormatMessages(history)}"); + Assert.Contains(history, m => m.Role == ChatRole.User && m.Text == "What's the weather?"); + Assert.Contains(history, m => m.Contents.OfType().Any( + frc => frc.Result?.ToString()?.Contains("rejected") == true)); + Assert.Contains(history, m => m.Role == ChatRole.Assistant && + m.Text == "I'm sorry, I cannot check the weather without your approval."); + + // Verify the second service call received the rejection FRC + Assert.Equal(2, capturedInputs.Count); + Assert.Contains(capturedInputs[1], m => m.Contents.OfType().Any( + frc => frc.Result?.ToString()?.Contains("rejected") == true)); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs index cc9b7acb19..3e54dbc06e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs @@ -500,4 +500,158 @@ public class ChatClientAgent_ChatHistoryManagementTests } #endregion + + #region End-to-End Chat History Persistence Tests + + /// + /// Verifies that with per-service-call persistence (default), a simple request/response + /// results in the correct chat history being persisted: [user, assistant]. + /// + [Fact] + public async Task RunAsync_PerServiceCallPersistence_SimpleResponse_PersistsCorrectHistoryAsync() + { + // Arrange & Act & Assert + await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "Hello")], + serviceCallExpectations: + [ + new(new ChatResponse([new(ChatRole.Assistant, "Hi there")])), + ], + agentOptions: new() + { + ChatOptions = new() { Instructions = "Be helpful" }, + PersistChatHistoryAtEndOfRun = false, + }, + expectedServiceCallCount: 1, + expectedHistory: + [ + new(ChatRole.User, TextContains: "Hello"), + new(ChatRole.Assistant, TextContains: "Hi there"), + ]); + } + + /// + /// Verifies that with per-service-call persistence and a function calling loop, + /// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)]. + /// + [Fact] + public async Task RunAsync_PerServiceCallPersistence_FunctionCallingLoop_PersistsCorrectHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + + // Act & Assert + await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: + [ + // First call: model requests a function call + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + // Second call: model returns final response after seeing function result + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])), + ], + agentOptions: new() + { + ChatOptions = new() { Tools = [tool] }, + PersistChatHistoryAtEndOfRun = false, + }, + expectedServiceCallCount: 2, + expectedHistory: + [ + new(ChatRole.User, TextContains: "What's the weather?"), + new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]), + new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]), + new(ChatRole.Assistant, TextContains: "sunny and 22°C"), + ]); + } + + /// + /// Verifies that with end-of-run persistence, a simple request/response + /// results in the correct chat history being persisted: [user, assistant]. + /// + [Fact] + public async Task RunAsync_EndOfRunPersistence_SimpleResponse_PersistsCorrectHistoryAsync() + { + // Arrange & Act & Assert + await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "Hello")], + serviceCallExpectations: + [ + new(new ChatResponse([new(ChatRole.Assistant, "Hi there")])), + ], + agentOptions: new() + { + ChatOptions = new() { Instructions = "Be helpful" }, + PersistChatHistoryAtEndOfRun = true, + }, + expectedServiceCallCount: 1, + expectedHistory: + [ + new(ChatRole.User, TextContains: "Hello"), + new(ChatRole.Assistant, TextContains: "Hi there"), + ]); + } + + /// + /// Verifies that with end-of-run persistence and a function calling loop, + /// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)]. + /// + [Fact] + public async Task RunAsync_EndOfRunPersistence_FunctionCallingLoop_PersistsCorrectHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + + // Act & Assert + await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: + [ + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])), + ], + agentOptions: new() + { + ChatOptions = new() { Tools = [tool] }, + PersistChatHistoryAtEndOfRun = true, + }, + expectedServiceCallCount: 2, + expectedHistory: + [ + new(ChatRole.User, TextContains: "What's the weather?"), + new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]), + new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]), + new(ChatRole.Assistant, TextContains: "sunny and 22°C"), + ]); + } + + /// + /// Verifies that when the service returns a ConversationId (service-stored history), + /// the session gets the ConversationId and no errors occur during the run. + /// + [Fact] + public async Task RunAsync_ServiceStoredHistory_SetsConversationIdAndCompletesWithoutErrorAsync() + { + // Arrange & Act + var result = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "Hello")], + serviceCallExpectations: + [ + new(new ChatResponse([new(ChatRole.Assistant, "Hi there")]) { ConversationId = "thread-123" }), + ], + agentOptions: new() + { + ChatOptions = new() { Instructions = "Be helpful" }, + PersistChatHistoryAtEndOfRun = false, + }, + expectedServiceCallCount: 1); + + // Assert — session should have the conversation id from the service + Assert.Equal("thread-123", result.Session.ConversationId); + Assert.Contains(result.Response.Messages, m => m.Text == "Hi there"); + } + + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs index 6dda0f0278..28d38ea36a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs @@ -176,10 +176,12 @@ public class ChatClientAgent_ChatOptionsMergingTests } /// - /// Verify that ChatOptions merging returns null when both agent and request have no ChatOptions. + /// Verify that ChatOptions merging returns a non-null ChatOptions instance with null ConversationId + /// when both agent and request have no ChatOptions. The sentinel conversation ID is set for + /// per-service-call persistence and stripped before reaching the inner client. /// [Fact] - public async Task ChatOptionsMergingReturnsNullWhenBothAgentAndRequestHaveNoneAsync() + public async Task ChatOptionsMergingReturnsChatOptionsWithNullConversationIdWhenBothAgentAndRequestHaveNoneAsync() { // Arrange Mock mockService = new(); @@ -189,7 +191,7 @@ public class ChatClientAgent_ChatOptionsMergingTests It.IsAny>(), It.IsAny(), It.IsAny())) - .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + .Callback, ChatOptions?, CancellationToken>((msgs, opts, ct) => capturedChatOptions = opts) .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); @@ -199,8 +201,9 @@ public class ChatClientAgent_ChatOptionsMergingTests // Act await agent.RunAsync(messages); - // Assert - Assert.Null(capturedChatOptions); + // Assert — ChatOptions is non-null because the sentinel was set, but ConversationId is null (stripped) + Assert.NotNull(capturedChatOptions); + Assert.Null(capturedChatOptions!.ConversationId); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs index 459859224f..e7f91ab5d7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs @@ -763,4 +763,171 @@ public class ChatHistoryPersistingChatClientTests await Task.CompletedTask; } + + /// + /// Verifies that when per-service-call persistence is active and no real conversation ID exists, + /// sets the + /// sentinel on the chat options and strips it before + /// forwarding to the inner client. + /// + [Fact] + public async Task RunAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync() + { + // Arrange + ChatOptions? capturedOptions = null; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test" }, + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + await agent.RunAsync([new(ChatRole.User, "test")]); + + // Assert — the inner client should NOT see the sentinel conversation ID + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions!.ConversationId); + } + + /// + /// Verifies that the sentinel is NOT set when end-of-run persistence is enabled + /// (mark-only mode), since the issue only applies to per-service-call persistence. + /// + [Fact] + public async Task RunAsync_DoesNotSetSentinel_WhenEndOfRunPersistenceEnabledAsync() + { + // Arrange + ChatOptions? capturedOptions = null; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test" }, + PersistChatHistoryAtEndOfRun = true, + }); + + // Act + await agent.RunAsync([new(ChatRole.User, "test")]); + + // Assert — the inner client should see options but NOT the sentinel conversation ID + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions!.ConversationId); + } + + /// + /// Verifies that the sentinel is NOT set when a real conversation ID is already present + /// on the session (indicating server-side history management). + /// + [Fact] + public async Task RunAsync_DoesNotSetSentinel_WhenRealConversationIdExistsAsync() + { + // Arrange + const string RealConversationId = "real-conv-123"; + ChatOptions? capturedOptions = null; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) + { + ConversationId = RealConversationId, + }); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + PersistChatHistoryAtEndOfRun = false, + }); + + // Create a session with a real conversation ID. + var session = await agent.CreateSessionAsync(RealConversationId); + + // Act + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert — the inner client should see the real conversation ID, not the sentinel + Assert.NotNull(capturedOptions); + Assert.Equal(RealConversationId, capturedOptions!.ConversationId); + } + + /// + /// Verifies that the sentinel is set and stripped correctly in the streaming path. + /// + [Fact] + public async Task RunStreamingAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync() + { + // Arrange + ChatOptions? capturedOptions = null; + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .Returns(CreateAsyncEnumerableAsync(new ChatResponseUpdate(role: ChatRole.Assistant, content: "response"))); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test" }, + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")])) + { + // Consume the stream. + } + + // Assert — the inner client should NOT see the sentinel conversation ID + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions!.ConversationId); + } + + /// + /// Verifies that the session's conversation ID is NOT set to the sentinel after the run. + /// The sentinel should only exist transiently on the ChatOptions for the pipeline. + /// + [Fact] + public async Task RunAsync_SentinelDoesNotLeakToSession_WhenPerServiceCallPersistenceActiveAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert — session should NOT have the sentinel conversation ID + Assert.Null(session!.ConversationId); + } } From 9bfa593ae7bea160aef6885962611590423f59a9 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 27 Mar 2026 02:52:07 +0900 Subject: [PATCH 20/31] Python: Move ag_ui_workflow_handoff demo from demos/ to 05-end-to-end/ (#4900) * Move ag_ui_workflow_handoff demo to 05-end-to-end (#4895) Move the AG-UI workflow handoff demo from python/samples/demos/ to python/samples/05-end-to-end/ to follow the current folder structure convention. Update README paths accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix review feedback: remove build artifacts, fix README paths (#4895) - Add .gitignore to frontend/ to exclude *.tsbuildinfo, vite.config.js, and vite.config.d.ts build artifacts from version control - Remove the 4 tracked build artifact files from the tree - Fix step 2 cd path in README to be relative after 'cd python' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clarify working directory context in README Step 2 (#4895) Step 2 uses a python/-relative path (samples/...) which assumes the user is still in the python/ directory from Step 1. Add a brief note making this explicit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ag_ui_workflow_handoff/README.md | 8 +++++--- .../ag_ui_workflow_handoff/backend/server.py | 0 .../ag_ui_workflow_handoff/frontend/.gitignore | 7 +++++++ .../ag_ui_workflow_handoff/frontend/index.html | 0 .../ag_ui_workflow_handoff/frontend/package-lock.json | 0 .../ag_ui_workflow_handoff/frontend/package.json | 0 .../ag_ui_workflow_handoff/frontend/src/App.tsx | 0 .../ag_ui_workflow_handoff/frontend/src/main.tsx | 0 .../ag_ui_workflow_handoff/frontend/src/styles.css | 0 .../ag_ui_workflow_handoff/frontend/src/vite-env.d.ts | 0 .../ag_ui_workflow_handoff/frontend/tsconfig.json | 0 .../frontend/tsconfig.node.json | 0 .../ag_ui_workflow_handoff/frontend/vite.config.ts | 0 .../frontend/tsconfig.node.tsbuildinfo | 1 - .../frontend/tsconfig.tsbuildinfo | 1 - .../ag_ui_workflow_handoff/frontend/vite.config.d.ts | 2 -- .../ag_ui_workflow_handoff/frontend/vite.config.js | 11 ----------- 17 files changed, 12 insertions(+), 18 deletions(-) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/README.md (93%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/backend/server.py (100%) create mode 100644 python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/index.html (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/package-lock.json (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/package.json (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/src/App.tsx (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/src/main.tsx (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/src/styles.css (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/tsconfig.json (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/tsconfig.node.json (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/vite.config.ts (100%) delete mode 100644 python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo delete mode 100644 python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo delete mode 100644 python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts delete mode 100644 python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js diff --git a/python/samples/demos/ag_ui_workflow_handoff/README.md b/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md similarity index 93% rename from python/samples/demos/ag_ui_workflow_handoff/README.md rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md index bd9a6b6a5f..51e1c9fc1c 100644 --- a/python/samples/demos/ag_ui_workflow_handoff/README.md +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md @@ -35,9 +35,9 @@ The backend uses Azure OpenAI responses and supports intent-driven, non-linear h From the Python repo root: ```bash -cd /Users/evmattso/git/agent-framework/python +cd python uv sync -uv run python samples/demos/ag_ui_workflow_handoff/backend/server.py +uv run python samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py ``` Backend default URL: @@ -47,8 +47,10 @@ Backend default URL: ## 2) Install Frontend Packages (npm) +From the `python/` directory (where Step 1 left you): + ```bash -cd /Users/evmattso/git/agent-framework/python/samples/demos/ag_ui_workflow_handoff/frontend +cd samples/05-end-to-end/ag_ui_workflow_handoff/frontend npm install ``` diff --git a/python/samples/demos/ag_ui_workflow_handoff/backend/server.py b/python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/backend/server.py rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py diff --git a/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore new file mode 100644 index 0000000000..16c69217c0 --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore @@ -0,0 +1,7 @@ +# dependencies +/node_modules + +# build artifacts +*.tsbuildinfo +vite.config.js +vite.config.d.ts diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/index.html b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/index.html similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/index.html rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/index.html diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/package-lock.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/package-lock.json rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/package.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/package.json rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/App.tsx b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/App.tsx similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/App.tsx rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/App.tsx diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/main.tsx b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/main.tsx similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/main.tsx rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/main.tsx diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/styles.css b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/styles.css similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/styles.css rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/styles.css diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.json similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.json rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.json diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.node.json similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.json rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.node.json diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.ts b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/vite.config.ts similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.ts rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/vite.config.ts diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo b/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo deleted file mode 100644 index 9c052ccd41..0000000000 --- a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseast.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/types.d-agj9qkwt.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/vite/dist/node/runtime.d.ts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@types/babel__generator/index.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@types/babel__template/index.d.ts","./node_modules/@types/babel__traverse/index.d.ts","./node_modules/@types/babel__core/index.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./vite.config.ts"],"fileIdsList":[[78],[78,79,80,81,82],[78,80],[77,83],[69],[67,69],[58,66,67,68,70,72],[56],[59,64,69,72],[55,72],[59,60,63,64,65,72],[59,60,61,63,64,72],[56,57,58,59,60,64,65,66,68,69,70,72],[72],[54,56,57,58,59,60,61,63,64,65,66,67,68,69,70,71],[54,72],[59,61,62,64,65,72],[63,72],[64,65,69,72],[57,67],[47,76],[46,47],[47,48,49,50,51,52,53,73,74,75,76],[49,50,51,52],[49,50,51],[49],[50],[47],[77,84]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","impliedFormat":1},{"version":"ee70b8037ecdf0de6c04f35277f253663a536d7e38f1539d270e4e916d225a3f","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"282f98006ed7fa9bb2cd9bdbe2524595cfc4bcd58a0bb3232e4519f2138df811","impliedFormat":1},{"version":"6222e987b58abfe92597e1273ad7233626285bc2d78409d4a7b113d81a83496b","impliedFormat":1},{"version":"cbe726263ae9a7bf32352380f7e8ab66ee25b3457137e316929269c19e18a2be","impliedFormat":1},{"version":"8b96046bf5fb0a815cba6b0880d9f97b7f3a93cf187e8dcfe8e2792e97f38f87","impliedFormat":99},{"version":"bacf2c84cf448b2cd02c717ad46c3d7fd530e0c91282888c923ad64810a4d511","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"8885cf05f3e2abf117590bbb951dcf6359e3e5ac462af1c901cfd24c6a6472e2","impliedFormat":1},{"version":"333caa2bfff7f06017f114de738050dd99a765c7eb16571c6d25a38c0d5365dc","impliedFormat":1},{"version":"e61df3640a38d535fd4bc9f4a53aef17c296b58dc4b6394fd576b808dd2fe5e6","impliedFormat":1},{"version":"459920181700cec8cbdf2a5faca127f3f17fd8dd9d9e577ed3f5f3af5d12a2e4","impliedFormat":1},{"version":"4719c209b9c00b579553859407a7e5dcfaa1c472994bd62aa5dd3cc0757eb077","impliedFormat":1},{"version":"7ec359bbc29b69d4063fe7dad0baaf35f1856f914db16b3f4f6e3e1bca4099fa","impliedFormat":1},{"version":"70790a7f0040993ca66ab8a07a059a0f8256e7bb57d968ae945f696cbff4ac7a","impliedFormat":1},{"version":"d1b9a81e99a0050ca7f2d98d7eedc6cda768f0eb9fa90b602e7107433e64c04c","impliedFormat":1},{"version":"a022503e75d6953d0e82c2c564508a5c7f8556fad5d7f971372d2d40479e4034","impliedFormat":1},{"version":"b215c4f0096f108020f666ffcc1f072c81e9f2f95464e894a5d5f34c5ea2a8b1","impliedFormat":1},{"version":"644491cde678bd462bb922c1d0cfab8f17d626b195ccb7f008612dc31f445d2d","impliedFormat":1},{"version":"dfe54dab1fa4961a6bcfba68c4ca955f8b5bbeb5f2ab3c915aa7adaa2eabc03a","impliedFormat":1},{"version":"1251d53755b03cde02466064260bb88fd83c30006a46395b7d9167340bc59b73","impliedFormat":1},{"version":"47865c5e695a382a916b1eedda1b6523145426e48a2eae4647e96b3b5e52024f","impliedFormat":1},{"version":"4cdf27e29feae6c7826cdd5c91751cc35559125e8304f9e7aed8faef97dcf572","impliedFormat":1},{"version":"331b8f71bfae1df25d564f5ea9ee65a0d847c4a94baa45925b6f38c55c7039bf","impliedFormat":1},{"version":"2a771d907aebf9391ac1f50e4ad37952943515eeea0dcc7e78aa08f508294668","impliedFormat":1},{"version":"0146fd6262c3fd3da51cb0254bb6b9a4e42931eb2f56329edd4c199cb9aaf804","impliedFormat":1},{"version":"183f480885db5caa5a8acb833c2be04f98056bdcc5fb29e969ff86e07efe57ab","impliedFormat":99},{"version":"82e687ebd99518bc63ea04b0c3810fb6e50aa6942decd0ca6f7a56d9b9a212a6","impliedFormat":99},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"8f07f2b6514744ac96e51d7cb8518c0f4de319471237ea10cf688b8d0e9d0225","impliedFormat":1},{"version":"257b83faa134d971c738a6b9e4c47e59bb7b23274719d92197580dd662bfafc3","impliedFormat":99},{"version":"556ccd493ec36c7d7cb130d51be66e147b91cc1415be383d71da0f1e49f742a9","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"95aba78013d782537cc5e23868e736bec5d377b918990e28ed56110e3ae8b958","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"26e0ffceb2198feb1ef460d5d14111c69ad07d44c5a67fd4bfeb74c969aa9afb","impliedFormat":99},{"version":"2448a94bdacc4085b4fd26ccb7c3f323d04a220af29a24b61703903730b68984","signature":"4b96dd19fd2949d28ce80e913412b0026dc421e5bf6c31d87c7b5eb11b5753b4"}],"root":[85],"options":{"allowSyntheticDefaultImports":true,"composite":true,"module":99,"skipLibCheck":true,"target":7},"referencedMap":[[80,1],[83,2],[79,1],[81,3],[82,1],[84,4],[70,5],[68,6],[69,7],[57,8],[58,6],[65,9],[56,10],[61,11],[62,12],[67,13],[73,14],[72,15],[55,16],[63,17],[64,18],[59,19],[66,5],[60,20],[48,21],[47,22],[77,23],[74,24],[52,25],[50,26],[51,27],[76,28],[85,29]],"semanticDiagnosticsPerFile":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"latestChangedDtsFile":"./vite.config.d.ts","version":"5.9.3"} \ No newline at end of file diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo b/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo deleted file mode 100644 index 68fc7fc564..0000000000 --- a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts b/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts deleted file mode 100644 index 340562aff1..0000000000 --- a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const _default: import("vite").UserConfig; -export default _default; diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js b/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js deleted file mode 100644 index 96a3b3875f..0000000000 --- a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -export default defineConfig({ - plugins: [react()], - server: { - host: "127.0.0.1", - port: 5173, - }, -}); From 5530bc536bc6d163625980f0245e35b5a2a4d2f0 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 26 Mar 2026 16:17:57 -0400 Subject: [PATCH 21/31] .NET: feat: Implement return-to-previous routing in handoff workflow (#4356) * feat: Implement return-to-previous routing in handoff workflow - Also obsoletes HandoffsWorkflowBuilder => HandoffWorkflowBuilder (no "s") * refactor: Remove instance-shared current agent tracking in handoffs Because the tracker was instance-shared between the start and end executors, it would be shared between all sessions, resulting in incorrect behaviour. The corect way to do this is to keep the data in a shared executor scope, which is per-session. * fix: Fix test logic for Handoff to correctly use checkpointing for multiturn --- .../AgentWorkflowBuilder.cs | 4 +- .../HandoffsWorkflowBuilder.cs | 86 +++++-- .../Specialized/HandoffAgentExecutor.cs | 12 +- .../Specialized/HandoffState.cs | 3 +- .../Specialized/HandoffsEndExecutor.cs | 19 +- .../Specialized/HandoffsStartExecutor.cs | 28 ++- .../AgentWorkflowBuilderTests.cs | 223 ++++++++++++++++-- .../Sample/12_HandOff_HostAsAgent.cs | 4 +- .../WorkflowHostSmokeTests.cs | 2 +- 9 files changed, 323 insertions(+), 58 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs index 501c7df230..22ee1b48eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs @@ -145,7 +145,7 @@ public static partial class AgentWorkflowBuilder return builder.Build(); } - /// Creates a new using as the starting agent in the workflow. + /// Creates a new using as the starting agent in the workflow. /// The agent that will receive inputs provided to the workflow. /// The builder for creating a workflow based on handoffs. /// @@ -154,7 +154,7 @@ public static partial class AgentWorkflowBuilder /// The must be capable of understanding those provided. If the agent /// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur. /// - public static HandoffsWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent) + public static HandoffWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent) { Throw.IfNull(initialAgent); return new(initialAgent); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs index ccb993b188..8e5cee7ed5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Linq; using Microsoft.Agents.AI.Workflows.Specialized; @@ -8,10 +9,21 @@ using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows; +/// +[Obsolete("Prefer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed in a future release before GA.")] +public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent) +{ +} + +/// +public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent) +{ +} + /// /// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow. /// -public sealed class HandoffsWorkflowBuilder +public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkflowBuilderCore { /// /// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}<agent_id>`, @@ -26,12 +38,13 @@ public sealed class HandoffsWorkflowBuilder private bool _emitAgentResponseEvents; private bool _emitAgentResponseUpdateEvents; private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly; + private bool _returnToPrevious; /// /// Initializes a new instance of the class with no handoff relationships. /// /// The first agent to be invoked (prior to any handoff). - internal HandoffsWorkflowBuilder(AIAgent initialAgent) + internal HandoffWorkflowBuilderCore(AIAgent initialAgent) { this._initialAgent = initialAgent; this._allAgents.Add(initialAgent); @@ -63,10 +76,10 @@ public sealed class HandoffsWorkflowBuilder /// constant. /// /// The instructions to provide, or to restore the default instructions. - public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions) + public TBuilder WithHandoffInstructions(string? instructions) { this.HandoffInstructions = instructions ?? DefaultHandoffInstructions; - return this; + return (TBuilder)this; } /// @@ -75,10 +88,10 @@ public sealed class HandoffsWorkflowBuilder /// /// /// - public HandoffsWorkflowBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true) + public TBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true) { this._emitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents; - return this; + return (TBuilder)this; } /// @@ -86,10 +99,10 @@ public sealed class HandoffsWorkflowBuilder /// /// /// - public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true) + public TBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true) { this._emitAgentResponseEvents = emitAgentResponseEvents; - return this; + return (TBuilder)this; } /// @@ -97,10 +110,21 @@ public sealed class HandoffsWorkflowBuilder /// s flowing through the handoff workflow. Defaults to . /// /// The filtering behavior to apply. - public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior) + public TBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior) { this._toolCallFilteringBehavior = behavior; - return this; + return (TBuilder)this; + } + + /// + /// Configures the workflow so that subsequent user turns route directly back to the specialist agent + /// that handled the previous turn, rather than always routing through the initial (coordinator) agent. + /// + /// The updated instance. + public TBuilder EnableReturnToPrevious() + { + this._returnToPrevious = true; + return (TBuilder)this; } /// @@ -110,7 +134,7 @@ public sealed class HandoffsWorkflowBuilder /// The target agents to add as handoff targets for the source agent. /// The updated instance. /// The handoff reason for each target in is derived from that agent's description or name. - public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable to) + public TBuilder WithHandoffs(AIAgent from, IEnumerable to) { Throw.IfNull(from); Throw.IfNull(to); @@ -125,7 +149,7 @@ public sealed class HandoffsWorkflowBuilder this.WithHandoff(from, target); } - return this; + return (TBuilder)this; } /// @@ -138,7 +162,7 @@ public sealed class HandoffsWorkflowBuilder /// If , the reason is derived from 's description or name. /// /// The updated instance. - public HandoffsWorkflowBuilder WithHandoffs(IEnumerable from, AIAgent to, string? handoffReason = null) + public TBuilder WithHandoffs(IEnumerable from, AIAgent to, string? handoffReason = null) { Throw.IfNull(from); Throw.IfNull(to); @@ -153,7 +177,7 @@ public sealed class HandoffsWorkflowBuilder this.WithHandoff(source, to, handoffReason); } - return this; + return (TBuilder)this; } /// @@ -166,7 +190,7 @@ public sealed class HandoffsWorkflowBuilder /// If , the reason is derived from 's description or name. /// /// The updated instance. - public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null) + public TBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null) { Throw.IfNull(from); Throw.IfNull(to); @@ -196,7 +220,7 @@ public sealed class HandoffsWorkflowBuilder Throw.InvalidOperationException($"A handoff from agent '{from.Name ?? from.Id}' to agent '{to.Name ?? to.Id}' has already been registered."); } - return this; + return (TBuilder)this; } /// @@ -206,8 +230,8 @@ public sealed class HandoffsWorkflowBuilder /// The workflow built based on the handoffs in the builder. public Workflow Build() { - HandoffsStartExecutor start = new(); - HandoffsEndExecutor end = new(); + HandoffsStartExecutor start = new(this._returnToPrevious); + HandoffsEndExecutor end = new(this._returnToPrevious); WorkflowBuilder builder = new(start); HandoffAgentExecutorOptions options = new(this.HandoffInstructions, @@ -215,11 +239,31 @@ public sealed class HandoffsWorkflowBuilder this._emitAgentResponseUpdateEvents, this._toolCallFilteringBehavior); - // Create an AgentExecutor for each again. + // Create an AgentExecutor for each agent. Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options)); - // Connect the start executor to the initial agent. - builder.AddEdge(start, executors[this._initialAgent.Id]); + // Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled). + if (this._returnToPrevious) + { + string initialAgentId = this._initialAgent.Id; + builder.AddSwitch(start, sb => + { + foreach (var agent in this._allAgents) + { + if (agent.Id != initialAgentId) + { + string agentId = agent.Id; + sb.AddCase(state => state?.CurrentAgentId == agentId, executors[agentId]); + } + } + + sb.WithDefault(executors[initialAgentId]); + }); + } + else + { + builder.AddEdge(start, executors[this._initialAgent.Id]); + } // Initialize each executor with its handoff targets to the other executors. foreach (var agent in this._allAgents) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index 21519e07ee..98205a40c6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -42,7 +42,7 @@ internal sealed class HandoffMessagesFilter internal static bool IsHandoffFunctionName(string name) { - return name.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal); + return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal); } public IEnumerable FilterMessages(List messages) @@ -173,6 +173,7 @@ internal sealed class HandoffAgentExecutor( private readonly AIAgent _agent = agent; private readonly HashSet _handoffFunctionNames = []; + private readonly Dictionary _handoffFunctionToAgentId = []; private ChatClientAgentRunOptions? _agentOptions; public void Initialize( @@ -199,9 +200,10 @@ internal sealed class HandoffAgentExecutor( foreach (HandoffTarget handoff in handoffs) { index++; - var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffsWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema); + var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema); this._handoffFunctionNames.Add(handoffFunc.Name); + this._handoffFunctionToAgentId[handoffFunc.Name] = handoff.Target.Id; this._agentOptions.ChatOptions.Tools.Add(handoffFunc); @@ -267,7 +269,11 @@ internal sealed class HandoffAgentExecutor( roleChanges.ResetUserToAssistantForChangedRoles(); - return new(message.TurnToken, requestedHandoff, allMessages); + string currentAgentId = requestedHandoff is not null && this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetAgentId) + ? targetAgentId + : this._agent.Id; + + return new(message.TurnToken, requestedHandoff, allMessages, currentAgentId); async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs index cc4d87d21a..56e2fef9df 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs @@ -8,4 +8,5 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal sealed record class HandoffState( TurnToken TurnToken, string? InvokedHandoff, - List Messages); + List Messages, + string? CurrentAgentId = null); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs index 69f81376be..4a43c00a72 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs @@ -1,20 +1,35 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Specialized; /// Executor used at the end of a handoff workflow to raise a final completed event. -internal sealed class HandoffsEndExecutor() : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor { public const string ExecutorId = "HandoffEnd"; protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler((handoff, context, cancellationToken) => - context.YieldOutputAsync(handoff.Messages, cancellationToken))) + this.HandleAsync(handoff, context, cancellationToken))) .YieldsOutput>(); + private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken) + { + if (returnToPrevious) + { + await context.QueueStateUpdateAsync(HandoffConstants.CurrentAgentTrackerKey, + handoff.CurrentAgentId, + HandoffConstants.CurrentAgentTrackerScope, + cancellationToken) + .ConfigureAwait(false); + } + + await context.YieldOutputAsync(handoff.Messages, cancellationToken).ConfigureAwait(false); + } + public ValueTask ResetAsync() => default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs index 9039e86f5b..87c3b4566b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs @@ -7,8 +7,14 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Specialized; +internal static class HandoffConstants +{ + internal const string CurrentAgentTrackerKey = "LastAgentId"; + internal const string CurrentAgentTrackerScope = "HandoffOrchestration"; +} + /// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token. -internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor { internal const string ExecutorId = "HandoffStart"; @@ -22,7 +28,25 @@ internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId, base.ConfigureProtocol(protocolBuilder).SendsMessage(); protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) - => context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages), cancellationToken: cancellationToken); + { + if (returnToPrevious) + { + return context.InvokeWithStateAsync( + async (string? currentAgentId, IWorkflowContext context, CancellationToken cancellationToken) => + { + HandoffState handoffState = new(new(emitEvents), null, messages, currentAgentId); + await context.SendMessageAsync(handoffState, cancellationToken).ConfigureAwait(false); + + return currentAgentId; + }, + HandoffConstants.CurrentAgentTrackerKey, + HandoffConstants.CurrentAgentTrackerScope, + cancellationToken); + } + + HandoffState handoff = new(new(emitEvents), null, messages); + return context.SendMessageAsync(handoff, cancellationToken); + } public new ValueTask ResetAsync() => base.ResetAsync(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index 77d8d0a88d..7f06145a8e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -147,7 +147,7 @@ public class AgentWorkflowBuilderTests for (int iter = 0; iter < 3; iter++) { const string UserInput = "abc"; - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); Assert.NotNull(result); Assert.Equal(numAgents + 1, result.Count); @@ -225,7 +225,7 @@ public class AgentWorkflowBuilderTests barrier.Value = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); remaining.Value = 2; - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.NotEmpty(updateText); Assert.NotNull(result); @@ -258,7 +258,7 @@ public class AgentWorkflowBuilderTests }), description: "nop")) .Build(); - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent1", updateText); Assert.NotNull(result); @@ -296,7 +296,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(initialAgent, nextAgent) .Build(); - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent2", updateText); Assert.NotNull(result); @@ -406,7 +406,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(secondAgent, thirdAgent) .Build(); - (string updateText, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Contains("Hello from agent3", updateText); @@ -604,7 +604,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(secondAgent, thirdAgent) .Build(); - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent3", updateText); Assert.NotNull(result); @@ -651,7 +651,7 @@ public class AgentWorkflowBuilderTests for (int iter = 0; iter < 3; iter++) { const string UserInput = "abc"; - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); Assert.NotNull(result); Assert.Equal(maxIterations + 1, result.Count); @@ -680,36 +680,211 @@ public class AgentWorkflowBuilderTests } } - private static async Task<(string UpdateText, List? Result)> RunWorkflowAsync( - Workflow workflow, List input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep) + [Fact] + public async Task Handoffs_ReturnToPrevious_DisabledByDefault_SecondTurnRoutesViaCoordinatorAsync() { - StringBuilder sb = new(); + int coordinatorCallCount = 0; - InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment(); - await using StreamingRun run = await environment.RunStreamingAsync(workflow, input); + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + if (coordinatorCallCount == 1) + { + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + } + return new(new ChatMessage(ChatRole.Assistant, "coordinator responded on turn 2")); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + new(new ChatMessage(ChatRole.Assistant, "specialist responded"))), + name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator hands off to specialist + WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); + Assert.Equal(1, coordinatorCallCount); + + // Turn 2: without ReturnToPrevious, coordinator should be invoked again + _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "my id is 12345")], Environment, checkpointManager, result.LastCheckpoint); + Assert.Equal(2, coordinatorCallCount); + } + + [Fact] + public async Task Handoffs_ReturnToPrevious_Enabled_SecondTurnRoutesDirectlyToSpecialistAsync() + { + int coordinatorCallCount = 0; + int specialistCallCount = 0; + + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + { + specialistCallCount++; + return new(new ChatMessage(ChatRole.Assistant, "specialist responded")); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .EnableReturnToPrevious() + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator hands off to specialist + WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); + Assert.Equal(1, coordinatorCallCount); + Assert.Equal(1, specialistCallCount); + + // Turn 2: with ReturnToPrevious, specialist should be invoked directly, coordinator should NOT be called again + _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "my id is 12345")], Environment, checkpointManager, result.LastCheckpoint); + Assert.Equal(1, coordinatorCallCount); // coordinator NOT called again + Assert.Equal(2, specialistCallCount); // specialist called again + } + + [Fact] + public async Task Handoffs_ReturnToPrevious_Enabled_BeforeAnyHandoff_RoutesViaInitialAgentAsync() + { + int coordinatorCallCount = 0; + + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + return new(new ChatMessage(ChatRole.Assistant, "coordinator responded")); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + { + Assert.Fail("Specialist should not be invoked."); + return new(); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .EnableReturnToPrevious() + .Build(); + + // First turn with no prior handoff: should route to initial (coordinator) agent + _ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hello")]); + Assert.Equal(1, coordinatorCallCount); + } + + [Fact] + public async Task Handoffs_ReturnToPrevious_Enabled_AfterHandoffBackToCoordinator_NextTurnRoutesViaCoordinatorAsync() + { + int coordinatorCallCount = 0; + int specialistCallCount = 0; + + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + if (coordinatorCallCount == 1) + { + // First call: hand off to specialist + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + } + // Subsequent calls: respond without handoff + return new(new ChatMessage(ChatRole.Assistant, "coordinator responded")); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + { + specialistCallCount++; + // Specialist hands back to coordinator + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .WithHandoff(specialist, coordinator) + .EnableReturnToPrevious() + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator → specialist → coordinator (specialist hands back) + WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); + Assert.Equal(2, coordinatorCallCount); // called twice: initial handoff + receiving handback + Assert.Equal(1, specialistCallCount); // specialist called once, then handed back + + // Turn 2: after handoff back to coordinator, should route to coordinator (not specialist) + _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "never mind")], Environment, checkpointManager, result.LastCheckpoint); + Assert.Equal(3, coordinatorCallCount); // coordinator called again on turn 2 + Assert.Equal(1, specialistCallCount); // specialist NOT called + } + + private sealed record WorkflowRunResult(string UpdateText, List? Result, CheckpointInfo? LastCheckpoint); + + private static Task RunWorkflowCheckpointedAsync( + Workflow workflow, List input, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null) + { + InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment() + .WithCheckpointing(checkpointManager); + + return RunWorkflowCheckpointedAsync(workflow, input, environment, fromCheckpoint); + } + + private static async Task RunWorkflowCheckpointedAsync( + Workflow workflow, List input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null) + { + await using StreamingRun run = + fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint) + : await environment.OpenStreamingAsync(workflow); + + await run.TrySendMessageAsync(input); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + StringBuilder sb = new(); WorkflowOutputEvent? output = null; + CheckpointInfo? lastCheckpoint = null; await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is AgentResponseUpdateEvent executorComplete) + switch (evt) { - sb.Append(executorComplete.Data); - } - else if (evt is WorkflowOutputEvent e) - { - output = e; - break; - } - else if (evt is WorkflowErrorEvent errorEvent) - { - Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}"); + case AgentResponseUpdateEvent executorComplete: + sb.Append(executorComplete.Data); + break; + + case WorkflowOutputEvent e: + output = e; + break; + + case WorkflowErrorEvent errorEvent: + Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}"); + break; + + case SuperStepCompletedEvent stepCompleted: + lastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint; + break; } } - return (sb.ToString(), output?.As>()); + return new(sb.ToString(), output?.As>(), lastCheckpoint); } + private static Task RunWorkflowAsync( + Workflow workflow, List input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep) + => RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment()); + private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox> barrier, StrongBox remaining) : DoubleEchoAgent(name) { protected override async IAsyncEnumerable RunCoreStreamingAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs index 3d88ed22ab..993a6d462b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs @@ -20,7 +20,7 @@ internal sealed class HandoffTestEchoAgent(string id, string name, string prefix { IEnumerable? handoffs = chatClientOptions.ChatOptions .Tools? - .Where(tool => tool.Name?.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, + .Where(tool => tool.Name?.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.OrdinalIgnoreCase) is true); if (handoffs != null) @@ -58,7 +58,7 @@ internal static class Step12EntryPoint .Select(i => new HandoffTestEchoAgent($"{EchoAgentIdPrefix}{i}", $"{EchoAgentNamePrefix}{i}", EchoPrefixForAgent(i))) .ToArray(); - return new HandoffsWorkflowBuilder(echoAgents[0]) + return new HandoffWorkflowBuilder(echoAgents[0]) .WithHandoff(echoAgents[0], echoAgents[1]) .Build(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index 7bf00c4fb9..3fa17a34bb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -794,7 +794,7 @@ public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase { // Arrange TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); - Workflow handoffWorkflow = new HandoffsWorkflowBuilder(agent).Build(); + Workflow handoffWorkflow = new HandoffWorkflowBuilder(agent).Build(); return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(handoffWorkflow, runAsync); } } From 0fcbe7e10553d901cc4969eef5c61532846f93e0 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:27:17 +0000 Subject: [PATCH 22/31] .NET: [Breaking] Restructure agent skills to use multi-source architecture (#4871) * initial commit * address comments * address comments * address comments * address comments * rename executor to runner to align naming with python implementation * rename runner execute method to run method * remove poc leftovers and fix compilation issues * make script runner optional * remove unnecessary pragmas * make resources and scripts props virtual * address comments * update comment for name validation regex * address comments --- dotnet/agent-framework-dotnet.slnx | 2 +- .../Agent_Step01_BasicSkills/Program.cs | 50 -- .../Agent_Step01_BasicSkills/README.md | 63 -- .../skills/expense-report/SKILL.md | 40 - .../assets/expense-report-template.md | 5 - .../expense-report/references/POLICY_FAQ.md | 55 -- .../Agent_Step01_FileBasedSkills.csproj} | 4 + .../Agent_Step01_FileBasedSkills/Program.cs | 48 ++ .../Agent_Step01_FileBasedSkills/README.md | 51 ++ .../skills/unit-converter/SKILL.md | 11 + .../references/conversion-table.md | 10 + .../skills/unit-converter/scripts/convert.py | 29 + .../samples/02-agents/AgentSkills/README.md | 2 +- .../AgentSkills/SubprocessScriptRunner.cs | 137 ++++ .../A2AServer/HostAgentFactory.cs | 6 +- .../Microsoft.Agents.AI/Skills/AgentSkill.cs | 58 ++ .../Skills/AgentSkillFrontmatter.cs | 196 +++++ .../Skills/AgentSkillResource.cs | 46 ++ .../Skills/AgentSkillScript.cs | 47 ++ .../Skills/AgentSkillsProvider.cs | 383 +++++++++ .../Skills/AgentSkillsProviderBuilder.cs | 192 +++++ .../Skills/AgentSkillsProviderOptions.cs | 37 + .../Skills/AgentSkillsSource.cs | 24 + .../Skills/AggregatingAgentSkillsSource.cs | 45 ++ .../DeduplicatingAgentSkillsSource.cs | 58 ++ .../Decorators/DelegatingAgentSkillsSource.cs | 41 + .../Decorators/FilteringAgentSkillsSource.cs | 70 ++ .../Skills/File/AgentFileSkill.cs | 57 ++ .../Skills/File/AgentFileSkillResource.cs | 43 + .../Skills/File/AgentFileSkillScript.cs | 56 ++ .../Skills/File/AgentFileSkillScriptRunner.cs | 27 + .../AgentFileSkillsSource.cs} | 368 +++++---- .../File/AgentFileSkillsSourceOptions.cs | 33 + .../Skills/FileAgentSkill.cs | 56 -- .../Skills/FileAgentSkillsProvider.cs | 222 ----- .../Skills/FileAgentSkillsProviderOptions.cs | 32 - .../Skills/SkillFrontmatter.cs | 32 - .../AgentSkills/AgentFileSkillScriptTests.cs | 103 +++ .../AgentFileSkillsSourceScriptTests.cs | 255 ++++++ .../AgentSkillFrontmatterValidatorTests.cs | 260 ++++++ .../AgentSkillsProviderBuilderTests.cs | 229 ++++++ .../AgentSkills/AgentSkillsProviderTests.cs | 765 ++++++++++++++++++ .../DeduplicatingAgentSkillsSourceTests.cs | 99 +++ .../AgentSkills/FileAgentSkillLoaderTests.cs | 494 +++++------ .../FileAgentSkillsProviderTests.cs | 266 ------ .../FilteringAgentSkillsSourceTests.cs | 120 +++ .../AgentSkills/TestSkillTypes.cs | 72 ++ 47 files changed, 4086 insertions(+), 1213 deletions(-) delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md rename dotnet/samples/02-agents/AgentSkills/{Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj => Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj} (86%) create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py create mode 100644 dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs rename dotnet/src/Microsoft.Agents.AI/Skills/{FileAgentSkillLoader.cs => File/AgentFileSkillsSource.cs} (51%) create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index a4ffe13958..a5b93487b3 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -104,7 +104,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs deleted file mode 100644 index 9b0a4b4f99..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates how to use Agent Skills with a ChatClientAgent. -// Agent Skills are modular packages of instructions and resources that extend an agent's capabilities. -// Skills follow the progressive disclosure pattern: advertise -> load -> read resources. -// -// This sample includes the expense-report skill: -// - Policy-based expense filing with references and assets - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using OpenAI.Responses; - -// --- Configuration --- -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -// --- Skills Provider --- -// Discovers skills from the 'skills' directory and makes them available to the agent -var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills")); - -// --- Agent Setup --- -AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) - .GetResponsesClient() - .AsAIAgent(new ChatClientAgentOptions - { - Name = "SkillsAgent", - ChatOptions = new() - { - Instructions = "You are a helpful assistant.", - }, - AIContextProviders = [skillsProvider], - }, - model: deploymentName); - -// --- Example 1: Expense policy question (loads FAQ resource) --- -Console.WriteLine("Example 1: Checking expense policy FAQ"); -Console.WriteLine("---------------------------------------"); -AgentResponse response1 = await agent.RunAsync("Are tips reimbursable? I left a 25% tip on a taxi ride and want to know if that's covered."); -Console.WriteLine($"Agent: {response1.Text}\n"); - -// --- Example 2: Filing an expense report (multi-turn with template asset) --- -Console.WriteLine("Example 2: Filing an expense report"); -Console.WriteLine("---------------------------------------"); -AgentSession session = await agent.CreateSessionAsync(); -AgentResponse response2 = await agent.RunAsync("I had 3 client dinners and a $1,200 flight last week. Return a draft expense report and ask about any missing details.", - session); -Console.WriteLine($"Agent: {response2.Text}\n"); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md deleted file mode 100644 index 78099fa8a5..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# Agent Skills Sample - -This sample demonstrates how to use **Agent Skills** with a `ChatClientAgent` in the Microsoft Agent Framework. - -## What are Agent Skills? - -Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement the progressive disclosure pattern: - -1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill) -2. **Load**: Full instructions are loaded on-demand via `load_skill` tool -3. **Resources**: References and other files loaded via `read_skill_resource` tool - -## Skills Included - -### expense-report -Policy-based expense filing with spending limits, receipt requirements, and approval workflows. -- `references/POLICY_FAQ.md` — Detailed expense policy Q&A -- `assets/expense-report-template.md` — Submission template - -## Project Structure - -``` -Agent_Step01_BasicSkills/ -├── Program.cs -├── Agent_Step01_BasicSkills.csproj -└── skills/ - └── expense-report/ - ├── SKILL.md - ├── references/ - │ └── POLICY_FAQ.md - └── assets/ - └── expense-report-template.md -``` - -## Running the Sample - -### Prerequisites -- .NET 10.0 SDK -- Azure OpenAI endpoint with a deployed model - -### Setup -1. Set environment variables: - ```bash - export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" - export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" - ``` - -2. Run the sample: - ```bash - dotnet run - ``` - -### Examples - -The sample runs two examples: - -1. **Expense policy FAQ** — Asks about tip reimbursement; the agent loads the expense-report skill and reads the FAQ resource -2. **Filing an expense report** — Multi-turn conversation to draft an expense report using the template asset - -## Learn More - -- [Agent Skills Specification](https://agentskills.io/) -- [Microsoft Agent Framework Documentation](../../../../../docs/) diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md deleted file mode 100644 index fc6c83cf30..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: expense-report -description: File and validate employee expense reports according to Contoso company policy. Use when asked about expense submissions, reimbursement rules, receipt requirements, spending limits, or expense categories. -metadata: - author: contoso-finance - version: "2.1" ---- - -# Expense Report - -## Categories and Limits - -| Category | Limit | Receipt | Approval | -|---|---|---|---| -| Meals — solo | $50/day | >$25 | No | -| Meals — team/client | $75/person | Always | Manager if >$200 total | -| Lodging | $250/night | Always | Manager if >3 nights | -| Ground transport | $100/day | >$15 | No | -| Airfare | Economy | Always | Manager; VP if >$1,500 | -| Conference/training | $2,000/event | Always | Manager + L&D | -| Office supplies | $100 | Yes | No | -| Software/subscriptions | $50/month | Yes | Manager if >$200/year | - -## Filing Process - -1. Collect receipts — must show vendor, date, amount, payment method. -2. Categorize per table above. -3. Use template: [assets/expense-report-template.md](assets/expense-report-template.md). -4. For client/team meals: list attendee names and business purpose. -5. Submit — auto-approved if <$500; manager if $500–$2,000; VP if >$2,000. -6. Reimbursement: 10 business days via direct deposit. - -## Policy Rules - -- Submit within 30 days of transaction. -- Alcohol is never reimbursable. -- Foreign currency: convert to USD at transaction-date rate; note original currency and amount. -- Mixed personal/business travel: only business portion reimbursable; provide comparison quotes. -- Lost receipts (>$25): file Lost Receipt Affidavit from Finance. Max 2 per quarter. -- For policy questions not covered above, consult the FAQ: [references/POLICY_FAQ.md](references/POLICY_FAQ.md). Answers should be based on what this document and the FAQ state. diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md deleted file mode 100644 index 3f7c7dc36c..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md +++ /dev/null @@ -1,5 +0,0 @@ -# Expense Report Template - -| Date | Category | Vendor | Description | Amount (USD) | Original Currency | Original Amount | Attendees | Business Purpose | Receipt Attached | -|------|----------|--------|-------------|--------------|-------------------|-----------------|-----------|------------------|------------------| -| | | | | | | | | | Yes or No | diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md deleted file mode 100644 index 8e971192f8..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md +++ /dev/null @@ -1,55 +0,0 @@ -# Expense Policy — Frequently Asked Questions - -## Meals - -**Q: Can I expense coffee or snacks during the workday?** -A: Daily coffee/snacks under $10 are not reimbursable (considered personal). Coffee purchased during a client meeting or team working session is reimbursable as a team meal. - -**Q: What if a team dinner exceeds the per-person limit?** -A: The $75/person limit applies as a guideline. Overages up to 20% are accepted with a written justification (e.g., "client dinner at venue chosen by client"). Overages beyond 20% require pre-approval from your VP. - -**Q: Do I need to list every attendee?** -A: Yes. For client meals, list the client's name and company. For team meals, list all employee names. For groups over 10, you may attach a separate attendee list. - -## Travel - -**Q: Can I book a premium economy or business class flight?** -A: Economy class is the standard. Premium economy is allowed for flights over 6 hours. Business class requires VP pre-approval and is generally reserved for flights over 10 hours or medical accommodation. - -**Q: What about ride-sharing (Uber/Lyft) vs. rental cars?** -A: Use ride-sharing for trips under 30 miles round-trip. Rent a car for multi-day travel or when ride-sharing would exceed $100/day. Always choose the compact/standard category unless traveling with 3+ people. - -**Q: Are tips reimbursable?** -A: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping. Tips above 20% require justification. - -## Lodging - -**Q: What if the $250/night limit isn't enough for the city I'm visiting?** -A: For high-cost cities (New York, San Francisco, London, Tokyo, Sydney), the limit is automatically increased to $350/night. No additional approval is needed. For other locations where rates are unusually high (e.g., during a major conference), request a per-trip exception from your manager before booking. - -**Q: Can I stay with friends/family instead and get a per-diem?** -A: No. Contoso reimburses actual lodging costs only, not per-diems. - -## Subscriptions and Software - -**Q: Can I expense a personal productivity tool?** -A: Software must be directly related to your job function. Tools like IDE licenses, design software, or project management apps are reimbursable. General productivity apps (note-taking, personal calendar) are not, unless your manager confirms a business need in writing. - -**Q: What about annual subscriptions?** -A: Annual subscriptions over $200 require manager approval before purchase. Submit the approval email with your expense report. - -## Receipts and Documentation - -**Q: My receipt is faded/damaged. What do I do?** -A: Try to obtain a duplicate from the vendor. If not possible, submit a Lost Receipt Affidavit (available from the Finance SharePoint site). You're limited to 2 affidavits per quarter. - -**Q: Do I need a receipt for parking meters or tolls?** -A: For amounts under $15, no receipt is required — just note the date, location, and amount. For $15 and above, a receipt or bank/credit card statement excerpt is required. - -## Approval and Reimbursement - -**Q: My manager is on leave. Who approves my report?** -A: Expense reports can be approved by your skip-level manager or any manager designated as an alternate approver in the expense system. - -**Q: Can I submit expenses from a previous quarter?** -A: The standard 30-day window applies. Expenses older than 30 days require a written explanation and VP approval. Expenses older than 90 days are not reimbursable except in extraordinary circumstances (extended leave, medical emergency) with CFO approval. diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj similarity index 86% rename from dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj rename to dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj index 2a503bbfb2..7e7e9ef0fa 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj @@ -14,6 +14,10 @@ + + + + diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs new file mode 100644 index 0000000000..b787bb86a3 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use file-based Agent Skills with a ChatClientAgent. +// Skills are discovered from SKILL.md files on disk and follow the progressive disclosure pattern: +// 1. Advertise — skill names and descriptions in the system prompt +// 2. Load — full instructions loaded on demand via load_skill tool +// 3. Read resources — reference files read via read_skill_resource tool +// 4. Run scripts — scripts executed via run_skill_script tool with a subprocess executor +// +// This sample uses a unit-converter skill that converts between miles, kilometers, pounds, and kilograms. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using OpenAI.Responses; + +// --- Configuration --- +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// --- Skills Provider --- +// Discovers skills from the 'skills' directory containing SKILL.md files. +// The script runner runs file-based scripts (e.g. Python) as local subprocesses. +var skillsProvider = new AgentSkillsProvider( + Path.Combine(AppContext.BaseDirectory, "skills"), + SubprocessScriptRunner.RunAsync); +// --- Agent Setup --- +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetResponsesClient() + .AsAIAgent(new ChatClientAgentOptions + { + Name = "UnitConverterAgent", + ChatOptions = new() + { + Instructions = "You are a helpful assistant that can convert units.", + }, + AIContextProviders = [skillsProvider], + }, + model: deploymentName); + +// --- Example: Unit conversion --- +Console.WriteLine("Converting units with file-based skills"); +Console.WriteLine(new string('-', 60)); + +AgentResponse response = await agent.RunAsync( + "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?"); + +Console.WriteLine($"Agent: {response.Text}"); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md new file mode 100644 index 0000000000..41b813b98f --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md @@ -0,0 +1,51 @@ +# File-Based Agent Skills Sample + +This sample demonstrates how to use **file-based Agent Skills** with a `ChatClientAgent`. + +## What it demonstrates + +- Discovering skills from `SKILL.md` files on disk via `AgentFileSkillsSource` +- The progressive disclosure pattern: advertise → load → read resources → run scripts +- Using the `AgentSkillsProvider` constructor with a skill directory path and script executor +- Running file-based scripts (Python) via a subprocess-based executor + +## Skills Included + +### unit-converter + +Converts between common units (miles↔km, pounds↔kg) using a multiplication factor. + +- `references/conversion-table.md` — Conversion factor table +- `scripts/convert.py` — Python script that performs the conversion + +## Running the Sample + +### Prerequisites + +- .NET 10.0 SDK +- Azure OpenAI endpoint with a deployed model +- Python 3 installed and available as `python3` on your PATH + +### Setup + +```bash +export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +### Run + +```bash +dotnet run +``` + +### Expected Output + +``` +Converting units with file-based skills +------------------------------------------------------------ +Agent: Here are your conversions: + +1. **26.2 miles → 42.16 km** (a marathon distance) +2. **75 kg → 165.35 lbs** +``` diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md new file mode 100644 index 0000000000..6a8e692ff2 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md @@ -0,0 +1,11 @@ +--- +name: unit-converter +description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms. +--- + +## Usage + +When the user requests a unit conversion: +1. First, review `references/conversion-table.md` to find the correct factor +2. Run the `scripts/convert.py` script with `--value --factor ` (e.g. `--value 26.2 --factor 1.60934`) +3. Present the converted value clearly with both units diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md new file mode 100644 index 0000000000..7a0160b854 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md @@ -0,0 +1,10 @@ +# Conversion Tables + +Formula: **result = value × factor** + +| From | To | Factor | +|-------------|-------------|----------| +| miles | kilometers | 1.60934 | +| kilometers | miles | 0.621371 | +| pounds | kilograms | 0.453592 | +| kilograms | pounds | 2.20462 | diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py new file mode 100644 index 0000000000..228c8809ff --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py @@ -0,0 +1,29 @@ +# Unit conversion script +# Converts a value using a multiplication factor: result = value × factor +# +# Usage: +# python scripts/convert.py --value 26.2 --factor 1.60934 +# python scripts/convert.py --value 75 --factor 2.20462 + +import argparse +import json + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Convert a value using a multiplication factor.", + epilog="Examples:\n" + " python scripts/convert.py --value 26.2 --factor 1.60934\n" + " python scripts/convert.py --value 75 --factor 2.20462", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--value", type=float, required=True, help="The numeric value to convert.") + parser.add_argument("--factor", type=float, required=True, help="The conversion factor from the table.") + args = parser.parse_args() + + result = round(args.value * args.factor, 4) + print(json.dumps({"value": args.value, "factor": args.factor, "result": result})) + + +if __name__ == "__main__": + main() diff --git a/dotnet/samples/02-agents/AgentSkills/README.md b/dotnet/samples/02-agents/AgentSkills/README.md index 8488ec9eed..75b850f077 100644 --- a/dotnet/samples/02-agents/AgentSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/README.md @@ -4,4 +4,4 @@ Samples demonstrating Agent Skills capabilities. | Sample | Description | |--------|-------------| -| [Agent_Step01_BasicSkills](Agent_Step01_BasicSkills/) | Using Agent Skills with a ChatClientAgent, including progressive disclosure and skill resources | +| [Agent_Step01_FileBasedSkills](Agent_Step01_FileBasedSkills/) | Define skills as `SKILL.md` files on disk with reference documents. Uses a unit-converter skill. | diff --git a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs new file mode 100644 index 0000000000..e95bde61df --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Sample subprocess-based skill script runner. +// Executes file-based skill scripts as local subprocesses. +// This is provided for demonstration purposes only. + +using System.Diagnostics; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +/// +/// Executes file-based skill scripts as local subprocesses. +/// +/// +/// This runner uses the script's absolute path, converts the arguments +/// to CLI flags, and returns captured output. It is intended for +/// demonstration purposes only. +/// +internal static class SubprocessScriptRunner +{ + /// + /// Runs a skill script as a local subprocess. + /// + public static async Task RunAsync( + AgentFileSkill skill, + AgentFileSkillScript script, + AIFunctionArguments arguments, + CancellationToken cancellationToken) + { + if (!File.Exists(script.FullPath)) + { + return $"Error: Script file not found: {script.FullPath}"; + } + + string extension = Path.GetExtension(script.FullPath); + string? interpreter = extension switch + { + ".py" => "python3", + ".js" => "node", + ".sh" => "bash", + ".ps1" => "pwsh", + _ => null, + }; + + var startInfo = new ProcessStartInfo + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(script.FullPath) ?? ".", + }; + + if (interpreter is not null) + { + startInfo.FileName = interpreter; + startInfo.ArgumentList.Add(script.FullPath); + } + else + { + startInfo.FileName = script.FullPath; + } + + if (arguments is not null) + { + foreach (var (key, value) in arguments) + { + if (value is bool boolValue) + { + if (boolValue) + { + startInfo.ArgumentList.Add(NormalizeKey(key)); + } + } + else if (value is not null) + { + startInfo.ArgumentList.Add(NormalizeKey(key)); + startInfo.ArgumentList.Add(value.ToString()!); + } + } + } + + Process? process = null; + try + { + process = Process.Start(startInfo); + if (process is null) + { + return $"Error: Failed to start process for script '{script.Name}'."; + } + + Task outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + string output = await outputTask.ConfigureAwait(false); + string error = await errorTask.ConfigureAwait(false); + + if (!string.IsNullOrEmpty(error)) + { + output += $"\nStderr:\n{error}"; + } + + if (process.ExitCode != 0) + { + output += $"\nScript exited with code {process.ExitCode}"; + } + + return string.IsNullOrEmpty(output) ? "(no output)" : output.Trim(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Kill the process on cancellation to avoid leaving orphaned subprocesses. + process?.Kill(entireProcessTree: true); + throw; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return $"Error: Failed to execute script '{script.Name}': {ex.Message}"; + } + finally + { + process?.Dispose(); + } + } + + /// + /// Normalizes a parameter key to a consistent --flag format. + /// Models may return keys with or without leading dashes (e.g., "value" vs "--value"). + /// + private static string NormalizeKey(string key) => "--" + key.TrimStart('-'); +} diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs index 584b7db422..1149f9a293 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs @@ -59,7 +59,7 @@ internal static class HostAgentFactory PushNotifications = false, }; - var invoiceQuery = new AgentSkill() + var invoiceQuery = new A2A.AgentSkill() { Id = "id_invoice_agent", Name = "InvoiceQuery", @@ -91,7 +91,7 @@ internal static class HostAgentFactory PushNotifications = false, }; - var policyQuery = new AgentSkill() + var policyQuery = new A2A.AgentSkill() { Id = "id_policy_agent", Name = "PolicyAgent", @@ -123,7 +123,7 @@ internal static class HostAgentFactory PushNotifications = false, }; - var logisticsQuery = new AgentSkill() + var logisticsQuery = new A2A.AgentSkill() { Id = "id_logistics_agent", Name = "LogisticsQuery", diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs new file mode 100644 index 0000000000..5f0a66808d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for all agent skills. +/// +/// +/// +/// A skill represents a domain-specific capability with instructions, resources, and scripts. +/// Concrete implementations include (filesystem-backed). +/// +/// +/// Skill metadata follows the Agent Skills specification. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkill +{ + /// + /// Gets the frontmatter metadata for this skill. + /// + /// + /// Contains the L1 discovery metadata (name, description, license, compatibility, etc.) + /// as defined by the Agent Skills specification. + /// + public abstract AgentSkillFrontmatter Frontmatter { get; } + + /// + /// Gets the full skill content. + /// + /// + /// For file-based skills this is the raw SKILL.md file content. + /// + public abstract string Content { get; } + + /// + /// Gets the resources associated with this skill, or if none. + /// + /// + /// The default implementation returns . + /// Override this property in derived classes to provide skill-specific resources. + /// + public virtual IReadOnlyList? Resources => null; + + /// + /// Gets the scripts associated with this skill, or if none. + /// + /// + /// The default implementation returns . + /// Override this property in derived classes to provide skill-specific scripts. + /// + public virtual IReadOnlyList? Scripts => null; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs new file mode 100644 index 0000000000..df087ff2bb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents the YAML frontmatter metadata parsed from a SKILL.md file. +/// +/// +/// +/// Frontmatter is the L1 (discovery) layer of the +/// Agent Skills specification. +/// It contains the minimal metadata needed to advertise a skill in the system prompt +/// without loading the full skill content. +/// +/// +/// The constructor validates the name and description against specification rules +/// and throws if either value is invalid. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillFrontmatter +{ + /// + /// Maximum allowed length for the skill name. + /// + internal const int MaxNameLength = 64; + + /// + /// Maximum allowed length for the skill description. + /// + internal const int MaxDescriptionLength = 1024; + + /// + /// Maximum allowed length for the compatibility field. + /// + internal const int MaxCompatibilityLength = 500; + + // Validates skill names per the Agent Skills specification (https://agentskills.io/specification#frontmatter): + // lowercase letters, numbers, and hyphens only; must not start or end with a hyphen; must not contain consecutive hyphens. + private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled); + + private string? _compatibility; + + /// + /// Initializes a new instance of the class. + /// + /// Skill name in kebab-case. + /// Skill description for discovery. + /// Optional compatibility information (max 500 chars). + /// + /// Thrown when , , or violates the + /// Agent Skills specification rules. + /// + public AgentSkillFrontmatter(string name, string description, string? compatibility = null) + { + if (!ValidateName(name, out string? reason) || + !ValidateDescription(description, out reason) || + !ValidateCompatibility(compatibility, out reason)) + { + throw new ArgumentException(reason); + } + + this.Name = name; + this.Description = description; + this._compatibility = compatibility; + } + + /// + /// Gets the skill name. Lowercase letters, numbers, and hyphens only; no leading, trailing, or consecutive hyphens. + /// + public string Name { get; } + + /// + /// Gets the skill description. Used for discovery in the system prompt. + /// + public string Description { get; } + + /// + /// Gets or sets an optional license name or reference. + /// + public string? License { get; set; } + + /// + /// Gets or sets optional compatibility information (max 500 chars). + /// + /// + /// Thrown when the value exceeds characters. + /// + public string? Compatibility + { + get => this._compatibility; + set + { + if (!ValidateCompatibility(value, out string? reason)) + { + throw new ArgumentException(reason); + } + + this._compatibility = value; + } + } + + /// + /// Gets or sets optional space-delimited list of pre-approved tools. + /// + public string? AllowedTools { get; set; } + + /// + /// Gets or sets the arbitrary key-value metadata for this skill. + /// + public AdditionalPropertiesDictionary? Metadata { get; set; } + + /// + /// Validates a skill name against specification rules. + /// + /// The skill name to validate (may be ). + /// When validation fails, contains a human-readable description of the failure. + /// if the name is valid; otherwise, . + public static bool ValidateName( + string? name, + [NotNullWhen(false)] out string? reason) + { + if (string.IsNullOrWhiteSpace(name)) + { + reason = "Skill name is required."; + return false; + } + + if (name.Length > MaxNameLength) + { + reason = $"Skill name must be {MaxNameLength} characters or fewer."; + return false; + } + + if (!s_validNameRegex.IsMatch(name)) + { + reason = "Skill name must use only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens."; + return false; + } + + reason = null; + return true; + } + + /// + /// Validates a skill description against specification rules. + /// + /// The skill description to validate (may be ). + /// When validation fails, contains a human-readable description of the failure. + /// if the description is valid; otherwise, . + public static bool ValidateDescription( + string? description, + [NotNullWhen(false)] out string? reason) + { + if (string.IsNullOrWhiteSpace(description)) + { + reason = "Skill description is required."; + return false; + } + + if (description.Length > MaxDescriptionLength) + { + reason = $"Skill description must be {MaxDescriptionLength} characters or fewer."; + return false; + } + + reason = null; + return true; + } + + /// + /// Validates an optional skill compatibility value against specification rules. + /// + /// The optional compatibility value to validate (may be ). + /// When validation fails, contains a human-readable description of the failure. + /// if the value is valid; otherwise, . + public static bool ValidateCompatibility( + string? compatibility, + [NotNullWhen(false)] out string? reason) + { + if (compatibility?.Length > MaxCompatibilityLength) + { + reason = $"Skill compatibility must be {MaxCompatibilityLength} characters or fewer."; + return false; + } + + reason = null; + return true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs new file mode 100644 index 0000000000..b3cfc3f117 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for skill resources. A resource provides supplementary content (references, assets) to a skill. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkillResource +{ + /// + /// Initializes a new instance of the class. + /// + /// The resource name (e.g., relative path or identifier). + /// An optional description of the resource. + protected AgentSkillResource(string name, string? description = null) + { + this.Name = Throw.IfNullOrWhitespace(name); + this.Description = description; + } + + /// + /// Gets the resource name. + /// + public string Name { get; } + + /// + /// Gets the optional resource description. + /// + public string? Description { get; } + + /// + /// Reads the resource content asynchronously. + /// + /// Optional service provider for dependency injection. + /// Cancellation token. + /// The resource content. + public abstract Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs new file mode 100644 index 0000000000..ad647d2eb0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for skill scripts. A script represents an executable action associated with a skill. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkillScript +{ + /// + /// Initializes a new instance of the class. + /// + /// The script name. + /// An optional description of the script. + protected AgentSkillScript(string name, string? description = null) + { + this.Name = Throw.IfNullOrWhitespace(name); + this.Description = description; + } + + /// + /// Gets the script name. + /// + public string Name { get; } + + /// + /// Gets the optional script description. + /// + public string? Description { get; } + + /// + /// Runs the script with the given arguments. + /// + /// The skill that owns this script. + /// Arguments for script execution. + /// Cancellation token. + /// The script execution result. + public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs new file mode 100644 index 0000000000..f2f87851c0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -0,0 +1,383 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Security; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// An that exposes agent skills from one or more instances. +/// +/// +/// +/// This provider implements the progressive disclosure pattern from the +/// Agent Skills specification: +/// +/// +/// Advertise — skill names and descriptions are injected into the system prompt. +/// Load — the full skill body is returned via the load_skill tool. +/// Read resources — supplementary content is read on demand via the read_skill_resource tool. +/// Run scripts — scripts are executed via the run_skill_script tool (when scripts exist). +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed partial class AgentSkillsProvider : AIContextProvider +{ + /// + /// Placeholder token for the generated skills list in the prompt template. + /// + private const string SkillsPlaceholder = "{skills}"; + + /// + /// Placeholder token for the script instructions in the prompt template. + /// + private const string ScriptInstructionsPlaceholder = "{script_instructions}"; + + /// + /// Placeholder token for the resource instructions in the prompt template. + /// + private const string ResourceInstructionsPlaceholder = "{resource_instructions}"; + + private const string DefaultSkillsInstructionPrompt = + """ + You have access to skills containing domain-specific knowledge and capabilities. + Each skill provides specialized instructions, reference documents, and assets for specific tasks. + + + {skills} + + + When a task aligns with a skill's domain, follow these steps in exact order: + - Use `load_skill` to retrieve the skill's instructions. + - Follow the provided guidance. + {resource_instructions} + {script_instructions} + Only load what is needed, when it is needed. + """; + + private readonly AgentSkillsSource _source; + private readonly AgentSkillsProviderOptions? _options; + private readonly ILogger _logger; + private Task? _contextTask; + + /// + /// Initializes a new instance of the class + /// that discovers file-based skills from a single directory. + /// Duplicate skill names are automatically deduplicated (first occurrence wins). + /// + /// Path to search for skills. + /// Optional delegate that runs file-based scripts. Required only when skills contain scripts. + /// Optional options that control skill discovery behavior. + /// Optional provider configuration. + /// Optional logger factory. + public AgentSkillsProvider( + string skillPath, + AgentFileSkillScriptRunner? scriptRunner = null, + AgentFileSkillsSourceOptions? fileOptions = null, + AgentSkillsProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this([Throw.IfNull(skillPath)], scriptRunner, fileOptions, options, loggerFactory) + { + } + + /// + /// Initializes a new instance of the class + /// that discovers file-based skills from multiple directories. + /// Duplicate skill names are automatically deduplicated (first occurrence wins). + /// + /// Paths to search for skills. + /// Optional delegate that runs file-based scripts. Required only when skills contain scripts. + /// Optional options that control skill discovery behavior. + /// Optional provider configuration. + /// Optional logger factory. + public AgentSkillsProvider( + IEnumerable skillPaths, + AgentFileSkillScriptRunner? scriptRunner = null, + AgentFileSkillsSourceOptions? fileOptions = null, + AgentSkillsProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this( + new DeduplicatingAgentSkillsSource( + new AgentFileSkillsSource(skillPaths, scriptRunner, fileOptions, loggerFactory), + loggerFactory), + options, + loggerFactory) + { + } + + /// + /// Initializes a new instance of the class + /// from a custom . Unlike other constructors, this one does not + /// apply automatic deduplication, allowing callers to customize deduplication behavior via the source pipeline. + /// + /// The skill source providing skills. + /// Optional configuration. + /// Optional logger factory. + public AgentSkillsProvider(AgentSkillsSource source, AgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) + { + this._source = Throw.IfNull(source); + this._options = options; + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + + if (options?.SkillsInstructionPrompt is string prompt) + { + ValidatePromptTemplate(prompt, nameof(options)); + } + } + + /// + protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + if (this._options?.DisableCaching == true) + { + return await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false); + } + + return await this.GetOrCreateContextAsync(context, cancellationToken).ConfigureAwait(false); + } + + private async Task CreateContextAsync(InvokingContext context, CancellationToken cancellationToken) + { + var skills = await this._source.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + if (skills is not { Count: > 0 }) + { + return await base.ProvideAIContextAsync(context, cancellationToken).ConfigureAwait(false); + } + + bool hasScripts = skills.Any(s => s.Scripts is { Count: > 0 }); + bool hasResources = skills.Any(s => s.Resources is { Count: > 0 }); + + return new AIContext + { + Instructions = this.BuildSkillsInstructions(skills, includeScriptInstructions: hasScripts, hasResources), + Tools = this.BuildTools(skills, hasScripts, hasResources), + }; + } + + private async Task GetOrCreateContextAsync(InvokingContext context, CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + if (Interlocked.CompareExchange(ref this._contextTask, tcs.Task, null) is { } existing) + { + return await existing.ConfigureAwait(false); + } + + try + { + var result = await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false); + tcs.SetResult(result); + return result; + } + catch (Exception ex) + { + this._contextTask = null; + tcs.TrySetException(ex); + throw; + } + } + + private IList BuildTools(IList skills, bool hasScripts, bool hasResources) + { + IList tools = + [ + AIFunctionFactory.Create( + (string skillName) => this.LoadSkill(skills, skillName), + name: "load_skill", + description: "Loads the full content of a specific skill"), + ]; + + if (hasResources) + { + tools.Add(AIFunctionFactory.Create( + (string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) => + this.ReadSkillResourceAsync(skills, skillName, resourceName, serviceProvider, cancellationToken), + name: "read_skill_resource", + description: "Reads a resource associated with a skill, such as references, assets, or dynamic data.")); + } + + if (!hasScripts) + { + return tools; + } + + AIFunction scriptFunction = AIFunctionFactory.Create( + (string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) => + this.RunSkillScriptAsync(skills, skillName, scriptName, arguments, serviceProvider, cancellationToken), + name: "run_skill_script", + description: "Runs a script associated with a skill."); + + if (this._options?.ScriptApproval == true) + { + return [.. tools, new ApprovalRequiredAIFunction(scriptFunction)]; + } + + return [.. tools, scriptFunction]; + } + + private string? BuildSkillsInstructions(IList skills, bool includeScriptInstructions, bool includeResourceInstructions) + { + string promptTemplate = this._options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt; + + var sb = new StringBuilder(); + foreach (var skill in skills.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal)) + { + sb.AppendLine(" "); + sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Name)}"); + sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Description)}"); + sb.AppendLine(" "); + } + + string resourceInstruction = includeResourceInstructions + ? """ + - Use `read_skill_resource` to read any referenced resources, using the name exactly as listed + (e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`). + """ + : string.Empty; + + string scriptInstruction = includeScriptInstructions + ? "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed." + : string.Empty; + + return new StringBuilder(promptTemplate) + .Replace(SkillsPlaceholder, sb.ToString().TrimEnd()) + .Replace(ResourceInstructionsPlaceholder, resourceInstruction) + .Replace(ScriptInstructionsPlaceholder, scriptInstruction) + .ToString(); + } + + private string LoadSkill(IList skills, string skillName) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); + if (skill == null) + { + return $"Error: Skill '{skillName}' not found."; + } + + LogSkillLoading(this._logger, skillName); + + return skill.Content; + } + + private async Task ReadSkillResourceAsync(IList skills, string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + if (string.IsNullOrWhiteSpace(resourceName)) + { + return "Error: Resource name cannot be empty."; + } + + var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); + if (skill == null) + { + return $"Error: Skill '{skillName}' not found."; + } + + var resource = skill.Resources?.FirstOrDefault(resource => resource.Name == resourceName); + if (resource is null) + { + return $"Error: Resource '{resourceName}' not found in skill '{skillName}'."; + } + + try + { + return await resource.ReadAsync(serviceProvider, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LogResourceReadError(this._logger, skillName, resourceName, ex); + return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'."; + } + } + + private async Task RunSkillScriptAsync(IList skills, string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + if (string.IsNullOrWhiteSpace(scriptName)) + { + return "Error: Script name cannot be empty."; + } + + var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); + if (skill == null) + { + return $"Error: Skill '{skillName}' not found."; + } + + var script = skill.Scripts?.FirstOrDefault(resource => resource.Name == scriptName); + if (script is null) + { + return $"Error: Script '{scriptName}' not found in skill '{skillName}'."; + } + + try + { + return await script.RunAsync(skill, new AIFunctionArguments(arguments) { Services = serviceProvider }, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LogScriptExecutionError(this._logger, skillName, scriptName, ex); + return $"Error: Failed to execute script '{scriptName}' from skill '{skillName}'."; + } + } + + /// + /// Validates that a custom prompt template contains the required placeholder tokens. + /// + private static void ValidatePromptTemplate(string template, string paramName) + { + if (template.IndexOf(SkillsPlaceholder, StringComparison.Ordinal) < 0) + { + throw new ArgumentException( + $"The custom prompt template must contain the '{SkillsPlaceholder}' placeholder for the generated skills list.", + paramName); + } + + if (template.IndexOf(ResourceInstructionsPlaceholder, StringComparison.Ordinal) < 0) + { + throw new ArgumentException( + $"The custom prompt template must contain the '{ResourceInstructionsPlaceholder}' placeholder for resource instructions.", + paramName); + } + + if (template.IndexOf(ScriptInstructionsPlaceholder, StringComparison.Ordinal) < 0) + { + throw new ArgumentException( + $"The custom prompt template must contain the '{ScriptInstructionsPlaceholder}' placeholder for script instructions.", + paramName); + } + } + + [LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")] + private static partial void LogSkillLoading(ILogger logger, string skillName); + + [LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")] + private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception); + + [LoggerMessage(LogLevel.Error, "Failed to execute script '{ScriptName}' from skill '{SkillName}'")] + private static partial void LogScriptExecutionError(ILogger logger, string skillName, string scriptName, Exception exception); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs new file mode 100644 index 0000000000..17d7f2d6f3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Fluent builder for constructing an backed by a composite source. +/// +/// +/// +/// var provider = new AgentSkillsProviderBuilder() +/// .UseFileSkills("/path/to/skills") +/// .Build(); +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillsProviderBuilder +{ + private readonly List> _sourceFactories = []; + private AgentSkillsProviderOptions? _options; + private ILoggerFactory? _loggerFactory; + private AgentFileSkillScriptRunner? _scriptRunner; + private Func? _filter; + + /// + /// Adds a file-based skill source that discovers skills from a filesystem directory. + /// + /// Path to search for skills. + /// Optional options that control skill discovery behavior. + /// + /// Optional runner for file-based scripts. When provided, overrides the builder-level runner + /// set via . + /// + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFileSkill(string skillPath, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptRunner? scriptRunner = null) + { + return this.UseFileSkills([skillPath], options, scriptRunner); + } + + /// + /// Adds a file-based skill source that discovers skills from multiple filesystem directories. + /// + /// Paths to search for skills. + /// Optional options that control skill discovery behavior. + /// + /// Optional runner for file-based scripts. When provided, overrides the builder-level runner + /// set via . + /// + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFileSkills(IEnumerable skillPaths, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptRunner? scriptRunner = null) + { + this._sourceFactories.Add((builderScriptRunner, loggerFactory) => + { + var resolvedRunner = scriptRunner + ?? builderScriptRunner + ?? throw new InvalidOperationException($"File-based skill sources require a script runner. Call {nameof(this.UseFileScriptRunner)} or pass a runner to {nameof(this.UseFileSkill)}/{nameof(this.UseFileSkills)}."); + return new AgentFileSkillsSource(skillPaths, resolvedRunner, options, loggerFactory); + }); + return this; + } + + /// + /// Adds a custom skill source. + /// + /// The custom skill source. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseSource(AgentSkillsSource source) + { + _ = Throw.IfNull(source); + this._sourceFactories.Add((_, _) => source); + return this; + } + + /// + /// Sets a custom system prompt template. + /// + /// The prompt template with {skills} placeholder for the skills list, + /// {resource_instructions} for optional resource instructions, + /// and {script_instructions} for optional script instructions. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UsePromptTemplate(string promptTemplate) + { + this.GetOrCreateOptions().SkillsInstructionPrompt = promptTemplate; + return this; + } + + /// + /// Enables or disables the script approval gate. + /// + /// Whether script execution requires approval. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseScriptApproval(bool enabled = true) + { + this.GetOrCreateOptions().ScriptApproval = enabled; + return this; + } + + /// + /// Sets the runner for file-based skill scripts. + /// + /// The delegate that runs file-based scripts. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFileScriptRunner(AgentFileSkillScriptRunner runner) + { + this._scriptRunner = Throw.IfNull(runner); + return this; + } + + /// + /// Sets the logger factory. + /// + /// The logger factory. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseLoggerFactory(ILoggerFactory loggerFactory) + { + this._loggerFactory = loggerFactory; + return this; + } + + /// + /// Sets a filter predicate that controls which skills are included. + /// + /// + /// Skills for which the predicate returns are kept; + /// others are excluded. Only one filter is supported; calling this method + /// again replaces any previously set filter. + /// + /// A predicate that determines which skills to include. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFilter(Func predicate) + { + _ = Throw.IfNull(predicate); + this._filter = predicate; + return this; + } + + /// + /// Configures the using the provided delegate. + /// + /// A delegate to configure the options. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseOptions(Action configure) + { + _ = Throw.IfNull(configure); + configure(this.GetOrCreateOptions()); + return this; + } + + /// + /// Builds the . + /// + /// A configured . + public AgentSkillsProvider Build() + { + var resolvedSources = new List(this._sourceFactories.Count); + foreach (var factory in this._sourceFactories) + { + resolvedSources.Add(factory(this._scriptRunner, this._loggerFactory)); + } + + AgentSkillsSource source; + if (resolvedSources.Count == 1) + { + source = resolvedSources[0]; + } + else + { + source = new AggregatingAgentSkillsSource(resolvedSources); + } + + // Apply user-specified filter, then dedup. + if (this._filter != null) + { + source = new FilteringAgentSkillsSource(source, this._filter, this._loggerFactory); + } + + source = new DeduplicatingAgentSkillsSource(source, this._loggerFactory); + + return new AgentSkillsProvider(source, this._options, this._loggerFactory); + } + + private AgentSkillsProviderOptions GetOrCreateOptions() + { + return this._options ??= new AgentSkillsProviderOptions(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs new file mode 100644 index 0000000000..2f89ebfda6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Configuration options for . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillsProviderOptions +{ + /// + /// Gets or sets a custom system prompt template for advertising skills. + /// The template must contain {skills} as the placeholder for the generated skills list, + /// {resource_instructions} for resource instructions, + /// and {script_instructions} for script instructions. + /// When , a default template is used. + /// + public string? SkillsInstructionPrompt { get; set; } + + /// + /// Gets or sets a value indicating whether script execution requires approval. + /// When , script execution is blocked until approved. + /// Defaults to . + /// + public bool ScriptApproval { get; set; } + + /// + /// Gets or sets a value indicating whether caching of tools and instructions is disabled. + /// When (the default), the provider caches the tools and instructions + /// after the first build and returns the cached instance on subsequent calls. + /// Set to to rebuild tools and instructions on every invocation. + /// + public bool DisableCaching { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs new file mode 100644 index 0000000000..6a72d0c01a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for skill sources. A skill source provides skills from a specific origin +/// (filesystem, remote server, database, in-memory, etc.). +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkillsSource +{ + /// + /// Gets the skills provided by this source. + /// + /// Cancellation token. + /// A collection of skills from this source. + public abstract Task> GetSkillsAsync(CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs new file mode 100644 index 0000000000..7dc468742f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source that aggregates multiple child sources, preserving their registration order. +/// +/// +/// Skills from each child source are returned in the order the sources were registered, +/// with each source's skills appended sequentially. No deduplication or filtering is applied. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class AggregatingAgentSkillsSource : AgentSkillsSource +{ + private readonly IEnumerable _sources; + + /// + /// Initializes a new instance of the class. + /// + /// The child sources to aggregate. + public AggregatingAgentSkillsSource(IEnumerable sources) + { + this._sources = Throw.IfNull(sources); + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var allSkills = new List(); + foreach (var source in this._sources) + { + var skills = await source.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + allSkills.AddRange(skills); + } + + return allSkills; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs new file mode 100644 index 0000000000..bf943daae5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source decorator that removes duplicate skills by name, keeping only the first occurrence. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed partial class DeduplicatingAgentSkillsSource : DelegatingAgentSkillsSource +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The inner source to deduplicate. + /// Optional logger factory. + public DeduplicatingAgentSkillsSource(AgentSkillsSource innerSource, ILoggerFactory? loggerFactory = null) + : base(innerSource) + { + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + + var deduplicated = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var skill in allSkills) + { + if (seen.Add(skill.Frontmatter.Name)) + { + deduplicated.Add(skill); + } + else + { + LogDuplicateSkillName(this._logger, skill.Frontmatter.Name); + } + } + + return deduplicated; + } + + [LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': subsequent skill skipped in favor of first occurrence")] + private static partial void LogDuplicateSkillName(ILogger logger, string skillName); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs new file mode 100644 index 0000000000..920ad0428b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides an abstract base class for skill sources that delegate operations to an inner source +/// while allowing for extensibility and customization. +/// +/// +/// implements the decorator pattern for , +/// enabling the creation of source pipelines where each layer can add functionality (caching, deduplication, +/// filtering, etc.) while delegating core operations to an underlying source. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal abstract class DelegatingAgentSkillsSource : AgentSkillsSource +{ + /// + /// Initializes a new instance of the class with the specified inner source. + /// + /// The underlying skill source that will handle the core operations. + protected DelegatingAgentSkillsSource(AgentSkillsSource innerSource) + { + this.InnerSource = Throw.IfNull(innerSource); + } + + /// + /// Gets the inner skill source that receives delegated operations. + /// + protected AgentSkillsSource InnerSource { get; } + + /// + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + => this.InnerSource.GetSkillsAsync(cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs new file mode 100644 index 0000000000..2bd26acce2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source decorator that filters skills using a caller-supplied predicate. +/// +/// +/// Skills for which the predicate returns are included in the result; +/// skills for which it returns are excluded and logged at debug level. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed partial class FilteringAgentSkillsSource : DelegatingAgentSkillsSource +{ + private readonly Func _predicate; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The inner source whose skills will be filtered. + /// + /// A predicate that determines which skills to include. Skills for which the predicate + /// returns are kept; others are excluded. + /// + /// Optional logger factory. + public FilteringAgentSkillsSource( + AgentSkillsSource innerSource, + Func predicate, + ILoggerFactory? loggerFactory = null) + : base(innerSource) + { + this._predicate = Throw.IfNull(predicate); + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + + var filtered = new List(); + foreach (var skill in allSkills) + { + if (this._predicate(skill)) + { + filtered.Add(skill); + } + else + { + LogSkillFiltered(this._logger, skill.Frontmatter.Name); + } + } + + return filtered; + } + + [LoggerMessage(LogLevel.Debug, "Skill '{SkillName}' excluded by filter predicate")] + private static partial void LogSkillFiltered(ILogger logger, string skillName); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs new file mode 100644 index 0000000000..4bb62e99a8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// An discovered from a filesystem directory backed by a SKILL.md file. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentFileSkill : AgentSkill +{ + private readonly IReadOnlyList _resources; + private readonly IReadOnlyList _scripts; + + /// + /// Initializes a new instance of the class. + /// + /// The parsed frontmatter metadata for this skill. + /// The full raw SKILL.md file content including YAML frontmatter. + /// Absolute path to the directory containing this skill. + /// Resources discovered for this skill. + /// Scripts discovered for this skill. + internal AgentFileSkill( + AgentSkillFrontmatter frontmatter, + string content, + string path, + IReadOnlyList? resources = null, + IReadOnlyList? scripts = null) + { + this.Frontmatter = Throw.IfNull(frontmatter); + this.Content = Throw.IfNull(content); + this.Path = Throw.IfNullOrWhitespace(path); + this._resources = resources ?? []; + this._scripts = scripts ?? []; + } + + /// + public override AgentSkillFrontmatter Frontmatter { get; } + + /// + public override string Content { get; } + + /// + /// Gets the directory path where the skill was discovered. + /// + public string Path { get; } + + /// + public override IReadOnlyList Resources => this._resources; + + /// + public override IReadOnlyList Scripts => this._scripts; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs new file mode 100644 index 0000000000..9ba5b7e24a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A file-path-backed skill resource. Reads content from a file on disk relative to the skill directory. +/// +internal sealed class AgentFileSkillResource : AgentSkillResource +{ + /// + /// Initializes a new instance of the class. + /// + /// The resource name (relative path within the skill directory). + /// The absolute file path to the resource. + public AgentFileSkillResource(string name, string fullPath) + : base(name) + { + this.FullPath = Throw.IfNullOrWhitespace(fullPath); + } + + /// + /// Gets the absolute file path to the resource. + /// + public string FullPath { get; } + + /// + public override async Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { +#if NET8_0_OR_GREATER + return await File.ReadAllTextAsync(this.FullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); +#else + using var reader = new StreamReader(this.FullPath, Encoding.UTF8); + return await reader.ReadToEndAsync().ConfigureAwait(false); +#endif + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs new file mode 100644 index 0000000000..116847126f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A file-path-backed skill script. Represents a script file on disk that requires an external runner to run. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentFileSkillScript : AgentSkillScript +{ + private readonly AgentFileSkillScriptRunner? _runner; + + /// + /// Initializes a new instance of the class. + /// + /// The script name. + /// The absolute file path to the script. + /// Optional external runner for running the script. An is thrown from if no runner is provided. + internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptRunner? runner = null) + : base(name) + { + this.FullPath = Throw.IfNullOrWhitespace(fullPath); + this._runner = runner; + } + + /// + /// Gets the absolute file path to the script. + /// + public string FullPath { get; } + + /// + public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) + { + if (skill is not AgentFileSkill fileSkill) + { + throw new InvalidOperationException($"File-based script '{this.Name}' requires an {nameof(AgentFileSkill)} but received '{skill.GetType().Name}'."); + } + + if (this._runner is null) + { + throw new InvalidOperationException( + $"Script '{this.Name}' cannot be executed because no {nameof(AgentFileSkillScriptRunner)} was provided. " + + $"Supply a script runner when constructing {nameof(AgentFileSkillsSource)} to enable script execution."); + } + + return await this._runner(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs new file mode 100644 index 0000000000..c19d19e056 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Delegate for running file-based skill scripts. +/// +/// +/// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment). +/// +/// The skill that owns the script. +/// The file-based script to run. +/// Optional arguments for the script, provided by the agent/LLM. +/// Cancellation token. +/// The script execution result. +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public delegate Task AgentFileSkillScriptRunner( + AgentFileSkill skill, + AgentFileSkillScript script, + AIFunctionArguments arguments, + CancellationToken cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs similarity index 51% rename from dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs rename to dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs index 18fa87999a..c6dc3bc629 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -2,151 +2,135 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; /// -/// Discovers, parses, and validates SKILL.md files from filesystem directories. +/// A skill source that discovers skills from filesystem directories containing SKILL.md files. /// /// -/// Searches directories recursively (up to levels) for SKILL.md files. -/// Each file is validated for YAML frontmatter. Resource files are discovered by scanning the skill +/// Searches directories recursively (up to 2 levels deep) for SKILL.md files. +/// Each file is validated for YAML frontmatter. Resource and script files are discovered by scanning the skill /// directory for files with matching extensions. Invalid resources are skipped with logged warnings. -/// Resource paths are checked against path traversal and symlink escape attacks. +/// Resource and script paths are checked against path traversal and symlink escape attacks. /// -internal sealed partial class FileAgentSkillLoader +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed partial class AgentFileSkillsSource : AgentSkillsSource { private const string SkillFileName = "SKILL.md"; private const int MaxSearchDepth = 2; - private const int MaxNameLength = 64; - private const int MaxDescriptionLength = 1024; + + private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"]; + private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"]; // Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters. // Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block. // The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend. - // Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n" private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - // Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value. + // Matches top-level YAML "key: value" lines. Group 1 = key (supports hyphens for keys like allowed-tools), + // Group 2 = quoted value, Group 3 = unquoted value. // Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values. - // Examples: "name: foo" → (name, _, foo), "name: 'foo bar'" → (name, foo bar, _), - // "description: \"A skill\"" → (description, A skill, _) - private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + private static readonly Regex s_yamlKeyValueRegex = new(@"^([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - // Validates skill names: lowercase letters, numbers, and hyphens only; - // must not start or end with a hyphen; must not contain consecutive hyphens. - // Examples: "my-skill" ✓, "skill123" ✓, "-bad" ✗, "bad-" ✗, "Bad" ✗, "my--skill" ✗ - private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled); + // Matches a "metadata:" line followed by indented sub-key/value pairs. + // Group 1 captures the entire indented block beneath the metadata key. + private static readonly Regex s_yamlMetadataBlockRegex = new(@"^metadata\s*:\s*$\n((?:[ \t]+\S.*\n?)+)", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - private readonly ILogger _logger; + // Matches indented YAML "key: value" lines within a metadata block. + // Group 1 = key (supports hyphens), Group 2 = quoted value, Group 3 = unquoted value. + private static readonly Regex s_yamlIndentedKeyValueRegex = new(@"^\s+([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + + private readonly IEnumerable _skillPaths; private readonly HashSet _allowedResourceExtensions; + private readonly HashSet _allowedScriptExtensions; + private readonly AgentFileSkillScriptRunner? _scriptRunner; + private readonly ILogger _logger; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The logger instance. - /// File extensions to recognize as skill resources. When , defaults are used. - internal FileAgentSkillLoader(ILogger logger, IEnumerable? allowedResourceExtensions = null) + /// Path to search for skills. + /// Optional runner for file-based scripts. Required only when skills contain scripts. + /// Optional options that control skill discovery behavior. + /// Optional logger factory. + public AgentFileSkillsSource( + string skillPath, + AgentFileSkillScriptRunner? scriptRunner = null, + AgentFileSkillsSourceOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this([skillPath], scriptRunner, options, loggerFactory) { - this._logger = logger; - - ValidateExtensions(allowedResourceExtensions); - - this._allowedResourceExtensions = new HashSet( - allowedResourceExtensions ?? [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"], - StringComparer.OrdinalIgnoreCase); } /// - /// Discovers skill directories and loads valid skills from them. + /// Initializes a new instance of the class. /// - /// Paths to search for skills. Each path can point to an individual skill folder or a parent folder. - /// A dictionary of loaded skills keyed by skill name. - internal Dictionary DiscoverAndLoadSkills(IEnumerable skillPaths) + /// Paths to search for skills. + /// Optional runner for file-based scripts. Required only when skills contain scripts. + /// Optional options that control skill discovery behavior. + /// Optional logger factory. + public AgentFileSkillsSource( + IEnumerable skillPaths, + AgentFileSkillScriptRunner? scriptRunner = null, + AgentFileSkillsSourceOptions? options = null, + ILoggerFactory? loggerFactory = null) { - var skills = new Dictionary(StringComparer.OrdinalIgnoreCase); + this._skillPaths = Throw.IfNull(skillPaths); - var discoveredPaths = DiscoverSkillDirectories(skillPaths); + var resolvedOptions = options ?? new AgentFileSkillsSourceOptions(); + + ValidateExtensions(resolvedOptions.AllowedResourceExtensions); + ValidateExtensions(resolvedOptions.AllowedScriptExtensions); + + this._allowedResourceExtensions = new HashSet( + resolvedOptions.AllowedResourceExtensions ?? s_defaultResourceExtensions, + StringComparer.OrdinalIgnoreCase); + + this._allowedScriptExtensions = new HashSet( + resolvedOptions.AllowedScriptExtensions ?? s_defaultScriptExtensions, + StringComparer.OrdinalIgnoreCase); + + this._scriptRunner = scriptRunner; + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + /// + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var discoveredPaths = DiscoverSkillDirectories(this._skillPaths); LogSkillsDiscovered(this._logger, discoveredPaths.Count); + var skills = new List(); + foreach (string skillPath in discoveredPaths) { - FileAgentSkill? skill = this.ParseSkillFile(skillPath); + AgentFileSkill? skill = this.ParseSkillDirectory(skillPath); if (skill is null) { continue; } - if (skills.TryGetValue(skill.Frontmatter.Name, out FileAgentSkill? existing)) - { - LogDuplicateSkillName(this._logger, skill.Frontmatter.Name, skillPath, existing.SourcePath); - - // Skip duplicate skill names, keeping the first one found. - continue; - } - - skills[skill.Frontmatter.Name] = skill; + skills.Add(skill); LogSkillLoaded(this._logger, skill.Frontmatter.Name); } LogSkillsLoadedTotal(this._logger, skills.Count); - return skills; - } - - /// - /// Reads a resource file from disk with path traversal and symlink guards. - /// - /// The skill that owns the resource. - /// Relative path of the resource within the skill directory. - /// Cancellation token. - /// The UTF-8 text content of the resource file. - /// - /// The resource is not registered, resolves outside the skill directory, or does not exist. - /// - internal async Task ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default) - { - resourceName = NormalizeResourcePath(resourceName); - - if (!skill.ResourceNames.Any(r => r.Equals(resourceName, StringComparison.OrdinalIgnoreCase))) - { - throw new InvalidOperationException($"Resource '{resourceName}' not found in skill '{skill.Frontmatter.Name}'."); - } - - string fullPath = Path.GetFullPath(Path.Combine(skill.SourcePath, resourceName)); - string normalizedSourcePath = Path.GetFullPath(skill.SourcePath) + Path.DirectorySeparatorChar; - - if (!IsPathWithinDirectory(fullPath, normalizedSourcePath)) - { - throw new InvalidOperationException($"Resource file '{resourceName}' references a path outside the skill directory."); - } - - if (!File.Exists(fullPath)) - { - throw new InvalidOperationException($"Resource file '{resourceName}' not found in skill '{skill.Frontmatter.Name}'."); - } - - if (HasSymlinkInPath(fullPath, normalizedSourcePath)) - { - throw new InvalidOperationException($"Resource file '{resourceName}' is a symlink that resolves outside the skill directory."); - } - - LogResourceReading(this._logger, resourceName, skill.Frontmatter.Name); - -#if NET - return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); -#else - return await Task.FromResult(File.ReadAllText(fullPath, Encoding.UTF8)).ConfigureAwait(false); -#endif + return Task.FromResult(skills as IList); } private static List DiscoverSkillDirectories(IEnumerable skillPaths) @@ -185,30 +169,30 @@ internal sealed partial class FileAgentSkillLoader } } - private FileAgentSkill? ParseSkillFile(string skillDirectoryFullPath) + private AgentFileSkill? ParseSkillDirectory(string skillDirectoryFullPath) { string skillFilePath = Path.Combine(skillDirectoryFullPath, SkillFileName); - string content = File.ReadAllText(skillFilePath, Encoding.UTF8); - if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body)) + if (!this.TryParseFrontmatter(content, skillFilePath, out AgentSkillFrontmatter? frontmatter)) { return null; } - List resourceNames = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name); + var resources = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name); + var scripts = this.DiscoverScriptFiles(skillDirectoryFullPath, frontmatter.Name); - return new FileAgentSkill( + return new AgentFileSkill( frontmatter: frontmatter, - body: body, - sourcePath: skillDirectoryFullPath, - resourceNames: resourceNames); + content: content, + path: skillDirectoryFullPath, + resources: resources, + scripts: scripts); } - private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body) + private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullWhen(true)] out AgentSkillFrontmatter? frontmatter) { - frontmatter = null!; - body = null!; + frontmatter = null; Match match = s_frontmatterRegex.Match(content); if (!match.Success) @@ -217,10 +201,13 @@ internal sealed partial class FileAgentSkillLoader return false; } + string yamlContent = match.Groups[1].Value.Trim(); + string? name = null; string? description = null; - - string yamlContent = match.Groups[1].Value.Trim(); + string? license = null; + string? compatibility = null; + string? allowedTools = null; foreach (Match kvMatch in s_yamlKeyValueRegex.Matches(yamlContent)) { @@ -235,50 +222,62 @@ internal sealed partial class FileAgentSkillLoader { description = value; } + else if (string.Equals(key, "license", StringComparison.OrdinalIgnoreCase)) + { + license = value; + } + else if (string.Equals(key, "compatibility", StringComparison.OrdinalIgnoreCase)) + { + compatibility = value; + } + else if (string.Equals(key, "allowed-tools", StringComparison.OrdinalIgnoreCase)) + { + allowedTools = value; + } } - if (string.IsNullOrWhiteSpace(name)) + // Parse metadata block (indented key-value pairs under "metadata:"). + AdditionalPropertiesDictionary? metadata = null; + Match metadataMatch = s_yamlMetadataBlockRegex.Match(yamlContent); + if (metadataMatch.Success) { - LogMissingFrontmatterField(this._logger, skillFilePath, "name"); + metadata = []; + foreach (Match kvMatch in s_yamlIndentedKeyValueRegex.Matches(metadataMatch.Groups[1].Value)) + { + metadata[kvMatch.Groups[1].Value] = kvMatch.Groups[2].Success ? kvMatch.Groups[2].Value : kvMatch.Groups[3].Value; + } + } + + if (!AgentSkillFrontmatter.ValidateName(name, out string? validationReason) || + !AgentSkillFrontmatter.ValidateDescription(description, out validationReason)) + { + LogInvalidFieldValue(this._logger, skillFilePath, "frontmatter", validationReason); return false; } - if (name.Length > MaxNameLength || !s_validNameRegex.IsMatch(name)) + frontmatter = new AgentSkillFrontmatter(name!, description!, compatibility) { - LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens."); - return false; - } + License = license, + AllowedTools = allowedTools, + Metadata = metadata, + }; // skillFilePath is e.g. "/skills/my-skill/SKILL.md". // GetDirectoryName strips the filename → "/skills/my-skill". // GetFileName then extracts the last segment → "my-skill". // This gives us the skill's parent directory name to validate against the frontmatter name. string directoryName = Path.GetFileName(Path.GetDirectoryName(skillFilePath)) ?? string.Empty; - if (!string.Equals(name, directoryName, StringComparison.Ordinal)) + if (!string.Equals(frontmatter.Name, directoryName, StringComparison.Ordinal)) { if (this._logger.IsEnabled(LogLevel.Error)) { - LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), name, SanitizePathForLog(directoryName)); + LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), frontmatter.Name, SanitizePathForLog(directoryName)); } + frontmatter = null; return false; } - if (string.IsNullOrWhiteSpace(description)) - { - LogMissingFrontmatterField(this._logger, skillFilePath, "description"); - return false; - } - - if (description.Length > MaxDescriptionLength) - { - LogInvalidFieldValue(this._logger, skillFilePath, "description", $"Must be {MaxDescriptionLength} characters or fewer."); - return false; - } - - frontmatter = new SkillFrontmatter(name, description); - body = content.Substring(match.Index + match.Length).TrimStart(); - return true; } @@ -287,15 +286,15 @@ internal sealed partial class FileAgentSkillLoader /// /// /// Recursively walks and collects files whose extension - /// matches , excluding SKILL.md itself. Each candidate + /// matches the allowed set, excluding SKILL.md itself. Each candidate /// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with /// a warning. /// - private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName) + private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName) { string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; - var resources = new List(); + var resources = new List(); #if NET var enumerationOptions = new EnumerationOptions @@ -326,21 +325,21 @@ internal sealed partial class FileAgentSkillLoader { LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension); } + continue; } // Normalize the enumerated path to guard against non-canonical forms - // (redundant separators, 8.3 short names, etc.) that would produce - // malformed relative resource names. string resolvedFilePath = Path.GetFullPath(filePath); // Path containment check - if (!IsPathWithinDirectory(resolvedFilePath, normalizedSkillDirectoryFullPath)) + if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) { if (this._logger.IsEnabled(LogLevel.Warning)) { LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); } + continue; } @@ -351,30 +350,86 @@ internal sealed partial class FileAgentSkillLoader { LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); } + continue; } // Compute relative path and normalize to forward slashes - string relativePath = resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length); - resources.Add(NormalizeResourcePath(relativePath)); + string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); + resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath)); } return resources; } /// - /// Checks that is under , - /// guarding against path traversal attacks. + /// Scans a skill directory for script files matching the configured extensions. /// - private static bool IsPathWithinDirectory(string fullPath, string normalizedDirectoryPath) + /// + /// Recursively walks the skill directory and collects files whose extension + /// matches the allowed set. Each candidate is validated against path-traversal + /// and symlink-escape checks; unsafe files are skipped with a warning. + /// + private List DiscoverScriptFiles(string skillDirectoryFullPath, string skillName) { - return fullPath.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase); + string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; + var scripts = new List(); + +#if NET + var enumerationOptions = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint, + }; + + foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions)) +#else + foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories)) +#endif + { + // Filter by extension + string extension = Path.GetExtension(filePath); + if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension)) + { + continue; + } + + // Normalize the enumerated path to guard against non-canonical forms + string resolvedFilePath = Path.GetFullPath(filePath); + + // Path containment check + if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); + } + + continue; + } + + // Symlink check + if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); + } + + continue; + } + + // Compute relative path and normalize to forward slashes + string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); + scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner)); + } + + return scripts; } /// - /// Checks whether any segment in (relative to - /// ) is a symlink (reparse point). - /// Uses which is available on all target frameworks. + /// Checks whether any segment in the path (relative to the directory) is a symlink. /// private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath) { @@ -399,11 +454,10 @@ internal sealed partial class FileAgentSkillLoader } /// - /// Normalizes a relative resource path by trimming a leading ./ prefix and replacing - /// backslashes with forward slashes so that ./refs/doc.md and refs/doc.md are - /// treated as the same resource. + /// Normalizes a relative path by replacing backslashes with forward slashes + /// and trimming a leading "./" prefix. /// - private static string NormalizeResourcePath(string path) + private static string NormalizePath(string path) { if (path.IndexOf('\\') >= 0) { @@ -419,8 +473,7 @@ internal sealed partial class FileAgentSkillLoader } /// - /// Replaces control characters in a file path with '?' to prevent log injection - /// via crafted filenames (e.g., filenames containing newlines on Linux). + /// Replaces control characters in a file path with '?' to prevent log injection. /// private static string SanitizePathForLog(string path) { @@ -449,7 +502,7 @@ internal sealed partial class FileAgentSkillLoader if (string.IsNullOrWhiteSpace(ext) || !ext.StartsWith(".", StringComparison.Ordinal)) { #pragma warning disable CA2208 // Instantiate argument exceptions correctly - throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", nameof(FileAgentSkillsProviderOptions.AllowedResourceExtensions)); + throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", "allowedResourceExtensions"); #pragma warning restore CA2208 // Instantiate argument exceptions correctly } } @@ -467,9 +520,6 @@ internal sealed partial class FileAgentSkillLoader [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' does not contain valid YAML frontmatter delimited by '---'")] private static partial void LogInvalidFrontmatter(ILogger logger, string skillFilePath); - [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' is missing a '{FieldName}' field in frontmatter")] - private static partial void LogMissingFrontmatterField(ILogger logger, string skillFilePath, string fieldName); - [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")] private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason); @@ -479,15 +529,15 @@ internal sealed partial class FileAgentSkillLoader [LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' references a path outside the skill directory")] private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourcePath); - [LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': skill from '{NewPath}' skipped in favor of existing skill from '{ExistingPath}'")] - private static partial void LogDuplicateSkillName(ILogger logger, string skillName, string newPath, string existingPath); - [LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")] private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath); - [LoggerMessage(LogLevel.Information, "Reading resource '{FileName}' from skill '{SkillName}'")] - private static partial void LogResourceReading(ILogger logger, string fileName, string skillName); - [LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")] private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension); + + [LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' references a path outside the skill directory")] + private static partial void LogScriptPathTraversal(ILogger logger, string skillName, string scriptPath); + + [LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")] + private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs new file mode 100644 index 0000000000..edaec327fa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Configuration options for file-based skill sources. +/// +/// +/// Use this class to configure file-based skill discovery without relying on +/// positional constructor or method parameters. New options can be added here +/// without breaking existing callers. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentFileSkillsSourceOptions +{ + /// + /// Gets or sets the allowed file extensions for skill resources. + /// When , defaults to .md, .json, .yaml, + /// .yml, .csv, .xml, .txt. + /// + public IEnumerable? AllowedResourceExtensions { get; set; } + + /// + /// Gets or sets the allowed file extensions for skill scripts. + /// When , defaults to .py, .js, .sh, + /// .ps1, .cs, .csx. + /// + public IEnumerable? AllowedScriptExtensions { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs deleted file mode 100644 index f28bad3ab0..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// Represents a loaded Agent Skill discovered from a filesystem directory. -/// -/// -/// Each skill is backed by a SKILL.md file containing YAML frontmatter (name and description) -/// and a markdown body with instructions. Resource files referenced in the body are validated at -/// discovery time and read from disk on demand. -/// -internal sealed class FileAgentSkill -{ - /// - /// Initializes a new instance of the class. - /// - /// Parsed YAML frontmatter (name and description). - /// The SKILL.md content after the closing --- delimiter. - /// Absolute path to the directory containing this skill. - /// Relative paths of resource files referenced in the skill body. - public FileAgentSkill( - SkillFrontmatter frontmatter, - string body, - string sourcePath, - IReadOnlyList? resourceNames = null) - { - this.Frontmatter = Throw.IfNull(frontmatter); - this.Body = Throw.IfNull(body); - this.SourcePath = Throw.IfNullOrWhitespace(sourcePath); - this.ResourceNames = resourceNames ?? []; - } - - /// - /// Gets the parsed YAML frontmatter (name and description). - /// - public SkillFrontmatter Frontmatter { get; } - - /// - /// Gets the SKILL.md body content (without the YAML frontmatter). - /// - public string Body { get; } - - /// - /// Gets the directory path where the skill was discovered. - /// - public string SourcePath { get; } - - /// - /// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md"). - /// - public IReadOnlyList ResourceNames { get; } -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs deleted file mode 100644 index 460faced70..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Security; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// An that discovers and exposes Agent Skills from filesystem directories. -/// -/// -/// -/// This provider implements the progressive disclosure pattern from the -/// Agent Skills specification: -/// -/// -/// Advertise — skill names and descriptions are injected into the system prompt (~100 tokens per skill). -/// Load — the full SKILL.md body is returned via the load_skill tool. -/// Read resources — supplementary files are read from disk on demand via the read_skill_resource tool. -/// -/// -/// Skills are discovered by searching the configured directories for SKILL.md files. -/// Referenced resources are validated at initialization; invalid skills are excluded and logged. -/// -/// -/// Security: this provider only reads static content. Skill metadata is XML-escaped -/// before prompt embedding, and resource reads are guarded against path traversal and symlink escape. -/// Only use skills from trusted sources. -/// -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed partial class FileAgentSkillsProvider : AIContextProvider -{ - private const string DefaultSkillsInstructionPrompt = - """ - You have access to skills containing domain-specific knowledge and capabilities. - Each skill provides specialized instructions, reference documents, and assets for specific tasks. - - - {0} - - - When a task aligns with a skill's domain: - 1. Use `load_skill` to retrieve the skill's instructions - 2. Follow the provided guidance - 3. Use `read_skill_resource` to read any references or other files mentioned by the skill - - Only load what is needed, when it is needed. - """; - - private readonly Dictionary _skills; - private readonly ILogger _logger; - private readonly FileAgentSkillLoader _loader; - private readonly AITool[] _tools; - private readonly string? _skillsInstructionPrompt; - - /// - /// Initializes a new instance of the class that searches a single directory for skills. - /// - /// Path to an individual skill folder (containing a SKILL.md file) or a parent folder with skill subdirectories. - /// Optional configuration for prompt customization. - /// Optional logger factory. - public FileAgentSkillsProvider(string skillPath, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) - : this([skillPath], options, loggerFactory) - { - } - - /// - /// Initializes a new instance of the class that searches multiple directories for skills. - /// - /// Paths to search. Each can be an individual skill folder or a parent folder with skill subdirectories. - /// Optional configuration for prompt customization. - /// Optional logger factory. - public FileAgentSkillsProvider(IEnumerable skillPaths, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) - { - _ = Throw.IfNull(skillPaths); - - this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); - - this._loader = new FileAgentSkillLoader(this._logger, options?.AllowedResourceExtensions); - this._skills = this._loader.DiscoverAndLoadSkills(skillPaths); - - this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills); - - this._tools = - [ - AIFunctionFactory.Create( - this.LoadSkill, - name: "load_skill", - description: "Loads the full instructions for a specific skill."), - AIFunctionFactory.Create( - this.ReadSkillResourceAsync, - name: "read_skill_resource", - description: "Reads a file associated with a skill, such as references or assets."), - ]; - } - - /// - protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) - { - if (this._skills.Count == 0) - { - return base.ProvideAIContextAsync(context, cancellationToken); - } - - return new ValueTask(new AIContext - { - Instructions = this._skillsInstructionPrompt, - Tools = this._tools - }); - } - - private string LoadSkill(string skillName) - { - if (string.IsNullOrWhiteSpace(skillName)) - { - return "Error: Skill name cannot be empty."; - } - - if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill)) - { - return $"Error: Skill '{skillName}' not found."; - } - - LogSkillLoading(this._logger, skillName); - - return skill.Body; - } - - private async Task ReadSkillResourceAsync(string skillName, string resourceName, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(skillName)) - { - return "Error: Skill name cannot be empty."; - } - - if (string.IsNullOrWhiteSpace(resourceName)) - { - return "Error: Resource name cannot be empty."; - } - - if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill)) - { - return $"Error: Skill '{skillName}' not found."; - } - - try - { - return await this._loader.ReadSkillResourceAsync(skill, resourceName, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - LogResourceReadError(this._logger, skillName, resourceName, ex); - return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'."; - } - } - - private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary skills) - { - string promptTemplate = DefaultSkillsInstructionPrompt; - - if (options?.SkillsInstructionPrompt is { } optionsInstructions) - { - try - { - _ = string.Format(optionsInstructions, string.Empty); - } - catch (FormatException ex) - { - throw new ArgumentException( - "The provided SkillsInstructionPrompt is not a valid format string.", - nameof(options), - ex); - } - - if (optionsInstructions.IndexOf("{0}", StringComparison.Ordinal) < 0) - { - throw new ArgumentException( - "The provided SkillsInstructionPrompt must contain a '{0}' placeholder for the generated skills list.", - nameof(options)); - } - - promptTemplate = optionsInstructions; - } - - if (skills.Count == 0) - { - return null; - } - - var sb = new StringBuilder(); - - // Order by name for deterministic prompt output across process restarts - // (Dictionary enumeration order is not guaranteed and varies with hash randomization). - foreach (var skill in skills.Values.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal)) - { - sb.AppendLine(" "); - sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Name)}"); - sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Description)}"); - sb.AppendLine(" "); - } - - return string.Format(promptTemplate, sb.ToString().TrimEnd()); - } - - [LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")] - private static partial void LogSkillLoading(ILogger logger, string skillName); - - [LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")] - private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception); -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs deleted file mode 100644 index 600c5b964c..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.Agents.AI; - -/// -/// Configuration options for . -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class FileAgentSkillsProviderOptions -{ - /// - /// Gets or sets a custom system prompt template for advertising skills. - /// Use {0} as the placeholder for the generated skills list. - /// When , a default template is used. - /// - public string? SkillsInstructionPrompt { get; set; } - - /// - /// Gets or sets the file extensions recognized as discoverable skill resources. - /// Each value must start with a '.' character (for example, .md), and - /// extension comparisons are performed in a case-insensitive manner. - /// Files in the skill directory (and its subdirectories) whose extension matches - /// one of these values will be automatically discovered as resources. - /// When , a default set of extensions is used - /// (.md, .json, .yaml, .yml, .csv, .xml, .txt). - /// - public IEnumerable? AllowedResourceExtensions { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs deleted file mode 100644 index 123a6c43f4..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description. -/// -internal sealed class SkillFrontmatter -{ - /// - /// Initializes a new instance of the class. - /// - /// Skill name. - /// Skill description. - public SkillFrontmatter(string name, string description) - { - this.Name = Throw.IfNullOrWhitespace(name); - this.Description = Throw.IfNullOrWhitespace(description); - } - - /// - /// Gets the skill name. Lowercase letters, numbers, and hyphens only. - /// - public string Name { get; } - - /// - /// Gets the skill description. Used for discovery in the system prompt. - /// - public string Description { get; } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs new file mode 100644 index 0000000000..eb4f706f30 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentFileSkillScriptTests +{ + [Fact] + public async Task RunAsync_SkillIsNotAgentFileSkill_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult("result"); + var script = CreateScript("test-script", "/path/to/script.py", RunnerAsync); + var nonFileSkill = new TestAgentSkill("my-skill", "A skill", "Instructions."); + + // Act & Assert + await Assert.ThrowsAsync( + () => script.RunAsync(nonFileSkill, new AIFunctionArguments(), CancellationToken.None)); + } + + [Fact] + public async Task RunAsync_WithAgentFileSkill_DelegatesToRunnerAsync() + { + // Arrange + var runnerCalled = false; + Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + { + runnerCalled = true; + return Task.FromResult("executed"); + } + var script = CreateScript("run-me", "/scripts/run-me.sh", runnerAsync); + var fileSkill = new AgentFileSkill( + new AgentSkillFrontmatter("my-skill", "A file skill"), + "---\nname: my-skill\n---\nContent", + "/skills/my-skill"); + + // Act + var result = await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + + // Assert + Assert.True(runnerCalled); + Assert.Equal("executed", result); + } + + [Fact] + public async Task RunAsync_RunnerReceivesCorrectArgumentsAsync() + { + // Arrange + AgentFileSkill? capturedSkill = null; + AgentFileSkillScript? capturedScript = null; + Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + { + capturedSkill = skill; + capturedScript = scriptArg; + return Task.FromResult(null); + } + var script = CreateScript("capture", "/scripts/capture.py", runnerAsync); + var fileSkill = new AgentFileSkill( + new AgentSkillFrontmatter("owner-skill", "Owner"), + "Content", + "/skills/owner-skill"); + + // Act + await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + + // Assert + Assert.Same(fileSkill, capturedSkill); + Assert.Same(script, capturedScript); + } + + [Fact] + public void Script_HasCorrectNameAndPath() + { + // Arrange & Act + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult(null); + var script = CreateScript("my-script", "/path/to/my-script.py", RunnerAsync); + + // Assert + Assert.Equal("my-script", script.Name); + Assert.Equal("/path/to/my-script.py", script.FullPath); + } + + /// + /// Helper to create an via reflection since the constructor is internal. + /// + private static AgentFileSkillScript CreateScript(string name, string fullPath, AgentFileSkillScriptRunner executor) + { + var ctor = typeof(AgentFileSkillScript).GetConstructor( + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, + null, + [typeof(string), typeof(string), typeof(AgentFileSkillScriptRunner)], + null) ?? throw new InvalidOperationException("Could not find internal constructor."); + + return (AgentFileSkillScript)ctor.Invoke([name, fullPath, executor]); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs new file mode 100644 index 0000000000..ef8f7780a6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for script discovery and execution in . +/// +public sealed class AgentFileSkillsSourceScriptTests : IDisposable +{ + private static readonly string[] s_rubyExtension = new[] { ".rb" }; + private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); + + private readonly string _testRoot; + + public AgentFileSkillsSourceScriptTests() + { + this._testRoot = Path.Combine(Path.GetTempPath(), "skills-source-script-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this._testRoot); + } + + public void Dispose() + { + if (Directory.Exists(this._testRoot)) + { + Directory.Delete(this._testRoot, recursive: true); + } + } + + [Fact] + public async Task GetSkillsAsync_WithScriptFiles_DiscoversScriptsAsync() + { + // Arrange + CreateSkillWithScript(this._testRoot, "my-skill", "A test skill", "Body.", "scripts/convert.py", "print('hello')"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + var skill = skills[0]; + Assert.NotNull(skill.Scripts); + Assert.Single(skill.Scripts!); + Assert.Equal("scripts/convert.py", skill.Scripts![0].Name); + } + + [Fact] + public async Task GetSkillsAsync_WithMultipleScriptExtensions_DiscoversAllAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "multi-ext-skill", "Multi-extension skill", "Body."); + CreateFile(skillDir, "scripts/run.py", "print('py')"); + CreateFile(skillDir, "scripts/run.sh", "echo 'sh'"); + CreateFile(skillDir, "scripts/run.js", "console.log('js')"); + CreateFile(skillDir, "scripts/run.ps1", "Write-Host 'ps'"); + CreateFile(skillDir, "scripts/run.cs", "Console.WriteLine();"); + CreateFile(skillDir, "scripts/run.csx", "Console.WriteLine();"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList(); + Assert.Equal(6, scriptNames.Count); + Assert.Contains("scripts/run.cs", scriptNames); + Assert.Contains("scripts/run.csx", scriptNames); + Assert.Contains("scripts/run.js", scriptNames); + Assert.Contains("scripts/run.ps1", scriptNames); + Assert.Contains("scripts/run.py", scriptNames); + Assert.Contains("scripts/run.sh", scriptNames); + } + + [Fact] + public async Task GetSkillsAsync_NonScriptExtensionsAreNotDiscoveredAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "no-script-skill", "Non-script skill", "Body."); + CreateFile(skillDir, "scripts/data.txt", "text data"); + CreateFile(skillDir, "scripts/config.json", "{}"); + CreateFile(skillDir, "scripts/notes.md", "# Notes"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + Assert.Empty(skills[0].Scripts!); + } + + [Fact] + public async Task GetSkillsAsync_NoScriptFiles_ReturnsEmptyScriptsAsync() + { + // Arrange + CreateSkillDir(this._testRoot, "no-scripts", "No scripts skill", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + Assert.NotNull(skills[0].Scripts); + Assert.Empty(skills[0].Scripts!); + } + + [Fact] + public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreAlsoDiscoveredAsync() + { + // Arrange — scripts at any depth in the skill directory are discovered + string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body."); + CreateFile(skillDir, "convert.py", "print('root')"); + CreateFile(skillDir, "tools/helper.sh", "echo 'helper'"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList(); + Assert.Equal(2, scriptNames.Count); + Assert.Contains("convert.py", scriptNames); + Assert.Contains("tools/helper.sh", scriptNames); + } + + [Fact] + public async Task GetSkillsAsync_WithRunner_ScriptsCanRunAsync() + { + // Arrange + CreateSkillWithScript(this._testRoot, "exec-skill", "Executor test", "Body.", "scripts/test.py", "print('ok')"); + var executorCalled = false; + var source = new AgentFileSkillsSource( + this._testRoot, + (skill, script, args, ct) => + { + executorCalled = true; + Assert.Equal("exec-skill", skill.Frontmatter.Name); + Assert.Equal("scripts/test.py", script.Name); + Assert.Equal(Path.GetFullPath(Path.Combine(this._testRoot, "exec-skill", "scripts", "test.py")), script.FullPath); + return Task.FromResult("executed"); + }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + var scriptResult = await skills[0].Scripts![0].RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None); + + // Assert + Assert.True(executorCalled); + Assert.Equal("executed", scriptResult); + } + + [Fact] + public void Constructor_NullExecutor_DoesNotThrow() + { + // Arrange & Act & Assert — null runner is allowed when skills have no scripts + var source = new AgentFileSkillsSource(this._testRoot, null); + Assert.NotNull(source); + } + + [Fact] + public async Task GetSkillsAsync_ScriptsWithNoRunner_ThrowsOnRunAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "no-runner-skill", "No runner", "Body."); + CreateFile(skillDir, "scripts/run.sh", "echo 'hello'"); + var source = new AgentFileSkillsSource(this._testRoot, scriptRunner: null); + + // Act — discovery succeeds even without a runner + var skills = await source.GetSkillsAsync(CancellationToken.None); + var script = skills[0].Scripts![0]; + + // Assert — running the script throws because no runner was provided + await Assert.ThrowsAsync(() => script.RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None)); + } + + [Fact] + public async Task GetSkillsAsync_CustomScriptExtensions_OnlyDiscoversMatchingAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "custom-ext-skill", "Custom extensions", "Body."); + CreateFile(skillDir, "scripts/run.py", "print('py')"); + CreateFile(skillDir, "scripts/run.rb", "puts 'rb'"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedScriptExtensions = s_rubyExtension }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + Assert.Single(skills[0].Scripts!); + Assert.Equal("scripts/run.rb", skills[0].Scripts![0].Name); + } + + [Fact] + public async Task GetSkillsAsync_ExecutorReceivesArgumentsAsync() + { + // Arrange + CreateSkillWithScript(this._testRoot, "args-skill", "Args test", "Body.", "scripts/test.py", "print('ok')"); + AIFunctionArguments? capturedArgs = null; + var source = new AgentFileSkillsSource( + this._testRoot, + (skill, script, args, ct) => + { + capturedArgs = args; + return Task.FromResult("done"); + }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + var arguments = new AIFunctionArguments + { + ["value"] = 26.2, + ["factor"] = 1.60934 + }; + await skills[0].Scripts![0].RunAsync(skills[0], arguments, CancellationToken.None); + + // Assert + Assert.NotNull(capturedArgs); + Assert.Equal(26.2, capturedArgs["value"]); + Assert.Equal(1.60934, capturedArgs["factor"]); + } + + private static string CreateSkillDir(string root, string name, string description, string body) + { + string skillDir = Path.Combine(root, name); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n{body}"); + return skillDir; + } + + private static void CreateSkillWithScript(string root, string name, string description, string body, string scriptRelativePath, string scriptContent) + { + string skillDir = CreateSkillDir(root, name, description, body); + CreateFile(skillDir, scriptRelativePath, scriptContent); + } + + private static void CreateFile(string root, string relativePath, string content) + { + string fullPath = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs new file mode 100644 index 0000000000..c0f8412655 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for validation. +/// +public sealed class AgentSkillFrontmatterValidatorTests +{ + [Theory] + [InlineData("my-skill")] + [InlineData("a")] + [InlineData("skill123")] + [InlineData("a1b2c3")] + public void ValidateName_ValidName_ReturnsTrue(string name) + { + // Act + bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Theory] + [InlineData("-leading-hyphen")] + [InlineData("trailing-hyphen-")] + [InlineData("has spaces")] + [InlineData("UPPERCASE")] + [InlineData("consecutive--hyphens")] + [InlineData("special!chars")] + public void ValidateName_InvalidName_ReturnsFalse(string name) + { + // Act + bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + Assert.Contains("name", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ValidateName_NameExceedsMaxLength_ReturnsFalse() + { + // Arrange + string longName = new('a', 65); + + // Act + bool result = AgentSkillFrontmatter.ValidateName(longName, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ValidateName_NullOrWhitespace_ReturnsFalse(string? name) + { + // Act + bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Fact] + public void ValidateDescription_ValidDescription_ReturnsTrue() + { + // Act + bool result = AgentSkillFrontmatter.ValidateDescription("A valid description.", out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Fact] + public void ValidateDescription_DescriptionExceedsMaxLength_ReturnsFalse() + { + // Arrange + string longDesc = new('x', 1025); + + // Act + bool result = AgentSkillFrontmatter.ValidateDescription(longDesc, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ValidateDescription_NullOrWhitespace_ReturnsFalse(string? description) + { + // Act + bool result = AgentSkillFrontmatter.ValidateDescription(description, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Fact] + public void ValidateCompatibility_Null_ReturnsTrue() + { + // Act + bool result = AgentSkillFrontmatter.ValidateCompatibility(null, out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Fact] + public void ValidateCompatibility_WithinMaxLength_ReturnsTrue() + { + // Arrange + string compatibility = new('x', 500); + + // Act + bool result = AgentSkillFrontmatter.ValidateCompatibility(compatibility, out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Fact] + public void ValidateCompatibility_ExceedsMaxLength_ReturnsFalse() + { + // Arrange + string compatibility = new('x', 501); + + // Act + bool result = AgentSkillFrontmatter.ValidateCompatibility(compatibility, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Theory] + [InlineData("UPPERCASE")] + [InlineData("-leading")] + [InlineData("trailing-")] + [InlineData("consecutive--hyphens")] + public void Constructor_InvalidName_ThrowsArgumentException(string name) + { + // Act & Assert + var ex = Assert.Throws(() => new AgentSkillFrontmatter(name, "A valid description.")); + Assert.Contains("name", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_NameExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + string longName = new('a', 65); + + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter(longName, "A valid description.")); + } + + [Fact] + public void Constructor_DescriptionExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + string longDesc = new('x', 1025); + + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("valid-name", longDesc)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_NullOrWhitespaceName_ThrowsArgumentException(string? name) + { + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter(name!, "A valid description.")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_NullOrWhitespaceDescription_ThrowsArgumentException(string? description) + { + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("valid-name", description!)); + } + + [Fact] + public void Compatibility_ExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description."); + string longCompatibility = new('x', 501); + + // Act & Assert + Assert.Throws(() => frontmatter.Compatibility = longCompatibility); + } + + [Fact] + public void Compatibility_WithinMaxLength_Succeeds() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description."); + string compatibility = new('x', 500); + + // Act + frontmatter.Compatibility = compatibility; + + // Assert + Assert.Equal(compatibility, frontmatter.Compatibility); + } + + [Fact] + public void Compatibility_Null_Succeeds() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description."); + + // Act + frontmatter.Compatibility = null; + + // Assert + Assert.Null(frontmatter.Compatibility); + } + + [Fact] + public void Constructor_WithCompatibility_SetsValue() + { + // Arrange & Act + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.", "Requires Python 3.10+"); + + // Assert + Assert.Equal("Requires Python 3.10+", frontmatter.Compatibility); + } + + [Fact] + public void Constructor_CompatibilityExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + string longCompatibility = new('x', 501); + + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("valid-name", "A valid description.", longCompatibility)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs new file mode 100644 index 0000000000..85335256a7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentSkillsProviderBuilderTests +{ + private readonly TestAIAgent _agent = new(); + + private AIContextProvider.InvokingContext CreateInvokingContext() + { + return new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + } + + [Fact] + public void Build_NoSourceConfigured_Succeeds() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act + var provider = builder.Build(); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public void Build_WithCustomSource_Succeeds() + { + // Arrange + var source = new TestAgentSkillsSource( + new TestAgentSkill("custom", "Custom skill", "Instructions.")); + var builder = new AgentSkillsProviderBuilder() + .UseSource(source); + + // Act + var provider = builder.Build(); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public void UseSource_NullSource_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseSource(null!)); + } + + [Fact] + public void UseFilter_NullPredicate_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseFilter(null!)); + } + + [Fact] + public void UseFileScriptRunner_NullRunner_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseFileScriptRunner(null!)); + } + + [Fact] + public void UseOptions_NullConfigure_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseOptions(null!)); + } + + [Fact] + public async Task Build_WithFilter_AppliesFilterToSkillsAsync() + { + // Arrange + var source = new TestAgentSkillsSource( + new TestAgentSkill("keep-me", "Keep", "Instructions."), + new TestAgentSkill("drop-me", "Drop", "Instructions.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseFilter(skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase)) + .Build(); + + // Act + var result = await provider.InvokingAsync( + this.CreateInvokingContext(), CancellationToken.None); + + // Assert — the instructions should mention "keep-me" but not "drop-me" + Assert.NotNull(result.Instructions); + Assert.Contains("keep-me", result.Instructions); + Assert.DoesNotContain("drop-me", result.Instructions); + } + + [Fact] + public async Task Build_WithCacheDisabled_ReloadsOnEachCallAsync() + { + // Arrange + var countingSource = new CountingSource( + new TestAgentSkill("skill-a", "A", "Instructions.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(countingSource) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + // Act + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + + // Assert — inner source should be called each time (dedup still calls through) + Assert.True(countingSource.CallCount >= 2); + } + + [Fact] + public async Task Build_WithCacheEnabled_CachesSkillsAsync() + { + // Arrange + var countingSource = new CountingSource( + new TestAgentSkill("skill-a", "A", "Instructions.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(countingSource) + .Build(); + + // Act + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + + // Assert — inner source should only be called once due to caching + Assert.Equal(1, countingSource.CallCount); + } + + [Fact] + public void Build_FluentChaining_ReturnsSameBuilder() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + var source = new TestAgentSkillsSource( + new TestAgentSkill("test", "Test", "Instructions.")); + + // Act — all fluent methods should return the same builder + var result = builder + .UseSource(source) + .UseScriptApproval(false) + .UsePromptTemplate("Skills:\n{skills}\n{resource_instructions}\n{script_instructions}"); + + // Assert + Assert.Same(builder, result); + } + + [Fact] + public void Build_UseOptions_ConfiguresOptions() + { + // Arrange + var source = new TestAgentSkillsSource( + new TestAgentSkill("test", "Test", "Instructions.")); + + // Act — UseOptions should not throw and successfully configure + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseOptions(opts => opts.ScriptApproval = true) + .Build(); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public async Task Build_WithMultipleCustomSources_AggregatesAllAsync() + { + // Arrange + var source1 = new TestAgentSkillsSource( + new TestAgentSkill("from-one", "Source 1", "Instructions 1.")); + var source2 = new TestAgentSkillsSource( + new TestAgentSkill("from-two", "Source 2", "Instructions 2.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source1) + .UseSource(source2) + .Build(); + + // Act + var result = await provider.InvokingAsync( + this.CreateInvokingContext(), CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("from-one", result.Instructions); + Assert.Contains("from-two", result.Instructions); + } + + /// + /// A test source that counts how many times GetSkillsAsync is called. + /// + private sealed class CountingSource : AgentSkillsSource + { + private readonly AgentSkill[] _skills; + private int _callCount; + + public CountingSource(params AgentSkill[] skills) + { + this._skills = skills; + } + + public int CallCount => this._callCount; + + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._callCount); + return Task.FromResult>(this._skills); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs new file mode 100644 index 0000000000..6dfc45918a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs @@ -0,0 +1,765 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for the class with . +/// +public sealed class AgentSkillsProviderTests : IDisposable +{ + private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); + private readonly string _testRoot; + private readonly TestAIAgent _agent = new(); + + public AgentSkillsProviderTests() + { + this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this._testRoot); + } + + public void Dispose() + { + if (Directory.Exists(this._testRoot)) + { + Directory.Delete(this._testRoot, recursive: true); + } + } + + [Fact] + public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync() + { + // Arrange + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext { Instructions = "Original instructions" }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.Equal("Original instructions", result.Instructions); + Assert.Null(result.Tools); + } + + [Fact] + public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync() + { + // Arrange + this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext { Instructions = "Base instructions" }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("Base instructions", result.Instructions); + Assert.Contains("provider-skill", result.Instructions); + Assert.Contains("Provider skill test", result.Instructions); + + // Should have load_skill tool (no resources, so no read_skill_resource) + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("load_skill", toolNames); + Assert.DoesNotContain("read_skill_resource", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync() + { + // Arrange + this.CreateSkill("null-instr-skill", "Null instruction test", "Body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("null-instr-skill", result.Instructions); + } + + [Fact] + public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync() + { + // Arrange + this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body."); + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Custom template: {skills}\n{resource_instructions}\n{script_instructions}" + }; + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.StartsWith("Custom template:", result.Instructions); + Assert.Contains("custom-prompt-skill", result.Instructions); + Assert.Contains("Custom prompt", result.Instructions); + } + + [Fact] + public void Constructor_PromptWithoutSkillsPlaceholder_ThrowsArgumentException() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "No skills placeholder here {resource_instructions} {script_instructions}" + }; + + // Act & Assert + var ex = Assert.Throws(() => + new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options)); + Assert.Contains("{skills}", ex.Message); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public void Constructor_PromptWithoutRunnerInstructionsPlaceholder_ThrowsArgumentException() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Has skills {skills} but no runner instructions {resource_instructions}" + }; + + // Act & Assert + var ex = Assert.Throws(() => + new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options)); + Assert.Contains("{script_instructions}", ex.Message); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public void Constructor_PromptWithBothPlaceholders_Succeeds() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Skills: {skills}\nResources: {resource_instructions}\nRunner: {script_instructions}" + }; + + // Act — should not throw + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public void Constructor_PromptWithoutResourceInstructionsPlaceholder_ThrowsArgumentException() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Has skills {skills} and runner {script_instructions} but no resource instructions" + }; + + // Act & Assert + var ex = Assert.Throws(() => + new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options)); + Assert.Contains("{resource_instructions}", ex.Message); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync() + { + // Arrange — description with XML-sensitive characters + string skillDir = Path.Combine(this._testRoot, "xml-skill"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: xml-skill\ndescription: Uses & \"quotes\"\n---\nBody."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("<tags>", result.Instructions); + Assert.Contains("&", result.Instructions); + } + + [Fact] + public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "dir1"); + string dir2 = Path.Combine(this._testRoot, "dir2"); + CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); + CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); + + // Act + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(new[] { dir1, dir2 }, s_noOpExecutor)); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Assert + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + Assert.NotNull(result.Instructions); + Assert.Contains("skill-a", result.Instructions); + Assert.Contains("skill-b", result.Instructions); + } + + [Fact] + public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync() + { + // Arrange + this.CreateSkill("tools-skill", "Tools test", "Body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + + var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool."); + var inputContext = new AIContext { Tools = new[] { existingTool } }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — existing tool should be preserved alongside the new skill tools + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("existing_tool", toolNames); + Assert.Contains("load_skill", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync() + { + // Arrange — create skills in reverse alphabetical order + this.CreateSkill("zulu-skill", "Zulu skill", "Body Z."); + this.CreateSkill("alpha-skill", "Alpha skill", "Body A."); + this.CreateSkill("mike-skill", "Mike skill", "Body M."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — skills should appear in alphabetical order in the prompt + Assert.NotNull(result.Instructions); + int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal); + int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal); + int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal); + Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill"); + Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill"); + } + + [Fact] + public async Task ProvideAIContextAsync_ConcurrentCalls_LoadsSkillsOnlyOnceAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("concurrent-skill", "Concurrent test", "Body.") + ]); + var provider = new AgentSkillsProvider(source); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act — invoke concurrently from multiple threads + var tasks = Enumerable.Range(0, 10) + .Select(_ => provider.InvokingAsync(invokingContext, CancellationToken.None).AsTask()) + .ToArray(); + await Task.WhenAll(tasks); + + // Assert — GetSkillsAsync should have been called exactly once (provider-level caching) + Assert.Equal(1, source.GetSkillsCallCount); + } + + [Fact] + public async Task InvokingCoreAsync_WithScripts_IncludesRunSkillScriptToolAsync() + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "script-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: script-skill\ndescription: Skill with scripts\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "test.py"), + "print('hello')"); + + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("run_skill_script", toolNames); + Assert.Contains("load_skill", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_WithoutScripts_NoRunSkillScriptToolAsync() + { + // Arrange + this.CreateSkill("no-script-skill", "No scripts", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.DoesNotContain("run_skill_script", toolNames); + } + + [Fact] + public void Build_WithFileSkillsButNoExecutor_ThrowsInvalidOperationException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder() + .UseFileSkill(this._testRoot); + + // Act & Assert + Assert.Throws(() => builder.Build()); + } + + [Fact] + public async Task Builder_UseFileSkillWithOptions_DiscoverSkillsAsync() + { + // Arrange + this.CreateSkill("opts-skill", "Options skill", "Options body."); + var options = new AgentFileSkillsSourceOptions(); + var provider = new AgentSkillsProviderBuilder() + .UseFileSkill(this._testRoot, options) + .UseFileScriptRunner(s_noOpExecutor) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + // Act + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("opts-skill", result.Instructions); + } + + [Fact] + public async Task Builder_UseFileSkillsWithOptions_DiscoverMultipleSkillsAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "multi-opts-1"); + string dir2 = Path.Combine(this._testRoot, "multi-opts-2"); + CreateSkillIn(dir1, "skill-x", "Skill X", "Body X."); + CreateSkillIn(dir2, "skill-y", "Skill Y", "Body Y."); + + var options = new AgentFileSkillsSourceOptions(); + var provider = new AgentSkillsProviderBuilder() + .UseFileSkills(new[] { dir1, dir2 }, options) + .UseFileScriptRunner(s_noOpExecutor) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + // Act + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("skill-x", result.Instructions); + Assert.Contains("skill-y", result.Instructions); + } + + [Fact] + public async Task Builder_UseFileSkillWithOptionsResourceFilter_FiltersResourcesAsync() + { + // Arrange — create a skill with both .md and .json resources + string skillDir = Path.Combine(this._testRoot, "res-filter-opts"); + CreateSkillIn(skillDir, "filter-skill", "Filter test", "Filter body."); + File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}", System.Text.Encoding.UTF8); + File.WriteAllText(Path.Combine(skillDir, "notes.txt"), "notes", System.Text.Encoding.UTF8); + + // Only allow .json resources + var options = new AgentFileSkillsSourceOptions + { + AllowedResourceExtensions = [".json"], + }; + var source = new AgentFileSkillsSource(skillDir, s_noOpExecutor, options); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + var fileSkill = Assert.IsType(skills[0]); + Assert.All(fileSkill.Resources, r => Assert.EndsWith(".json", r.Name)); + } + + private void CreateSkill(string name, string description, string body) + { + CreateSkillIn(this._testRoot, name, description, body); + } + + [Fact] + public async Task LoadSkill_DefaultOptions_ReturnsFullContentAsync() + { + // Arrange + this.CreateSkill("content-skill", "Content test", "Skill body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction; + + // Act + var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary { ["skillName"] = "content-skill" })); + + // Assert — should contain frontmatter and body + var text = content!.ToString()!; + Assert.Contains("---", text); + Assert.Contains("name: content-skill", text); + Assert.Contains("Skill body.", text); + } + + [Fact] + public async Task Builder_UseFileScriptRunnerAfterUseFileSkills_RunnerIsUsedAsync() + { + // Arrange — create a skill with a script file + string skillDir = Path.Combine(this._testRoot, "builder-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: builder-skill\ndescription: Builder test\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "run.py"), + "print('ok')"); + + var executorCalled = false; + + // Act — call UseFileScriptRunner AFTER UseFileSkill (the bug scenario) + var provider = new AgentSkillsProviderBuilder() + .UseFileSkill(this._testRoot) + .UseFileScriptRunner((skill, script, args, ct) => + { + executorCalled = true; + return Task.FromResult("executed"); + }) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — run_skill_script tool should be present and executor should work + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("run_skill_script", toolNames); + + var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction; + await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary + { + ["skillName"] = "builder-skill", + ["scriptName"] = "scripts/run.py", + })); + + Assert.True(executorCalled); + } + + private static void CreateSkillIn(string root, string name, string description, string body) + { + string skillDir = Path.Combine(root, name); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n{body}"); + } + + [Fact] + public async Task Build_WithCachingDisabled_ReloadsSkillsOnEachCallAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("no-cache-skill", "No cache test", "Body.") + ]); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — source should be called more than once since caching is disabled + Assert.True(source.GetSkillsCallCount > 1); + } + + [Fact] + public async Task Build_WithCachingEnabled_CachesSkillsAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("cached-skill", "Cached test", "Body.") + ]); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — source should be called exactly once (caching is on by default) + Assert.Equal(1, source.GetSkillsCallCount); + } + + [Fact] + public async Task Build_DefaultOptions_CachesSkillsAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("default-skill", "Default test", "Body.") + ]); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — default behavior caches + Assert.Equal(1, source.GetSkillsCallCount); + } + + [Fact] + public async Task InvokingCoreAsync_WithScriptsAndScriptApproval_WrapsRunScriptToolAsync() + { + // Arrange — create a skill with a script and enable ScriptApproval + string skillDir = Path.Combine(this._testRoot, "approval-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: approval-skill\ndescription: Approval test\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "run.py"), + "print('hello')"); + + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var options = new AgentSkillsProviderOptions { ScriptApproval = true }; + var provider = new AgentSkillsProvider(source, options); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — run_skill_script tool should be wrapped in ApprovalRequiredAIFunction + Assert.NotNull(result.Tools); + var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script"); + Assert.NotNull(scriptTool); + Assert.IsType(scriptTool); + } + + [Fact] + public async Task InvokingCoreAsync_WithScriptsNoScriptApproval_DoesNotWrapRunScriptToolAsync() + { + // Arrange — create a skill with a script, default options (no approval) + string skillDir = Path.Combine(this._testRoot, "no-approval-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: no-approval-skill\ndescription: No approval test\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "run.py"), + "print('hello')"); + + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — run_skill_script tool should NOT be wrapped + Assert.NotNull(result.Tools); + var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script"); + Assert.NotNull(scriptTool); + Assert.IsNotType(scriptTool); + } + + [Fact] + public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreSharedWhenCachedAsync() + { + // Arrange — with default caching, tools should be the same reference + this.CreateSkill("cached-tools-skill", "Cached tools test", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result1 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var result2 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — tool lists should be the same reference (cached) + Assert.NotNull(result1.Tools); + Assert.NotNull(result2.Tools); + Assert.Same(result1.Tools, result2.Tools); + } + + [Fact] + public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreNotSharedWhenCachingDisabledAsync() + { + // Arrange — with caching disabled, tools should be rebuilt per invocation + this.CreateSkill("fresh-tools-skill", "Fresh tools test", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var options = new AgentSkillsProviderOptions { DisableCaching = true }; + var provider = new AgentSkillsProvider(source, options); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result1 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var result2 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — tool lists should not be the same reference + Assert.NotNull(result1.Tools); + Assert.NotNull(result2.Tools); + Assert.NotSame(result1.Tools, result2.Tools); + } + + [Fact] + public async Task Constructor_SingleDirectory_DiscoverFileSkillsAsync() + { + // Arrange + this.CreateSkill("file-ctor-skill", "File ctor test", "File body."); + var provider = new AgentSkillsProvider(this._testRoot, s_noOpExecutor); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("file-ctor-skill", result.Instructions); + Assert.NotNull(result.Tools); + Assert.Contains(result.Tools!, t => t.Name == "load_skill"); + } + + [Fact] + public async Task Constructor_MultipleDirectories_DiscoverFileSkillsAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "dir1"); + string dir2 = Path.Combine(this._testRoot, "dir2"); + Directory.CreateDirectory(dir1); + Directory.CreateDirectory(dir2); + CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); + CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); + + var provider = new AgentSkillsProvider(new[] { dir1, dir2 }, s_noOpExecutor); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("skill-a", result.Instructions); + Assert.Contains("skill-b", result.Instructions); + } + + [Fact] + public async Task Constructor_MultipleDirectories_DeduplicatesSkillsByNameAsync() + { + // Arrange — same skill name in two directories + string dir1 = Path.Combine(this._testRoot, "dup1"); + string dir2 = Path.Combine(this._testRoot, "dup2"); + Directory.CreateDirectory(dir1); + Directory.CreateDirectory(dir2); + CreateSkillIn(dir1, "dup-skill", "First", "Body 1."); + CreateSkillIn(dir2, "dup-skill", "Second", "Body 2."); + + var provider = new AgentSkillsProvider(new[] { dir1, dir2 }, s_noOpExecutor); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction; + var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary { ["skillName"] = "dup-skill" })); + + // Assert — only first occurrence should survive + Assert.NotNull(content); + Assert.Contains("Body 1.", content!.ToString()!); + } + + /// + /// A test skill source that counts how many times is called. + /// + private sealed class CountingAgentSkillsSource : AgentSkillsSource + { + private readonly IList _skills; + private int _callCount; + + public CountingAgentSkillsSource(IList skills) + { + this._skills = skills; + } + + public int GetSkillsCallCount => this._callCount; + + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._callCount); + return Task.FromResult(this._skills); + } + } + + private sealed class TestAgentSkill : AgentSkill + { + private readonly string _content; + + public TestAgentSkill(string name, string description, string content) + { + this.Frontmatter = new AgentSkillFrontmatter(name, description); + this._content = content; + } + + public override AgentSkillFrontmatter Frontmatter { get; } + + public override string Content => this._content; + + public override IReadOnlyList? Resources => null; + + public override IReadOnlyList? Scripts => null; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs new file mode 100644 index 0000000000..860f402005 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class DeduplicatingAgentSkillsSourceTests +{ + [Fact] + public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("skill-a", "A", "Instructions A."), + new TestAgentSkill("skill-b", "B", "Instructions B.")); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + } + + [Fact] + public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync() + { + // Arrange + var skills = new AgentSkill[] + { + new TestAgentSkill("dupe", "First", "Instructions 1."), + new TestAgentSkill("dupe", "Second", "Instructions 2."), + new TestAgentSkill("unique", "Unique", "Instructions 3."), + }; + var inner = new TestAgentSkillsSource(skills); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("First", result.First(s => s.Frontmatter.Name == "dupe").Frontmatter.Description); + Assert.Contains(result, s => s.Frontmatter.Name == "unique"); + } + + [Fact] + public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync() + { + // Arrange — use a custom source that returns skills with same name but different casing + var inner = new FakeDuplicateCaseSource(); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(result); + Assert.Equal("First", result[0].Frontmatter.Description); + } + + [Fact] + public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource(System.Array.Empty()); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + /// + /// A fake source that returns skills with names differing only by case. + /// + private sealed class FakeDuplicateCaseSource : AgentSkillsSource + { + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + // AgentSkillFrontmatter validates names must be lowercase, so we build + // two skills with the same lowercase name to test case-insensitive dedup. + var skills = new List + { + new TestAgentSkill("my-skill", "First", "Instructions 1."), + new TestAgentSkill("my-skill", "Second", "Instructions 2."), + }; + return Task.FromResult>(skills); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs index 6134b04feb..e9dc2e0358 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs @@ -4,25 +4,25 @@ using System; using System.IO; using System.Linq; using System.Threading.Tasks; -using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.Agents.AI.UnitTests.AgentSkills; /// -/// Unit tests for the class. +/// Unit tests for the skill discovery and parsing logic. /// public sealed class FileAgentSkillLoaderTests : IDisposable { - private static readonly string[] s_traversalResource = new[] { "../secret.txt" }; + private static readonly string[] s_customExtensions = [".custom"]; + private static readonly string[] s_validExtensions = [".md", ".json", ".custom"]; + private static readonly string[] s_mixedValidInvalidExtensions = [".md", "json"]; + private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); private readonly string _testRoot; - private readonly FileAgentSkillLoader _loader; public FileAgentSkillLoaderTests() { this._testRoot = Path.Combine(Path.GetTempPath(), "agent-skills-tests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(this._testRoot); - this._loader = new FileAgentSkillLoader(NullLogger.Instance); } public void Dispose() @@ -34,23 +34,23 @@ public sealed class FileAgentSkillLoaderTests : IDisposable } [Fact] - public void DiscoverAndLoadSkills_ValidSkill_ReturnsSkill() + public async Task GetSkillsAsync_ValidSkill_ReturnsSkillAsync() { // Arrange _ = this.CreateSkillDirectory("my-skill", "A test skill", "Use this skill to do things."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.True(skills.ContainsKey("my-skill")); - Assert.Equal("A test skill", skills["my-skill"].Frontmatter.Description); - Assert.Equal("Use this skill to do things.", skills["my-skill"].Body); + Assert.Equal("my-skill", skills[0].Frontmatter.Name); + Assert.Equal("A test skill", skills[0].Frontmatter.Description); } [Fact] - public void DiscoverAndLoadSkills_QuotedFrontmatterValues_ParsesCorrectly() + public async Task GetSkillsAsync_QuotedFrontmatterValues_ParsesCorrectlyAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "quoted-skill"); @@ -58,33 +58,35 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: 'quoted-skill'\ndescription: \"A quoted description\"\n---\nBody text."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.Equal("quoted-skill", skills["quoted-skill"].Frontmatter.Name); - Assert.Equal("A quoted description", skills["quoted-skill"].Frontmatter.Description); + Assert.Equal("quoted-skill", skills[0].Frontmatter.Name); + Assert.Equal("A quoted description", skills[0].Frontmatter.Description); } [Fact] - public void DiscoverAndLoadSkills_MissingFrontmatter_ExcludesSkill() + public async Task GetSkillsAsync_MissingFrontmatter_ExcludesSkillAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "bad-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "No frontmatter here."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_MissingNameField_ExcludesSkill() + public async Task GetSkillsAsync_MissingNameField_ExcludesSkillAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "no-name"); @@ -92,16 +94,17 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\ndescription: A skill without a name\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill() + public async Task GetSkillsAsync_MissingDescriptionField_ExcludesSkillAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "no-desc"); @@ -109,9 +112,10 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: no-desc\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); @@ -123,7 +127,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [InlineData("trailing-hyphen-")] [InlineData("has spaces")] [InlineData("consecutive--hyphens")] - public void DiscoverAndLoadSkills_InvalidName_ExcludesSkill(string invalidName) + public async Task GetSkillsAsync_InvalidName_ExcludesSkillAsync(string invalidName) { // Arrange string skillDir = Path.Combine(this._testRoot, invalidName); @@ -136,16 +140,17 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), $"---\nname: {invalidName}\ndescription: A skill\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly() + public async Task GetSkillsAsync_DuplicateNames_KeepsFirstOnlyAsync() { // Arrange string dir1 = Path.Combine(this._testRoot, "dupe"); @@ -162,34 +167,37 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(nestedDir, "SKILL.md"), "---\nname: dupe\ndescription: Second\n---\nSecond body."); + var fileSource = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var source = new DeduplicatingAgentSkillsSource(fileSource); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert – filesystem enumeration order is not guaranteed, so we only // verify that exactly one of the two duplicates was kept. Assert.Single(skills); - string desc = skills["dupe"].Frontmatter.Description; + string desc = skills[0].Frontmatter.Description; Assert.True(desc == "First" || desc == "Second", $"Unexpected description: {desc}"); } [Fact] - public void DiscoverAndLoadSkills_NameMismatchesDirectory_ExcludesSkill() + public async Task GetSkillsAsync_NameMismatchesDirectory_ExcludesSkillAsync() { // Arrange — directory name differs from the frontmatter name _ = this.CreateSkillDirectoryWithRawContent( "wrong-dir-name", "---\nname: actual-skill-name\ndescription: A skill\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_FilesWithMatchingExtensions_DiscoveredAsResources() + public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync() { // Arrange — create resource files in the skill directory string skillDir = Path.Combine(this._testRoot, "resource-skill"); @@ -200,20 +208,21 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: resource-skill\ndescription: Has resources\n---\nSee docs for details."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["resource-skill"]; - Assert.Equal(2, skill.ResourceNames.Count); - Assert.Contains(skill.ResourceNames, r => r.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(skill.ResourceNames, r => r.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase)); + var skill = skills[0]; + Assert.Equal(2, skill.Resources!.Count); + Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase)); } [Fact] - public void DiscoverAndLoadSkills_FilesWithNonMatchingExtensions_NotDiscovered() + public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync() { // Arrange — create a file with an extension not in the default list string skillDir = Path.Combine(this._testRoot, "ext-skill"); @@ -223,19 +232,20 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: ext-skill\ndescription: Extension test\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["ext-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("data.json", skill.ResourceNames[0]); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("data.json", skill.Resources![0].Name); } [Fact] - public void DiscoverAndLoadSkills_SkillMdFile_NotIncludedAsResource() + public async Task GetSkillsAsync_SkillMdFile_NotIncludedAsResourceAsync() { // Arrange — the SKILL.md file itself should not be in the resource list string skillDir = Path.Combine(this._testRoot, "selfref-skill"); @@ -244,19 +254,20 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: selfref-skill\ndescription: Self ref test\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["selfref-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("notes.md", skill.ResourceNames[0]); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("notes.md", skill.Resources![0].Name); } [Fact] - public void DiscoverAndLoadSkills_NestedResourceFiles_Discovered() + public async Task GetSkillsAsync_NestedResourceFiles_DiscoveredAsync() { // Arrange — resource files in nested subdirectories string skillDir = Path.Combine(this._testRoot, "nested-res-skill"); @@ -266,26 +277,22 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: nested-res-skill\ndescription: Nested resources\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["nested-res-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Contains(skill.ResourceNames, r => r.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase)); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Contains(skill.Resources!, r => r.Name.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase)); } - private static readonly string[] s_customExtensions = new[] { ".custom" }; - private static readonly string[] s_validExtensions = new[] { ".md", ".json", ".custom" }; - private static readonly string[] s_mixedValidInvalidExtensions = new[] { ".md", "json" }; - [Fact] - public void DiscoverAndLoadSkills_CustomResourceExtensions_UsedForDiscovery() + public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync() { - // Arrange — use a loader with custom extensions - var customLoader = new FileAgentSkillLoader(NullLogger.Instance, s_customExtensions); + // Arrange — use a source with custom extensions string skillDir = Path.Combine(this._testRoot, "custom-ext-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "data.custom"), "custom data"); @@ -293,15 +300,16 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: custom-ext-skill\ndescription: Custom extensions\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_customExtensions }); // Act - var skills = customLoader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert — only .custom files should be discovered, not .json Assert.Single(skills); - var skill = skills["custom-ext-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("data.custom", skill.ResourceNames[0]); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("data.custom", skill.Resources![0].Name); } [Theory] @@ -311,39 +319,39 @@ public sealed class FileAgentSkillLoaderTests : IDisposable public void Constructor_InvalidExtension_ThrowsArgumentException(string badExtension) { // Arrange & Act & Assert - Assert.Throws(() => new FileAgentSkillLoader(NullLogger.Instance, new[] { badExtension })); + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = new string[] { badExtension } })); } [Fact] - public void Constructor_NullExtensions_UsesDefaults() + public async Task Constructor_NullExtensions_UsesDefaultsAsync() { // Arrange & Act - var loader = new FileAgentSkillLoader(NullLogger.Instance, null); string skillDir = this.CreateSkillDirectory("null-ext", "A skill", "Body."); File.WriteAllText(Path.Combine(skillDir, "notes.md"), "notes"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Assert — default extensions include .md - var skills = loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - Assert.Single(skills["null-ext"].ResourceNames); + var skills = await source.GetSkillsAsync(); + Assert.Single(skills[0].Resources!); } [Fact] public void Constructor_ValidExtensions_DoesNotThrow() { // Arrange & Act & Assert — should not throw - var loader = new FileAgentSkillLoader(NullLogger.Instance, s_validExtensions); - Assert.NotNull(loader); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_validExtensions }); + Assert.NotNull(source); } [Fact] public void Constructor_MixOfValidAndInvalidExtensions_ThrowsArgumentException() { // Arrange & Act & Assert — one bad extension in the list should cause failure - Assert.Throws(() => new FileAgentSkillLoader(NullLogger.Instance, s_mixedValidInvalidExtensions)); + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_mixedValidInvalidExtensions })); } [Fact] - public void DiscoverAndLoadSkills_ResourceInSkillRoot_Discovered() + public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredAsync() { // Arrange — resource file directly in the skill directory (not in a subdirectory) string skillDir = Path.Combine(this._testRoot, "root-resource-skill"); @@ -353,54 +361,62 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: root-resource-skill\ndescription: Root resources\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert — both root-level resource files should be discovered Assert.Single(skills); - var skill = skills["root-resource-skill"]; - Assert.Equal(2, skill.ResourceNames.Count); - Assert.Contains(skill.ResourceNames, r => r.Equals("guide.md", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(skill.ResourceNames, r => r.Equals("config.json", StringComparison.OrdinalIgnoreCase)); + var skill = skills[0]; + Assert.Equal(2, skill.Resources!.Count); + Assert.Contains(skill.Resources!, r => r.Name.Equals("guide.md", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(skill.Resources!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase)); } [Fact] - public void DiscoverAndLoadSkills_NoResourceFiles_ReturnsEmptyResourceNames() + public async Task GetSkillsAsync_NoResourceFiles_ReturnsEmptyResourcesAsync() { // Arrange — skill with no resource files _ = this.CreateSkillDirectory("no-resources", "A skill", "No resources here."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.Empty(skills["no-resources"].ResourceNames); + Assert.Empty(skills[0].Resources!); } [Fact] - public void DiscoverAndLoadSkills_EmptyPaths_ReturnsEmptyDictionary() + public async Task GetSkillsAsync_EmptyPaths_ReturnsEmptyListAsync() { + // Arrange + var source = new AgentFileSkillsSource(Enumerable.Empty(), s_noOpExecutor); + // Act - var skills = this._loader.DiscoverAndLoadSkills(Enumerable.Empty()); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_NonExistentPath_ReturnsEmptyDictionary() + public async Task GetSkillsAsync_NonExistentPath_ReturnsEmptyListAsync() { + // Arrange + var source = new AgentFileSkillsSource(Path.Combine(this._testRoot, "does-not-exist"), s_noOpExecutor); + // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { Path.Combine(this._testRoot, "does-not-exist") }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_NestedSkillDirectory_DiscoveredWithinDepthLimit() + public async Task GetSkillsAsync_NestedSkillDirectory_DiscoveredWithinDepthLimitAsync() { // Arrange — nested 1 level deep (MaxSearchDepth = 2, so depth 0 = testRoot, depth 1 = level1) string nestedDir = Path.Combine(this._testRoot, "level1", "nested-skill"); @@ -408,13 +424,14 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(nestedDir, "SKILL.md"), "---\nname: nested-skill\ndescription: Nested\n---\nNested body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.True(skills.ContainsKey("nested-skill")); + Assert.Equal("nested-skill", skills[0].Frontmatter.Name); } [Fact] @@ -425,54 +442,19 @@ public sealed class FileAgentSkillLoaderTests : IDisposable string refsDir = Path.Combine(skillDir, "refs"); Directory.CreateDirectory(refsDir); File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content here."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["read-skill"]; + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var skills = await source.GetSkillsAsync(); + var resource = skills[0].Resources!.First(r => r.Name == "refs/doc.md"); // Act - string content = await this._loader.ReadSkillResourceAsync(skill, "refs/doc.md"); + var content = await resource.ReadAsync(); // Assert Assert.Equal("Document content here.", content); } [Fact] - public async Task ReadSkillResourceAsync_UnregisteredResource_ThrowsInvalidOperationExceptionAsync() - { - // Arrange - string skillDir = this.CreateSkillDirectory("simple-skill", "A skill", "No resources."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["simple-skill"]; - - // Act & Assert - await Assert.ThrowsAsync( - () => this._loader.ReadSkillResourceAsync(skill, "unknown.md")); - } - - [Fact] - public async Task ReadSkillResourceAsync_PathTraversal_ThrowsInvalidOperationExceptionAsync() - { - // Arrange — skill with a legitimate resource, then try to read a traversal path at read time - string skillDir = this.CreateSkillDirectory("traverse-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "legit"); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["traverse-read"]; - - // Manually construct a skill with the traversal resource in its list to bypass discovery validation - var tampered = new FileAgentSkill( - skill.Frontmatter, - skill.Body, - skill.SourcePath, - s_traversalResource); - - // Act & Assert - await Assert.ThrowsAsync( - () => this._loader.ReadSkillResourceAsync(tampered, "../secret.txt")); - } - - [Fact] - public void DiscoverAndLoadSkills_NameExceedsMaxLength_ExcludesSkill() + public async Task GetSkillsAsync_NameExceedsMaxLength_ExcludesSkillAsync() { // Arrange — name longer than 64 characters string longName = new('a', 65); @@ -481,16 +463,17 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), $"---\nname: {longName}\ndescription: A skill\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_DescriptionExceedsMaxLength_ExcludesSkill() + public async Task GetSkillsAsync_DescriptionExceedsMaxLength_ExcludesSkillAsync() { // Arrange — description longer than 1024 characters string longDesc = new('x', 1025); @@ -499,71 +482,18 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), $"---\nname: long-desc\ndescription: {longDesc}\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } - [Fact] - public async Task ReadSkillResourceAsync_DotSlashPrefix_MatchesNormalizedResourceAsync() - { - // Arrange — skill loaded with bare path, caller uses ./ prefix - string skillDir = this.CreateSkillDirectory("dotslash-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["dotslash-read"]; - - // Act — caller passes ./refs/doc.md which should match refs/doc.md - string content = await this._loader.ReadSkillResourceAsync(skill, "./refs/doc.md"); - - // Assert - Assert.Equal("Document content.", content); - } - - [Fact] - public async Task ReadSkillResourceAsync_BackslashSeparator_MatchesNormalizedResourceAsync() - { - // Arrange — skill loaded with forward-slash path, caller uses backslashes - string skillDir = this.CreateSkillDirectory("backslash-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Backslash content."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["backslash-read"]; - - // Act — caller passes refs\doc.md which should match refs/doc.md - string content = await this._loader.ReadSkillResourceAsync(skill, "refs\\doc.md"); - - // Assert - Assert.Equal("Backslash content.", content); - } - - [Fact] - public async Task ReadSkillResourceAsync_DotSlashWithBackslash_MatchesNormalizedResourceAsync() - { - // Arrange — skill loaded with forward-slash path, caller uses .\ prefix with backslashes - string skillDir = this.CreateSkillDirectory("mixed-sep-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Mixed separator content."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["mixed-sep-read"]; - - // Act — caller passes .\refs\doc.md which should match refs/doc.md - string content = await this._loader.ReadSkillResourceAsync(skill, ".\\refs\\doc.md"); - - // Assert - Assert.Equal("Mixed separator content.", content); - } - #if NET [Fact] - public void DiscoverAndLoadSkills_SymlinkInPath_SkipsSymlinkedResources() + public async Task GetSkillsAsync_SymlinkInPath_SkipsSymlinkedResourcesAsync() { // Arrange — a "refs" subdirectory is a symlink pointing outside the skill directory string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill"); @@ -588,71 +518,179 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: symlink-escape-skill\ndescription: Symlinked directory escape\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert — skill should still load, but symlinked resources should be excluded - Assert.True(skills.ContainsKey("symlink-escape-skill")); - var skill = skills["symlink-escape-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("legit.md", skill.ResourceNames[0]); - } - - private static readonly string[] s_symlinkResource = ["refs/data.md"]; - - [Fact] - public async Task ReadSkillResourceAsync_SymlinkInPath_ThrowsInvalidOperationExceptionAsync() - { - // Arrange — build a skill with a symlinked subdirectory - string skillDir = Path.Combine(this._testRoot, "symlink-read-skill"); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(skillDir); - - string outsideDir = Path.Combine(this._testRoot, "outside-read"); - Directory.CreateDirectory(outsideDir); - File.WriteAllText(Path.Combine(outsideDir, "data.md"), "external data"); - - try - { - Directory.CreateSymbolicLink(refsDir, outsideDir); - } - catch (IOException) - { - // Symlink creation requires elevation on some platforms; skip gracefully. - return; - } - - // Manually construct a skill that bypasses discovery validation - var frontmatter = new SkillFrontmatter("symlink-read-skill", "A skill"); - var skill = new FileAgentSkill( - frontmatter: frontmatter, - body: "See [doc](refs/data.md).", - sourcePath: skillDir, - resourceNames: s_symlinkResource); - - // Act & Assert - await Assert.ThrowsAsync( - () => this._loader.ReadSkillResourceAsync(skill, "refs/data.md")); + var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-escape-skill"); + Assert.NotNull(skill); + Assert.Single(skill.Resources!); + Assert.Equal("legit.md", skill.Resources![0].Name); } #endif [Fact] - public void DiscoverAndLoadSkills_FileWithUtf8Bom_ParsesSuccessfully() + public async Task GetSkillsAsync_FileWithUtf8Bom_ParsesSuccessfullyAsync() { // Arrange — prepend a UTF-8 BOM (\uFEFF) before the frontmatter _ = this.CreateSkillDirectoryWithRawContent( "bom-skill", "\uFEFF---\nname: bom-skill\ndescription: Skill with BOM\n---\nBody content."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.True(skills.ContainsKey("bom-skill")); - Assert.Equal("Skill with BOM", skills["bom-skill"].Frontmatter.Description); - Assert.Equal("Body content.", skills["bom-skill"].Body); + Assert.Equal("bom-skill", skills[0].Frontmatter.Name); + Assert.Equal("Skill with BOM", skills[0].Frontmatter.Description); + } + + [Fact] + public async Task GetSkillsAsync_LicenseField_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "licensed-skill", + "---\nname: licensed-skill\ndescription: A skill with license\nlicense: MIT\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.Equal("MIT", skills[0].Frontmatter.License); + } + + [Fact] + public async Task GetSkillsAsync_CompatibilityField_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "compat-skill", + "---\nname: compat-skill\ndescription: A skill with compatibility\ncompatibility: Requires Node.js 18+\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.Equal("Requires Node.js 18+", skills[0].Frontmatter.Compatibility); + } + + [Fact] + public async Task GetSkillsAsync_AllowedToolsField_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "tools-skill", + "---\nname: tools-skill\ndescription: A skill with tools\nallowed-tools: grep glob bash\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.Equal("grep glob bash", skills[0].Frontmatter.AllowedTools); + } + + [Fact] + public async Task GetSkillsAsync_MetadataField_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "meta-skill", + "---\nname: meta-skill\ndescription: A skill with metadata\nmetadata:\n author: test-user\n version: 1.0\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.NotNull(skills[0].Frontmatter.Metadata); + Assert.Equal("test-user", skills[0].Frontmatter.Metadata!["author"]?.ToString()); + Assert.Equal("1.0", skills[0].Frontmatter.Metadata!["version"]?.ToString()); + } + + [Fact] + public async Task GetSkillsAsync_MetadataWithQuotedValues_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "quoted-meta", + "---\nname: quoted-meta\ndescription: Metadata with quotes\nmetadata:\n key1: 'single quoted'\n key2: \"double quoted\"\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.NotNull(skills[0].Frontmatter.Metadata); + Assert.Equal("single quoted", skills[0].Frontmatter.Metadata!["key1"]?.ToString()); + Assert.Equal("double quoted", skills[0].Frontmatter.Metadata!["key2"]?.ToString()); + } + + [Fact] + public async Task GetSkillsAsync_AllOptionalFields_ParsedCorrectlyAsync() + { + // Arrange + string content = string.Join( + "\n", + "---", + "name: full-skill", + "description: A skill with all fields", + "license: Apache-2.0", + "compatibility: Requires Python 3.10+", + "allowed-tools: grep glob view", + "metadata:", + " org: contoso", + " tier: premium", + "---", + "Full body content."); + _ = this.CreateSkillDirectoryWithRawContent("full-skill", content); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + var fm = skills[0].Frontmatter; + Assert.Equal("full-skill", fm.Name); + Assert.Equal("A skill with all fields", fm.Description); + Assert.Equal("Apache-2.0", fm.License); + Assert.Equal("Requires Python 3.10+", fm.Compatibility); + Assert.Equal("grep glob view", fm.AllowedTools); + Assert.NotNull(fm.Metadata); + Assert.Equal("contoso", fm.Metadata!["org"]?.ToString()); + Assert.Equal("premium", fm.Metadata!["tier"]?.ToString()); + } + + [Fact] + public async Task GetSkillsAsync_NoOptionalFields_DefaultsToNullAsync() + { + // Arrange + _ = this.CreateSkillDirectory("basic-skill", "A basic skill", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + var fm = skills[0].Frontmatter; + Assert.Null(fm.License); + Assert.Null(fm.Compatibility); + Assert.Null(fm.AllowedTools); + Assert.Null(fm.Metadata); } private string CreateSkillDirectory(string name, string description, string body) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs deleted file mode 100644 index 5da49525d4..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs +++ /dev/null @@ -1,266 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.UnitTests.AgentSkills; - -/// -/// Unit tests for the class. -/// -public sealed class FileAgentSkillsProviderTests : IDisposable -{ - private readonly string _testRoot; - private readonly TestAIAgent _agent = new(); - - public FileAgentSkillsProviderTests() - { - this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(this._testRoot); - } - - public void Dispose() - { - if (Directory.Exists(this._testRoot)) - { - Directory.Delete(this._testRoot, recursive: true); - } - } - - [Fact] - public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync() - { - // Arrange - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext { Instructions = "Original instructions" }; - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.Equal("Original instructions", result.Instructions); - Assert.Null(result.Tools); - } - - [Fact] - public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync() - { - // Arrange - this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext { Instructions = "Base instructions" }; - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.Contains("Base instructions", result.Instructions); - Assert.Contains("provider-skill", result.Instructions); - Assert.Contains("Provider skill test", result.Instructions); - - // Should have load_skill and read_skill_resource tools - Assert.NotNull(result.Tools); - var toolNames = result.Tools!.Select(t => t.Name).ToList(); - Assert.Contains("load_skill", toolNames); - Assert.Contains("read_skill_resource", toolNames); - } - - [Fact] - public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync() - { - // Arrange - this.CreateSkill("null-instr-skill", "Null instruction test", "Body."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.Contains("null-instr-skill", result.Instructions); - } - - [Fact] - public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync() - { - // Arrange - this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body."); - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "Custom template: {0}" - }; - var provider = new FileAgentSkillsProvider(this._testRoot, options); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.StartsWith("Custom template:", result.Instructions); - Assert.Contains("custom-prompt-skill", result.Instructions); - Assert.Contains("Custom prompt", result.Instructions); - } - - [Fact] - public void Constructor_InvalidPromptTemplate_ThrowsArgumentException() - { - // Arrange — template with unescaped braces and no valid {0} placeholder - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "Bad template with {unescaped} braces" - }; - - // Act & Assert - var ex = Assert.Throws(() => new FileAgentSkillsProvider(this._testRoot, options)); - Assert.Contains("SkillsInstructionPrompt", ex.Message); - Assert.Equal("options", ex.ParamName); - } - - [Fact] - public void Constructor_PromptWithoutPlaceholder_ThrowsArgumentException() - { - // Arrange -- valid format string but missing the required placeholder - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "No placeholder here" - }; - - var ex = Assert.Throws(() => new FileAgentSkillsProvider(this._testRoot, options)); - Assert.Contains("{0}", ex.Message); - Assert.Equal("options", ex.ParamName); - } - - [Fact] - public async Task Constructor_PromptWithPlaceholder_AppliesCustomTemplateAsync() - { - // Arrange — valid custom template with {0} placeholder - this.CreateSkill("custom-tpl-skill", "Custom template skill", "Body."); - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "== Skills ==\n{0}\n== End ==" - }; - var provider = new FileAgentSkillsProvider(this._testRoot, options); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert — the custom template wraps the skill list - Assert.NotNull(result.Instructions); - Assert.StartsWith("== Skills ==", result.Instructions); - Assert.Contains("custom-tpl-skill", result.Instructions); - Assert.Contains("== End ==", result.Instructions); - } - - [Fact] - public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync() - { - // Arrange — description with XML-sensitive characters - string skillDir = Path.Combine(this._testRoot, "xml-skill"); - Directory.CreateDirectory(skillDir); - File.WriteAllText( - Path.Combine(skillDir, "SKILL.md"), - "---\nname: xml-skill\ndescription: Uses & \"quotes\"\n---\nBody."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.Contains("<tags>", result.Instructions); - Assert.Contains("&", result.Instructions); - } - - [Fact] - public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync() - { - // Arrange - string dir1 = Path.Combine(this._testRoot, "dir1"); - string dir2 = Path.Combine(this._testRoot, "dir2"); - CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); - CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); - - // Act - var provider = new FileAgentSkillsProvider(new[] { dir1, dir2 }); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); - - // Assert - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - Assert.NotNull(result.Instructions); - Assert.Contains("skill-a", result.Instructions); - Assert.Contains("skill-b", result.Instructions); - } - - [Fact] - public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync() - { - // Arrange - this.CreateSkill("tools-skill", "Tools test", "Body."); - var provider = new FileAgentSkillsProvider(this._testRoot); - - var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool."); - var inputContext = new AIContext { Tools = new[] { existingTool } }; - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert — existing tool should be preserved alongside the new skill tools - Assert.NotNull(result.Tools); - var toolNames = result.Tools!.Select(t => t.Name).ToList(); - Assert.Contains("existing_tool", toolNames); - Assert.Contains("load_skill", toolNames); - Assert.Contains("read_skill_resource", toolNames); - } - - [Fact] - public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync() - { - // Arrange — create skills in reverse alphabetical order - this.CreateSkill("zulu-skill", "Zulu skill", "Body Z."); - this.CreateSkill("alpha-skill", "Alpha skill", "Body A."); - this.CreateSkill("mike-skill", "Mike skill", "Body M."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert — skills should appear in alphabetical order in the prompt - Assert.NotNull(result.Instructions); - int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal); - int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal); - int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal); - Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill"); - Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill"); - } - - private void CreateSkill(string name, string description, string body) - { - CreateSkillIn(this._testRoot, name, description, body); - } - - private static void CreateSkillIn(string root, string name, string description, string body) - { - string skillDir = Path.Combine(root, name); - Directory.CreateDirectory(skillDir); - File.WriteAllText( - Path.Combine(skillDir, "SKILL.md"), - $"---\nname: {name}\ndescription: {description}\n---\n{body}"); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs new file mode 100644 index 0000000000..de145004e0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class FilteringAgentSkillsSourceTests +{ + [Fact] + public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("skill-a", "A", "Instructions A."), + new TestAgentSkill("skill-b", "B", "Instructions B.")); + var source = new FilteringAgentSkillsSource(inner, _ => true); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + } + + [Fact] + public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("skill-a", "A", "Instructions A."), + new TestAgentSkill("skill-b", "B", "Instructions B.")); + var source = new FilteringAgentSkillsSource(inner, _ => false); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("keep-me", "Keep", "Instructions."), + new TestAgentSkill("drop-me", "Drop", "Instructions."), + new TestAgentSkill("keep-also", "KeepAlso", "Instructions.")); + var source = new FilteringAgentSkillsSource( + inner, + skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase)); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, s => Assert.StartsWith("keep", s.Frontmatter.Name)); + } + + [Fact] + public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource(Array.Empty()); + var source = new FilteringAgentSkillsSource(inner, _ => true); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void Constructor_NullPredicate_Throws() + { + // Arrange + var inner = new TestAgentSkillsSource(Array.Empty()); + + // Act & Assert + Assert.Throws(() => new FilteringAgentSkillsSource(inner, null!)); + } + + [Fact] + public void Constructor_NullInnerSource_Throws() + { + // Act & Assert + Assert.Throws(() => new FilteringAgentSkillsSource(null!, _ => true)); + } + + [Fact] + public async Task GetSkillsAsync_PreservesOrderAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("alpha", "Alpha", "Instructions."), + new TestAgentSkill("beta", "Beta", "Instructions."), + new TestAgentSkill("gamma", "Gamma", "Instructions."), + new TestAgentSkill("delta", "Delta", "Instructions.")); + + // Keep only alpha and gamma + var source = new FilteringAgentSkillsSource( + inner, + skill => skill.Frontmatter.Name is "alpha" or "gamma"); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("alpha", result[0].Frontmatter.Name); + Assert.Equal("gamma", result[1].Frontmatter.Name); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs new file mode 100644 index 0000000000..8c97a31ae4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// A simple in-memory implementation for unit tests. +/// +internal sealed class TestAgentSkill : AgentSkill +{ + private readonly AgentSkillFrontmatter _frontmatter; + private readonly string _content; + + /// + /// Initializes a new instance of the class. + /// + /// Kebab-case skill name. + /// Skill description. + /// Full skill content (body text). + public TestAgentSkill(string name, string description, string content) + { + this._frontmatter = new AgentSkillFrontmatter(name, description); + this._content = content; + } + + /// + public override AgentSkillFrontmatter Frontmatter => this._frontmatter; + + /// + public override string Content => this._content; + + /// + public override IReadOnlyList? Resources => null; + + /// + public override IReadOnlyList? Scripts => null; +} + +/// +/// A simple in-memory implementation for unit tests. +/// +internal sealed class TestAgentSkillsSource : AgentSkillsSource +{ + private readonly IList _skills; + + /// + /// Initializes a new instance of the class. + /// + /// The skills to return. + public TestAgentSkillsSource(IList skills) + { + this._skills = skills; + } + + /// + /// Initializes a new instance of the class. + /// + /// The skills to return. + public TestAgentSkillsSource(params AgentSkill[] skills) + { + this._skills = skills; + } + + /// + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(this._skills); + } +} From 3611be82cf034d598278861ff246dace86d15a19 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:43:25 +0900 Subject: [PATCH 23/31] Bump flatted from 3.3.3 to 3.4.2 in /python/packages/devui/frontend (#4805) Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2. - [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2) --- updated-dependencies: - dependency-name: flatted dependency-version: 3.4.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- python/packages/devui/frontend/package-lock.json | 6 +++--- python/packages/devui/frontend/yarn.lock | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/python/packages/devui/frontend/package-lock.json b/python/packages/devui/frontend/package-lock.json index 4a43bcd90f..e137c1053c 100644 --- a/python/packages/devui/frontend/package-lock.json +++ b/python/packages/devui/frontend/package-lock.json @@ -3892,9 +3892,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, diff --git a/python/packages/devui/frontend/yarn.lock b/python/packages/devui/frontend/yarn.lock index c0278b182e..24636d2146 100644 --- a/python/packages/devui/frontend/yarn.lock +++ b/python/packages/devui/frontend/yarn.lock @@ -1806,9 +1806,9 @@ flat-cache@^4.0.0: keyv "^4.5.4" flatted@^3.2.9: - version "3.3.3" - resolved "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz" - integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + version "3.4.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" + integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" From cc0cfaaac8e17cea6d4d9af2783376e858ad7169 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 27 Mar 2026 14:33:39 +0100 Subject: [PATCH 24/31] [BREAKING] Python: fix OpenAI Azure routing and provider samples (#4925) * Python: fix OpenAI Azure routing and provider samples Prefer OpenAI when OPENAI_API_KEY is present unless Azure is explicitly requested. Clarify constructor docs, keep deprecated Azure wrappers compatible with stricter settings validation, and refresh the provider samples and tests to use the current client patterns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix bandit * Python: align OpenAI embedding Azure routing Extend the shared OpenAI-vs-Azure routing and credential behavior to the embedding client, add Azure embedding regression coverage, and refresh the embedding samples to use the generic client path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: fix embedding client pyright check Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: thin OpenAI embedding wrapper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: document embedding overload routing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: fix callable OpenAI key routing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: fix Azure credential routing tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: address OpenAI review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: narrow Azure routing markers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: refine OpenAI model fallback order Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: narrow Azure deployment docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: remove embedding routing wording Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: run embedding Azure integration tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * changed variable name * Python: expand OpenAI package README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * clarified readme * Python: fix Azure OpenAI integration setup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: correct Azure integration env mapping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * updated code to fix int tests * test updates * test fix * fix test setup * updates to tests and setup * remove openai assistants int tests * improvements in int tests * fix env var * fix env vars * fix azure responses test * trigger actions --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/python-integration-tests.yml | 46 +- .github/workflows/python-merge-tests.yml | 44 +- python/.env.example | 8 +- python/DEV_SETUP.md | 4 +- python/README.md | 20 +- .../_deprecated_azure_openai.py | 85 +- .../azure_openai/test_azure_chat_client.py | 39 + .../test_azure_embedding_client.py | 83 +- .../test_azure_responses_client.py | 389 +++------ .../test_azure_responses_client_foundry.py | 131 +++ .../azure-ai/tests/test_azure_ai_client.py | 18 +- python/packages/core/README.md | 4 +- .../agent_framework_devui/ui/assets/index.js | 7 +- python/packages/devui/dev.md | 2 +- .../components/layout/deployment-modal.tsx | 4 +- .../agent_framework_foundry/__init__.py | 7 +- .../{_foundry_agent_client.py => _agent.py} | 293 ++++++- ...foundry_chat_client.py => _chat_client.py} | 61 +- .../_entra_id_authentication.py | 67 -- .../agent_framework_foundry/_foundry_agent.py | 287 ------- ...memory_provider.py => _memory_provider.py} | 31 +- .../agent_framework_foundry/_shared.py | 49 -- .../foundry/tests/assets/sample_image.jpg | Bin 0 -> 182161 bytes .../foundry/tests/{ => foundry}/conftest.py | 0 .../tests/foundry/test_foundry_agent.py | 413 ++++++++++ .../tests/foundry/test_foundry_chat_client.py | 751 ++++++++++++++++++ .../foundry/test_foundry_memory_provider.py | 501 ++++++++++++ .../foundry/tests/test_foundry_agent.py | 374 --------- .../tests/test_foundry_memory_provider.py | 507 ------------ .../packages/lab/gaia/samples/openai_agent.py | 4 +- python/packages/openai/AGENTS.md | 4 + python/packages/openai/README.md | 97 ++- .../agent_framework_openai/_chat_client.py | 400 ++++++---- .../_chat_completion_client.py | 523 +++++++----- .../_embedding_client.py | 418 +++++++--- .../openai/agent_framework_openai/_shared.py | 315 +++++--- .../packages/openai/tests/openai/conftest.py | 13 + .../tests/openai/test_assistant_provider.py | 62 -- .../tests/openai/test_openai_chat_client.py | 348 ++++---- .../openai/test_openai_chat_client_azure.py | 257 +++--- .../test_openai_chat_completion_client.py | 130 ++- ...est_openai_chat_completion_client_azure.py | 186 +++-- .../openai/test_openai_embedding_client.py | 51 +- .../test_openai_embedding_client_azure.py | 250 ++++++ .../openai/tests/openai/test_openai_shared.py | 54 ++ .../samples/02-agents/chat_client/README.md | 4 +- .../search_context_semantic.py | 17 +- python/samples/02-agents/devui/README.md | 2 +- .../embeddings/azure_openai_embeddings.py | 74 +- .../02-agents/embeddings/openai_embeddings.py | 21 +- python/samples/02-agents/mcp/README.md | 2 +- python/samples/02-agents/middleware/README.md | 2 +- .../02-agents/observability/.env.example | 4 +- .../02-agents/providers/azure/README.md | 48 +- ...=> openai_chat_completion_client_basic.py} | 33 +- ...mpletion_client_with_explicit_settings.py} | 21 +- ..._completion_client_with_function_tools.py} | 0 ...ai_chat_completion_client_with_session.py} | 0 .../providers/azure/openai_client_basic.py | 90 +++ .../openai_client_with_function_tools.py | 137 ++++ .../azure/openai_client_with_session.py | 152 ++++ .../openai_client_with_structured_output.py | 93 +++ .../02-agents/providers/custom/README.md | 6 +- .../02-agents/providers/openai/README.md | 102 ++- .../openai/chat_completion_client_basic.py | 85 ++ ...mpletion_client_with_explicit_settings.py} | 18 +- ..._completion_client_with_function_tools.py} | 14 +- ... chat_completion_client_with_local_mcp.py} | 12 +- ...letion_client_with_runtime_json_schema.py} | 10 +- ...=> chat_completion_client_with_session.py} | 16 +- ...chat_completion_client_with_web_search.py} | 8 +- ...i_chat_client_basic.py => client_basic.py} | 26 +- ...e_analysis.py => client_image_analysis.py} | 12 +- ...neration.py => client_image_generation.py} | 10 +- ...lient_reasoning.py => client_reasoning.py} | 8 +- ...y => client_streaming_image_generation.py} | 6 +- ...s_tool.py => client_with_agent_as_tool.py} | 8 +- ...ter.py => client_with_code_interpreter.py} | 12 +- ... => client_with_code_interpreter_files.py} | 10 +- ...gs.py => client_with_explicit_settings.py} | 0 ...e_search.py => client_with_file_search.py} | 12 +- ...tools.py => client_with_function_tools.py} | 0 ...osted_mcp.py => client_with_hosted_mcp.py} | 16 +- ..._local_mcp.py => client_with_local_mcp.py} | 12 +- ...al_shell.py => client_with_local_shell.py} | 6 +- ....py => client_with_runtime_json_schema.py} | 4 +- ...with_session.py => client_with_session.py} | 14 +- ...ent_with_shell.py => client_with_shell.py} | 12 +- ...ut.py => client_with_structured_output.py} | 16 +- ...eb_search.py => client_with_web_search.py} | 8 +- .../openai/openai_assistants_basic.py | 98 --- .../openai_assistants_provider_methods.py | 158 ---- ...openai_assistants_with_code_interpreter.py | 81 -- ...enai_assistants_with_existing_assistant.py | 118 --- ...penai_assistants_with_explicit_settings.py | 61 -- .../openai_assistants_with_file_search.py | 78 -- .../openai_assistants_with_function_tools.py | 159 ---- .../openai_assistants_with_response_format.py | 96 --- .../openai/openai_assistants_with_session.py | 172 ---- .../openai/openai_responses_client_basic.py | 132 --- .../05-end-to-end/m365-agent/.env.example | 2 +- .../05-end-to-end/m365-agent/README.md | 2 +- python/samples/README.md | 10 + 103 files changed, 5451 insertions(+), 4216 deletions(-) create mode 100644 python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py rename python/packages/foundry/agent_framework_foundry/{_foundry_agent_client.py => _agent.py} (57%) rename python/packages/foundry/agent_framework_foundry/{_foundry_chat_client.py => _chat_client.py} (92%) delete mode 100644 python/packages/foundry/agent_framework_foundry/_entra_id_authentication.py delete mode 100644 python/packages/foundry/agent_framework_foundry/_foundry_agent.py rename python/packages/foundry/agent_framework_foundry/{_foundry_memory_provider.py => _memory_provider.py} (94%) delete mode 100644 python/packages/foundry/agent_framework_foundry/_shared.py create mode 100644 python/packages/foundry/tests/assets/sample_image.jpg rename python/packages/foundry/tests/{ => foundry}/conftest.py (100%) create mode 100644 python/packages/foundry/tests/foundry/test_foundry_agent.py create mode 100644 python/packages/foundry/tests/foundry/test_foundry_chat_client.py create mode 100644 python/packages/foundry/tests/foundry/test_foundry_memory_provider.py delete mode 100644 python/packages/foundry/tests/test_foundry_agent.py delete mode 100644 python/packages/foundry/tests/test_foundry_memory_provider.py create mode 100644 python/packages/openai/tests/openai/test_openai_embedding_client_azure.py create mode 100644 python/packages/openai/tests/openai/test_openai_shared.py rename python/samples/02-agents/providers/azure/{openai_chat_completion_client_azure_basic.py => openai_chat_completion_client_basic.py} (70%) rename python/samples/02-agents/providers/azure/{openai_chat_completion_client_azure_with_explicit_settings.py => openai_chat_completion_client_with_explicit_settings.py} (71%) rename python/samples/02-agents/providers/azure/{openai_chat_completion_client_azure_with_function_tools.py => openai_chat_completion_client_with_function_tools.py} (100%) rename python/samples/02-agents/providers/azure/{openai_chat_completion_client_azure_with_session.py => openai_chat_completion_client_with_session.py} (100%) create mode 100644 python/samples/02-agents/providers/azure/openai_client_basic.py create mode 100644 python/samples/02-agents/providers/azure/openai_client_with_function_tools.py create mode 100644 python/samples/02-agents/providers/azure/openai_client_with_session.py create mode 100644 python/samples/02-agents/providers/azure/openai_client_with_structured_output.py create mode 100644 python/samples/02-agents/providers/openai/chat_completion_client_basic.py rename python/samples/02-agents/providers/openai/{openai_responses_client_with_explicit_settings.py => chat_completion_client_with_explicit_settings.py} (74%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_function_tools.py => chat_completion_client_with_function_tools.py} (91%) rename python/samples/02-agents/providers/openai/{openai_chat_client_with_local_mcp.py => chat_completion_client_with_local_mcp.py} (88%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_runtime_json_schema.py => chat_completion_client_with_runtime_json_schema.py} (89%) rename python/samples/02-agents/providers/openai/{openai_chat_client_with_session.py => chat_completion_client_with_session.py} (91%) rename python/samples/02-agents/providers/openai/{openai_chat_client_with_web_search.py => chat_completion_client_with_web_search.py} (86%) rename python/samples/02-agents/providers/openai/{openai_chat_client_basic.py => client_basic.py} (73%) rename python/samples/02-agents/providers/openai/{openai_responses_client_image_analysis.py => client_image_analysis.py} (70%) rename python/samples/02-agents/providers/openai/{openai_responses_client_image_generation.py => client_image_generation.py} (90%) rename python/samples/02-agents/providers/openai/{openai_responses_client_reasoning.py => client_reasoning.py} (91%) rename python/samples/02-agents/providers/openai/{openai_responses_client_streaming_image_generation.py => client_streaming_image_generation.py} (96%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_agent_as_tool.py => client_with_agent_as_tool.py} (90%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_code_interpreter.py => client_with_code_interpreter.py} (85%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_code_interpreter_files.py => client_with_code_interpreter_files.py} (92%) rename python/samples/02-agents/providers/openai/{openai_chat_client_with_explicit_settings.py => client_with_explicit_settings.py} (100%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_file_search.py => client_with_file_search.py} (85%) rename python/samples/02-agents/providers/openai/{openai_chat_client_with_function_tools.py => client_with_function_tools.py} (100%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_hosted_mcp.py => client_with_hosted_mcp.py} (95%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_local_mcp.py => client_with_local_mcp.py} (90%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_local_shell.py => client_with_local_shell.py} (96%) rename python/samples/02-agents/providers/openai/{openai_chat_client_with_runtime_json_schema.py => client_with_runtime_json_schema.py} (95%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_session.py => client_with_session.py} (93%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_shell.py => client_with_shell.py} (82%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_structured_output.py => client_with_structured_output.py} (87%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_web_search.py => client_with_web_search.py} (88%) delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_basic.py delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_with_session.py delete mode 100644 python/samples/02-agents/providers/openai/openai_responses_client_basic.py diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index 1b1c8066c6..2d12425312 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -60,9 +60,8 @@ jobs: environment: integration timeout-minutes: 60 env: - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} - OPENAI_EMBEDDINGS_MODEL_ID: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} @@ -96,10 +95,10 @@ jobs: environment: integration timeout-minutes: 60 env: - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }} + AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} defaults: run: @@ -126,7 +125,9 @@ jobs: uv run pytest --import-mode=importlib packages/openai/tests/openai/test_openai_chat_completion_client_azure.py packages/openai/tests/openai/test_openai_chat_client_azure.py + packages/openai/tests/openai/test_openai_embedding_client_azure.py packages/azure-ai/tests/azure_openai + --ignore=packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread @@ -202,15 +203,15 @@ jobs: timeout-minutes: 60 env: UV_PYTHON: "3.11" - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} - OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} + OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} - FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FUNCTIONS_WORKER_RUNTIME: "python" DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" AzureWebJobsStorage: "UseDevelopmentStorage=true" @@ -248,17 +249,19 @@ jobs: --timeout=360 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 - # Azure AI integration tests - python-tests-azure-ai: - name: Python Integration Tests - Azure AI + # Foundry integration tests + python-tests-foundry: + name: Python Integration Tests - Foundry runs-on: ubuntu-latest environment: integration timeout-minutes: 60 env: AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }} - FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} - FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} + FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }} + FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }} LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }} defaults: run: @@ -282,9 +285,14 @@ jobs: subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Test with pytest timeout-minutes: 15 - run: | - uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 - uv run --directory packages/foundry poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + run: > + uv run pytest --import-mode=importlib + packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py + packages/foundry/tests + -m integration + -n logical --dist worksteal + --timeout=120 --session-timeout=900 --timeout_method thread + --retries 2 --retry-delay 5 # Azure Cosmos integration tests python-tests-cosmos: @@ -341,7 +349,7 @@ jobs: python-tests-azure-openai, python-tests-misc-integration, python-tests-functions, - python-tests-azure-ai, + python-tests-foundry, python-tests-cosmos ] steps: diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index a46beb40cb..f32fceccb5 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -141,9 +141,8 @@ jobs: runs-on: ubuntu-latest environment: integration env: - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} - OPENAI_EMBEDDINGS_MODEL_ID: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} @@ -195,10 +194,10 @@ jobs: runs-on: ubuntu-latest environment: integration env: - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }} + AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} defaults: run: @@ -223,7 +222,9 @@ jobs: uv run pytest --import-mode=importlib packages/openai/tests/openai/test_openai_chat_completion_client_azure.py packages/openai/tests/openai/test_openai_chat_client_azure.py + packages/openai/tests/openai/test_openai_embedding_client_azure.py packages/azure-ai/tests/azure_openai + --ignore=packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread @@ -333,15 +334,15 @@ jobs: environment: integration env: UV_PYTHON: "3.11" - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} - OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} + OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} - FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FUNCTIONS_WORKER_RUNTIME: "python" DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" AzureWebJobsStorage: "UseDevelopmentStorage=true" @@ -387,8 +388,8 @@ jobs: fail-on-empty: false title: Functions integration test results - python-tests-azure-ai: - name: Python Tests - Azure AI + python-tests-foundry: + name: Python Integration Tests - Foundry needs: paths-filter if: > github.event_name != 'pull_request' && @@ -401,8 +402,10 @@ jobs: env: AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }} - FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} - FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} + FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }} + FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }} LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }} defaults: run: @@ -424,9 +427,14 @@ jobs: subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Test with pytest timeout-minutes: 15 - run: | - uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 - uv run --directory packages/foundry poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + run: > + uv run pytest --import-mode=importlib + packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py + packages/foundry/tests + -m integration + -n logical --dist worksteal + --timeout=120 --session-timeout=900 --timeout_method thread + --retries 2 --retry-delay 5 working-directory: ./python - name: Test Azure AI samples timeout-minutes: 10 @@ -513,7 +521,7 @@ jobs: python-tests-azure-openai, python-tests-misc-integration, python-tests-functions, - python-tests-azure-ai, + python-tests-foundry, python-tests-cosmos, ] steps: diff --git a/python/.env.example b/python/.env.example index c09300d775..4e7ba727e5 100644 --- a/python/.env.example +++ b/python/.env.example @@ -1,6 +1,6 @@ # Azure AI -AZURE_AI_PROJECT_ENDPOINT="" -AZURE_AI_MODEL_DEPLOYMENT_NAME="" +FOUNDRY_PROJECT_ENDPOINT="" +FOUNDRY_MODEL="" # Bing connection for web search (optional, used by samples with web search) BING_CONNECTION_ID="" # Azure AI Search (optional, used by AzureAISearchContextProvider samples) @@ -13,8 +13,8 @@ AZURE_SEARCH_KNOWLEDGE_BASE_NAME="" # (different from AZURE_AI_PROJECT_ENDPOINT - Knowledge Base needs OpenAI endpoint for model calls) # OpenAI OPENAI_API_KEY="" -OPENAI_CHAT_MODEL_ID="" -OPENAI_RESPONSES_MODEL_ID="" +OPENAI_CHAT_MODEL="" +OPENAI_RESPONSES_MODEL="" # Azure OpenAI AZURE_OPENAI_ENDPOINT="" AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="" diff --git a/python/DEV_SETUP.md b/python/DEV_SETUP.md index d90e29226d..dbddbaac93 100644 --- a/python/DEV_SETUP.md +++ b/python/DEV_SETUP.md @@ -108,10 +108,10 @@ Content of `.env` or `openai.env`: ```env OPENAI_API_KEY="" -OPENAI_CHAT_MODEL_ID="gpt-4o-mini" +OPENAI_MODEL="gpt-4o-mini" ``` -You will then configure the ChatClient class with the keyword argument `env_file_path`: +You will then configure the ChatClient class with the keyword argument `env_file_path` (alternatively you can use `load_dotenv` in your code): ```python from agent_framework.openai import OpenAIChatClient diff --git a/python/README.md b/python/README.md index f9350a08a4..0a3042992f 100644 --- a/python/README.md +++ b/python/README.md @@ -47,7 +47,7 @@ Set as environment variables, or create a .env file at your project root: ```bash OPENAI_API_KEY=sk-... -OPENAI_CHAT_MODEL_ID=... +OPENAI_MODEL=... ... AZURE_OPENAI_API_KEY=... AZURE_OPENAI_ENDPOINT=... @@ -57,15 +57,25 @@ FOUNDRY_PROJECT_ENDPOINT=... FOUNDRY_MODEL=... ``` +For the generic OpenAI clients (`OpenAIChatClient` and `OpenAIChatCompletionClient`), configuration +resolves in this order: + +1. Explicit Azure inputs such as `credential` or `azure_endpoint` +2. `OPENAI_API_KEY` / explicit OpenAI API-key parameters +3. Azure environment fallback such as `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_API_KEY` + +This means mixed shells default to OpenAI when `OPENAI_API_KEY` is present. To force Azure routing, +pass an explicit Azure input such as `credential=AzureCliCredential()`. + You can also override environment variables by explicitly passing configuration parameters to the chat client constructor: ```python -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatClient -client = AzureOpenAIChatClient( +client = OpenAIChatClient( api_key='', - endpoint='', - deployment_name='', + azure_endpoint='', + model='', api_version='', ) ``` diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py b/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py index 8370412394..21f50e930a 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py @@ -13,6 +13,7 @@ import json import logging import sys from collections.abc import Mapping, Sequence +from contextlib import contextmanager from copy import copy from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, cast from urllib.parse import urljoin, urlparse @@ -109,6 +110,12 @@ def _apply_azure_defaults( settings["token_endpoint"] = default_token_endpoint +@contextmanager +def _prefer_single_azure_endpoint_env(*, endpoint: str | None, base_url: str | None) -> Any: + """Preserve the legacy call shape without mutating process-wide environment state.""" + yield + + # endregion @@ -315,6 +322,8 @@ class AzureOpenAIResponsesClient( # type: ignore[misc] "or 'AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME' environment variable." ) + endpoint_value = azure_openai_settings.get("endpoint") + client_base_url = azure_openai_settings.get("base_url") if not async_client: # Create the Azure OpenAI client directly merged_headers = dict(copy(default_headers)) if default_headers else {} @@ -332,9 +341,7 @@ class AzureOpenAIResponsesClient( # type: ignore[misc] if not api_key_secret and not ad_token_provider: raise ValueError("Please provide either api_key, credential, or a client.") - client_endpoint = azure_openai_settings.get("endpoint") - client_base_url = azure_openai_settings.get("base_url") - if not client_endpoint and not client_base_url: + if not endpoint_value and not client_base_url: raise ValueError("Please provide an endpoint or a base_url") client_args: dict[str, Any] = {"default_headers": merged_headers} @@ -346,8 +353,8 @@ class AzureOpenAIResponsesClient( # type: ignore[misc] client_args["api_key"] = api_key_secret.get_secret_value() if client_base_url: client_args["base_url"] = str(client_base_url) - if client_endpoint and not client_base_url: - client_args["azure_endpoint"] = str(client_endpoint) + if endpoint_value and not client_base_url: + client_args["azure_endpoint"] = str(endpoint_value) if responses_deployment_name: client_args["azure_deployment"] = responses_deployment_name if "websocket_base_url" in kwargs: @@ -360,16 +367,19 @@ class AzureOpenAIResponsesClient( # type: ignore[misc] self.api_version = azure_openai_settings.get("api_version") or "" self.deployment_name = responses_deployment_name - super().__init__( - async_client=async_client, - model=responses_deployment_name, - api_version=azure_openai_settings.get("api_version"), - instruction_role=instruction_role, - default_headers=default_headers, - middleware=middleware, # type: ignore[arg-type] - function_invocation_configuration=function_invocation_configuration, - **kwargs, - ) + with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=client_base_url): + super().__init__( + async_client=async_client, + model=responses_deployment_name, + azure_endpoint=str(endpoint_value) if endpoint_value else None, + base_url=str(client_base_url) if client_base_url else None, + api_version=azure_openai_settings.get("api_version"), + instruction_role=instruction_role, + default_headers=default_headers, + middleware=middleware, # type: ignore[arg-type] + function_invocation_configuration=function_invocation_configuration, + **kwargs, + ) @staticmethod def _create_client_from_project( @@ -530,6 +540,8 @@ class AzureOpenAIChatClient( # type: ignore[misc] "or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable." ) + endpoint_value = azure_openai_settings.get("endpoint") + base_url_value = azure_openai_settings.get("base_url") if not async_client: # Create the Azure OpenAI client directly merged_headers = dict(copy(default_headers)) if default_headers else {} @@ -547,8 +559,6 @@ class AzureOpenAIChatClient( # type: ignore[misc] if not api_key_secret and not ad_token_provider: raise ValueError("Please provide either api_key, credential, or a client.") - endpoint_value = azure_openai_settings.get("endpoint") - base_url_value = azure_openai_settings.get("base_url") if not endpoint_value and not base_url_value: raise ValueError("Please provide an endpoint or a base_url") @@ -573,16 +583,19 @@ class AzureOpenAIChatClient( # type: ignore[misc] self.api_version = azure_openai_settings.get("api_version") or "" self.deployment_name = chat_deployment_name - super().__init__( - async_client=async_client, - model=chat_deployment_name, - api_version=azure_openai_settings.get("api_version"), - instruction_role=instruction_role, - default_headers=default_headers, - additional_properties=additional_properties, - middleware=middleware, # type: ignore[arg-type] - function_invocation_configuration=function_invocation_configuration, - ) + with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=base_url_value): + super().__init__( + async_client=async_client, + model=chat_deployment_name, + azure_endpoint=str(endpoint_value) if endpoint_value else None, + base_url=str(base_url_value) if base_url_value else None, + api_version=azure_openai_settings.get("api_version"), + instruction_role=instruction_role, + default_headers=default_headers, + additional_properties=additional_properties, + middleware=middleware, # type: ignore[arg-type] + function_invocation_configuration=function_invocation_configuration, + ) @override def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | None: @@ -842,6 +855,8 @@ class AzureOpenAIEmbeddingClient( "or 'AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME' environment variable." ) + endpoint_value = azure_openai_settings.get("endpoint") + base_url_value = azure_openai_settings.get("base_url") if not async_client: # Create the Azure OpenAI client directly merged_headers = dict(copy(default_headers)) if default_headers else {} @@ -859,8 +874,6 @@ class AzureOpenAIEmbeddingClient( if not api_key_secret and not ad_token_provider: raise ValueError("Please provide either api_key, credential, or a client.") - endpoint_value = azure_openai_settings.get("endpoint") - base_url_value = azure_openai_settings.get("base_url") if not endpoint_value and not base_url_value: raise ValueError("Please provide an endpoint or a base_url") @@ -885,11 +898,15 @@ class AzureOpenAIEmbeddingClient( self.api_version = azure_openai_settings.get("api_version") or "" self.deployment_name = embedding_deployment_name - super().__init__( - async_client=async_client, - model=embedding_deployment_name, - default_headers=default_headers, - ) + with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=base_url_value): + super().__init__( + async_client=async_client, + model=embedding_deployment_name, + azure_endpoint=str(endpoint_value) if endpoint_value else None, + base_url=str(base_url_value) if base_url_value else None, + api_version=azure_openai_settings.get("api_version"), + default_headers=default_headers, + ) if otel_provider_name is not None: self.OTEL_PROVIDER_NAME = otel_provider_name # type: ignore[misc] diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py index 22e0a20d96..c1485d430d 100644 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py @@ -2,6 +2,8 @@ import json import os +from functools import wraps +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import openai @@ -33,6 +35,8 @@ from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDelta from openai.types.chat.chat_completion_message import ChatCompletionMessage +pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIChatClient is deprecated\\..*:DeprecationWarning") + # region Service Setup skip_if_azure_integration_tests_disabled = pytest.mark.skipif( @@ -41,6 +45,32 @@ skip_if_azure_integration_tests_disabled = pytest.mark.skipif( ) +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as exc: + model = os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME") or os.getenv( + "AZURE_OPENAI_DEPLOYMENT_NAME", "" + ) + api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") + endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") + debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + def test_init(azure_openai_unit_test_env: dict[str, str]) -> None: # Test successful initialization azure_chat_client = AzureOpenAIChatClient() @@ -820,6 +850,7 @@ def get_weather(location: str) -> str: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_response() -> None: """Test Azure OpenAI chat completion responses.""" azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) @@ -851,6 +882,7 @@ async def test_azure_openai_chat_client_response() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_response_tools() -> None: """Test AzureOpenAI chat completion responses.""" azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) @@ -873,6 +905,7 @@ async def test_azure_openai_chat_client_response_tools() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_streaming() -> None: """Test Azure OpenAI chat completion responses.""" azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) @@ -909,6 +942,7 @@ async def test_azure_openai_chat_client_streaming() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_streaming_tools() -> None: """Test AzureOpenAI chat completion responses.""" azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) @@ -937,6 +971,7 @@ async def test_azure_openai_chat_client_streaming_tools() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_agent_basic_run(): """Test Azure OpenAI chat client agent basic run functionality with AzureOpenAIChatClient.""" async with Agent( @@ -954,6 +989,7 @@ async def test_azure_openai_chat_client_agent_basic_run(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_agent_basic_run_streaming(): """Test Azure OpenAI chat client agent basic streaming functionality with AzureOpenAIChatClient.""" async with Agent( @@ -976,6 +1012,7 @@ async def test_azure_openai_chat_client_agent_basic_run_streaming(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_agent_session_persistence(): """Test Azure OpenAI chat client agent session persistence across runs with AzureOpenAIChatClient.""" async with Agent( @@ -1002,6 +1039,7 @@ async def test_azure_openai_chat_client_agent_session_persistence(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_agent_existing_session(): """Test Azure OpenAI chat client agent with existing session to continue conversations across agent instances.""" # First conversation - capture the session @@ -1038,6 +1076,7 @@ async def test_azure_openai_chat_client_agent_existing_session(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_chat_client_agent_level_tool_persistence(): """Test that agent-level tools persist across multiple runs with Azure Chat Client.""" diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py index de78178df1..a172be577f 100644 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py @@ -3,15 +3,20 @@ from __future__ import annotations import os +from functools import wraps +from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest from agent_framework.azure import AzureOpenAIEmbeddingClient -from agent_framework_openai import OpenAIEmbeddingOptions +from agent_framework.openai import OpenAIEmbeddingOptions +from azure.identity.aio import AzureCliCredential from openai.types import CreateEmbeddingResponse from openai.types import Embedding as OpenAIEmbedding from openai.types.create_embedding_response import Usage +pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIEmbeddingClient is deprecated\\..*:DeprecationWarning") + def _make_openai_response( embeddings: list[list[float]], @@ -106,20 +111,72 @@ def test_azure_otel_provider_name(azure_embedding_unit_test_env: None) -> None: skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( - not os.getenv("AZURE_OPENAI_ENDPOINT") - or (not os.getenv("AZURE_OPENAI_API_KEY") and not os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME")), - reason="No Azure OpenAI credentials provided; skipping integration tests.", + os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com") + or ( + os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", "") == "" + and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" + ), + reason="No Azure OpenAI endpoint or embedding deployment provided; skipping integration tests.", ) +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as exc: + model = os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.getenv( + "AZURE_OPENAI_DEPLOYMENT_NAME", "" + ) + api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") + endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") + debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + +def _get_azure_embedding_deployment_name() -> str: + return os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"] + + +def _create_azure_openai_embedding_client( + *, + api_key: str | None = None, + credential: AzureCliCredential | None = None, +) -> AzureOpenAIEmbeddingClient: + resolved_api_key = ( + api_key if api_key is not None else None if credential is not None else os.getenv("AZURE_OPENAI_API_KEY") + ) + return AzureOpenAIEmbeddingClient( + deployment_name=_get_azure_embedding_deployment_name(), + api_key=resolved_api_key, + endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=credential, + ) + + @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_azure_openai_get_embeddings() -> None: """End-to-end test of Azure OpenAI embedding generation.""" - client = AzureOpenAIEmbeddingClient() + async with AzureCliCredential() as credential: + client = _create_azure_openai_embedding_client(credential=credential) - result = await client.get_embeddings(["hello world"]) + result = await client.get_embeddings(["hello world"]) assert len(result) == 1 assert isinstance(result[0].vector, list) @@ -133,11 +190,13 @@ async def test_integration_azure_openai_get_embeddings() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_azure_openai_get_embeddings_multiple() -> None: """Test Azure OpenAI embedding generation for multiple inputs.""" - client = AzureOpenAIEmbeddingClient() + async with AzureCliCredential() as credential: + client = _create_azure_openai_embedding_client(credential=credential) - result = await client.get_embeddings(["hello", "world", "test"]) + result = await client.get_embeddings(["hello", "world", "test"]) assert len(result) == 3 dims = [len(e.vector) for e in result] @@ -147,12 +206,14 @@ async def test_integration_azure_openai_get_embeddings_multiple() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_azure_openai_get_embeddings_with_dimensions() -> None: """Test Azure OpenAI embedding generation with custom dimensions.""" - client = AzureOpenAIEmbeddingClient() + async with AzureCliCredential() as credential: + client = _create_azure_openai_embedding_client(credential=credential) - options: OpenAIEmbeddingOptions = {"dimensions": 256} - result = await client.get_embeddings(["hello world"], options=options) + options: OpenAIEmbeddingOptions = {"dimensions": 256} + result = await client.get_embeddings(["hello world"], options=options) assert len(result) == 1 assert len(result[0].vector) == 256 diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py index 65e9629b96..99bd2061b7 100644 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py @@ -3,9 +3,9 @@ import json import logging import os +from functools import wraps from pathlib import Path from typing import Annotated, Any -from unittest.mock import MagicMock import pytest from agent_framework import ( @@ -22,11 +22,40 @@ from azure.identity import AzureCliCredential from pydantic import BaseModel from pytest import param +pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIResponsesClient is deprecated\\..*:DeprecationWarning") + skip_if_azure_integration_tests_disabled = pytest.mark.skipif( os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com"), reason="No real AZURE_OPENAI_ENDPOINT provided; skipping integration tests.", ) + +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as exc: + model = os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME") or os.getenv( + "AZURE_OPENAI_DEPLOYMENT_NAME", "" + ) + api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") + endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") + debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + logger = logging.getLogger(__name__) @@ -141,119 +170,6 @@ def test_init_with_empty_model_id(azure_openai_unit_test_env: dict[str, str]) -> AzureOpenAIResponsesClient() -def test_init_with_project_client(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test initialization with an existing AIProjectClient.""" - from unittest.mock import patch - - from openai import AsyncOpenAI - - # Create a mock AIProjectClient that returns a mock AsyncOpenAI client - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_openai_client.default_headers = {} - - mock_project_client = MagicMock() - mock_project_client.get_openai_client.return_value = mock_openai_client - - with patch( - "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", - return_value=mock_openai_client, - ): - azure_responses_client = AzureOpenAIResponsesClient( - project_client=mock_project_client, - deployment_name="gpt-4o", - ) - - assert azure_responses_client.model == "gpt-4o" - assert azure_responses_client.client is mock_openai_client - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_init_with_project_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test initialization with a project endpoint and credential.""" - from unittest.mock import patch - - from openai import AsyncOpenAI - - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_openai_client.default_headers = {} - - with patch( - "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", - return_value=mock_openai_client, - ): - azure_responses_client = AzureOpenAIResponsesClient( - project_endpoint="https://test-project.services.ai.azure.com", - deployment_name="gpt-4o", - credential=AzureCliCredential(), - ) - - assert azure_responses_client.model == "gpt-4o" - assert azure_responses_client.client is mock_openai_client - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_create_client_from_project_with_project_client() -> None: - """Test _create_client_from_project with an existing project client.""" - from openai import AsyncOpenAI - - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_project_client = MagicMock() - mock_project_client.get_openai_client.return_value = mock_openai_client - - result = AzureOpenAIResponsesClient._create_client_from_project( - project_client=mock_project_client, - project_endpoint=None, - credential=None, - ) - - assert result is mock_openai_client - mock_project_client.get_openai_client.assert_called_once() - - -def test_create_client_from_project_with_endpoint() -> None: - """Test _create_client_from_project with a project endpoint.""" - from unittest.mock import patch - - from openai import AsyncOpenAI - - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_credential = MagicMock() - - with patch("agent_framework_azure_ai._deprecated_azure_openai.AIProjectClient") as MockAIProjectClient: - mock_instance = MockAIProjectClient.return_value - mock_instance.get_openai_client.return_value = mock_openai_client - - result = AzureOpenAIResponsesClient._create_client_from_project( - project_client=None, - project_endpoint="https://test-project.services.ai.azure.com", - credential=mock_credential, - ) - - assert result is mock_openai_client - MockAIProjectClient.assert_called_once() - mock_instance.get_openai_client.assert_called_once() - - -def test_create_client_from_project_missing_endpoint() -> None: - """Test _create_client_from_project raises error when endpoint is missing.""" - with pytest.raises(ValueError, match="project endpoint is required"): - AzureOpenAIResponsesClient._create_client_from_project( - project_client=None, - project_endpoint=None, - credential=MagicMock(), - ) - - -def test_create_client_from_project_missing_credential() -> None: - """Test _create_client_from_project raises error when credential is missing.""" - with pytest.raises(ValueError, match="credential is required"): - AzureOpenAIResponsesClient._create_client_from_project( - project_client=None, - project_endpoint="https://test-project.services.ai.azure.com", - credential=None, - ) - - def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: default_headers = {"X-Unit-Test": "test-guid"} @@ -285,8 +201,6 @@ def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: "option_name,option_value,needs_validation", [ # Simple ChatOptions - just verify they don't fail - param("temperature", 0.7, False, id="temperature"), - param("top_p", 0.9, False, id="top_p"), param("max_tokens", 500, False, id="max_tokens"), param("seed", 123, False, id="seed"), param("user", "test-user-id", False, id="user"), @@ -299,7 +213,6 @@ def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: # OpenAIResponsesOptions - just verify they don't fail param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"), param("truncation", "auto", False, id="truncation"), - param("top_logprobs", 5, False, id="top_logprobs"), param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"), param("max_tool_calls", 3, False, id="max_tool_calls"), # Complex options requiring output validation @@ -343,6 +256,7 @@ def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: ), ], ) +@_with_azure_openai_debug() async def test_integration_options( option_name: str, option_value: Any, @@ -358,127 +272,84 @@ async def test_integration_options( # Need at least 2 iterations for tool_choice tests: one to get function call, one to get final response client.function_invocation_configuration["max_iterations"] = 2 - for streaming in [False, True]: - # Prepare test message + # Prepare test message + if option_name == "tools" or option_name == "tool_choice": + # Use weather-related prompt for tool tests + messages = [Message(role="user", text="What is the weather in Seattle?")] + elif option_name == "response_format": + # Use prompt that works well with structured output + messages = [ + Message(role="user", text="The weather in Seattle is sunny"), + Message(role="user", text="What is the weather in Seattle?"), + ] + else: + # Generic prompt for simple options + messages = [Message(role="user", text="Say 'Hello World' briefly.")] + + # Build options dict + options: dict[str, Any] = {option_name: option_value} + + # Add tools if testing tool_choice to avoid errors + if option_name == "tool_choice": + options["tools"] = [get_weather] + + # Test streaming mode + response = await client.get_response(messages=messages, stream=True, options=options).get_final_response() + + assert response is not None + assert isinstance(response, ChatResponse) + assert response.text is not None, f"No text in response for option '{option_name}'" + assert len(response.text) > 0, f"Empty response for option '{option_name}'" + + # Validate based on option type + if needs_validation: if option_name == "tools" or option_name == "tool_choice": - # Use weather-related prompt for tool tests - messages = [Message(role="user", text="What is the weather in Seattle?")] + # Should have called the weather function + text = response.text.lower() + assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" elif option_name == "response_format": - # Use prompt that works well with structured output - messages = [ - Message(role="user", text="The weather in Seattle is sunny"), - Message(role="user", text="What is the weather in Seattle?"), - ] - else: - # Generic prompt for simple options - messages = [Message(role="user", text="Say 'Hello World' briefly.")] - - # Build options dict - options: dict[str, Any] = {option_name: option_value} - - # Add tools if testing tool_choice to avoid errors - if option_name == "tool_choice": - options["tools"] = [get_weather] - - if streaming: - # Test streaming mode - response_stream = client.get_response( - messages=messages, - stream=True, - options=options, - ) - - response = await response_stream.get_final_response() - else: - # Test non-streaming mode - response = await client.get_response( - messages=messages, - options=options, - ) - - assert response is not None - assert isinstance(response, ChatResponse) - assert response.text is not None, f"No text in response for option '{option_name}'" - assert len(response.text) > 0, f"Empty response for option '{option_name}'" - - # Validate based on option type - if needs_validation: - if option_name == "tools" or option_name == "tool_choice": - # Should have called the weather function - text = response.text.lower() - assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" - elif option_name == "response_format": - if option_value == OutputStruct: - # Should have structured output - assert response.value is not None, "No structured output" - assert isinstance(response.value, OutputStruct) - assert "seattle" in response.value.location.lower() - else: - # Runtime JSON schema - assert response.value is None, "No structured output, can't parse any json." - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value - assert "seattle" in response_value["location"].lower() + if option_value == OutputStruct: + # Should have structured output + assert response.value is not None, "No structured output" + assert isinstance(response.value, OutputStruct) + assert "seattle" in response.value.location.lower() + else: + # Runtime JSON schema + assert response.value is None, "No structured output, can't parse any json." + response_value = json.loads(response.text) + assert isinstance(response_value, dict) + assert "location" in response_value + assert "seattle" in response_value["location"].lower() @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_web_search() -> None: client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) + response = await client.get_response( + messages=[ + Message( + role="user", + text="What is the current weather? Do not ask for my current location.", + ) + ], + options={ + "tools": [ + AzureOpenAIResponsesClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"}) + ] + }, + stream=True, + ).get_final_response() - for streaming in [False, True]: - content = { - "messages": [ - Message( - role="user", - text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [AzureOpenAIResponsesClient.get_web_search_tool()], - }, - "stream": streaming, - } - if streaming: - response = await client.get_response(**content).get_final_response() - else: - response = await client.get_response(**content) - - assert response is not None - assert isinstance(response, ChatResponse) - assert "Rumi" in response.text - assert "Mira" in response.text - assert "Zoey" in response.text - - # Test that the client will use the web search tool with location - content = { - "messages": [ - Message( - role="user", - text="What is the current weather? Do not ask for my current location.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [ - AzureOpenAIResponsesClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"}) - ], - }, - "stream": streaming, - } - if streaming: - response = await client.get_response(**content).get_final_response() - else: - response = await client.get_response(**content) - assert response.text is not None + assert response.text is not None @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_file_search() -> None: """Test Azure responses client with file search tool.""" azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) @@ -509,6 +380,7 @@ async def test_integration_client_file_search() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_file_search_streaming() -> None: """Test Azure responses client with file search tool and streaming.""" azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) @@ -541,6 +413,7 @@ async def test_integration_client_file_search_streaming() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_hosted_mcp_tool() -> None: """Integration test for MCP tool with Azure Response Agent using Microsoft Learn MCP.""" client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) @@ -566,6 +439,7 @@ async def test_integration_client_agent_hosted_mcp_tool() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_hosted_code_interpreter_tool(): """Test Azure Responses Client agent with code interpreter tool.""" client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) @@ -591,6 +465,7 @@ async def test_integration_client_agent_hosted_code_interpreter_tool(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_existing_session(): """Test Azure Responses Client agent with existing session to continue conversations across agent instances.""" # First conversation - capture the session @@ -627,6 +502,7 @@ async def test_integration_client_agent_existing_session(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_responses_client_tool_rich_content_image() -> None: """Test that Azure OpenAI Responses client can handle tool results containing images.""" image_path = Path(__file__).parent.parent / "assets" / "sample_image.jpg" @@ -660,70 +536,3 @@ async def test_azure_openai_responses_client_tool_rich_content_image() -> None: assert len(response.text) > 0 # sample_image.jpg contains a photo of a house; the model should mention it. assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}" - - -# region Integration with Foundry V2 - - -skip_if_azure_ai_integration_tests_disabled = pytest.mark.skipif( - os.getenv("AZURE_AI_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/") - or os.getenv("AZURE_AI_MODEL", "") == "", - reason="No real AZURE_AI_PROJECT_ENDPOINT or AZURE_AI_MODEL provided; skipping integration tests.", -) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_integration_function_call_roundtrip_preserves_fidelity(): - """Test that function calls roundtrip correctly with full fidelity preserved. - - This verifies the changes where: - 1. raw_representation is preserved when parsing function calls - 2. fc_id and status are included in additional_properties - 3. When re-sending messages, the full object fidelity is preserved - """ - call_count = 0 - - @tool(name="get_weather", approval_mode="never_require") - async def get_weather_tool(location: str) -> str: - """Get weather for a location.""" - nonlocal call_count - call_count += 1 - return f"Weather in {location} is sunny, 72F" - - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL"], - credential=AzureCliCredential(), - ) - - async with Agent( - client=client, - name="WeatherAgent", - instructions="You help check weather. Use get_weather when asked about weather.", - tools=[get_weather_tool], - default_options={"store": False}, # Store messages locally to test fidelity across messages - ) as agent: - session = agent.create_session() - - # First request - should invoke the tool - response1 = await agent.run("What is the weather in Seattle?", session=session) - - assert response1 is not None - assert response1.text is not None - assert call_count >= 1 - - # Verify the response contains expected content - response_text = response1.text.lower() - assert "seattle" in response_text or "sunny" in response_text or "72" in response_text - - # Second request - should work correctly with the preserved conversation - response2 = await agent.run("And how about in Portland?", session=session) - - assert response2 is not None - assert response2.text is not None - assert call_count >= 2 - - -# endregion diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py new file mode 100644 index 0000000000..bbf10e7b88 --- /dev/null +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py @@ -0,0 +1,131 @@ +# Copyright (c) Microsoft. All rights reserved. + +import warnings +from unittest.mock import MagicMock + +import pytest +from agent_framework import SupportsChatGetResponse + +warnings.filterwarnings( + "ignore", + message=r"RawAzureAIClient is deprecated\..*", + category=DeprecationWarning, +) + +from agent_framework.azure import AzureOpenAIResponsesClient # noqa: E402 +from azure.identity import AzureCliCredential # noqa: E402 + +pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIResponsesClient is deprecated\\..*:DeprecationWarning") + + +def test_init_with_project_client(azure_openai_unit_test_env: dict[str, str]) -> None: + """Test initialization with an existing AIProjectClient.""" + from unittest.mock import patch + + from openai import AsyncOpenAI + + # Create a mock AIProjectClient that returns a mock AsyncOpenAI client + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_openai_client.default_headers = {} + + mock_project_client = MagicMock() + mock_project_client.get_openai_client.return_value = mock_openai_client + + with patch( + "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", + return_value=mock_openai_client, + ): + azure_responses_client = AzureOpenAIResponsesClient( + project_client=mock_project_client, + deployment_name="gpt-4o", + ) + + assert azure_responses_client.model == "gpt-4o" + assert azure_responses_client.client is mock_openai_client + assert isinstance(azure_responses_client, SupportsChatGetResponse) + + +def test_init_with_project_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: + """Test initialization with a project endpoint and credential.""" + from unittest.mock import patch + + from openai import AsyncOpenAI + + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_openai_client.default_headers = {} + + with patch( + "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", + return_value=mock_openai_client, + ): + azure_responses_client = AzureOpenAIResponsesClient( + project_endpoint="https://test-project.services.ai.azure.com", + deployment_name="gpt-4o", + credential=AzureCliCredential(), + ) + + assert azure_responses_client.model == "gpt-4o" + assert azure_responses_client.client is mock_openai_client + assert isinstance(azure_responses_client, SupportsChatGetResponse) + + +def test_create_client_from_project_with_project_client() -> None: + """Test _create_client_from_project with an existing project client.""" + from openai import AsyncOpenAI + + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_project_client = MagicMock() + mock_project_client.get_openai_client.return_value = mock_openai_client + + result = AzureOpenAIResponsesClient._create_client_from_project( + project_client=mock_project_client, + project_endpoint=None, + credential=None, + ) + + assert result is mock_openai_client + mock_project_client.get_openai_client.assert_called_once() + + +def test_create_client_from_project_with_endpoint() -> None: + """Test _create_client_from_project with a project endpoint.""" + from unittest.mock import patch + + from openai import AsyncOpenAI + + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_credential = MagicMock() + + with patch("agent_framework_azure_ai._deprecated_azure_openai.AIProjectClient") as MockAIProjectClient: + mock_instance = MockAIProjectClient.return_value + mock_instance.get_openai_client.return_value = mock_openai_client + + result = AzureOpenAIResponsesClient._create_client_from_project( + project_client=None, + project_endpoint="https://test-project.services.ai.azure.com", + credential=mock_credential, + ) + + assert result is mock_openai_client + MockAIProjectClient.assert_called_once() + mock_instance.get_openai_client.assert_called_once() + + +def test_create_client_from_project_missing_endpoint() -> None: + """Test _create_client_from_project raises error when endpoint is missing.""" + with pytest.raises(ValueError, match="project endpoint is required"): + AzureOpenAIResponsesClient._create_client_from_project( + project_client=None, + project_endpoint=None, + credential=MagicMock(), + ) + + +def test_create_client_from_project_missing_credential() -> None: + """Test _create_client_from_project raises error when credential is missing.""" + with pytest.raises(ValueError, match="credential is required"): + AzureOpenAIResponsesClient._create_client_from_project( + project_client=None, + project_endpoint="https://test-project.services.ai.azure.com", + credential=None, + ) diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py index f0d8cbc430..f3e459d0a4 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -3,6 +3,7 @@ import json import os import sys +import warnings from collections.abc import AsyncGenerator, AsyncIterator from contextlib import asynccontextmanager from typing import Annotated, Any @@ -41,8 +42,21 @@ from openai.types.responses.response import Response as OpenAIResponse from pydantic import BaseModel, ConfigDict, Field from pytest import fixture -from agent_framework_azure_ai import AzureAIClient, AzureAISettings -from agent_framework_azure_ai._shared import from_azure_ai_tools +from agent_framework_azure_ai import AzureAIClient, AzureAISettings # noqa: E402 +from agent_framework_azure_ai._shared import from_azure_ai_tools # noqa: E402 + +warnings.filterwarnings( + "ignore", + message=r"RawAzureAIClient is deprecated\..*", + category=DeprecationWarning, +) +warnings.filterwarnings( + "ignore", + message=r"AzureAIClient is deprecated\..*", + category=DeprecationWarning, +) + +pytestmark = pytest.mark.filterwarnings("ignore:AzureAIClient is deprecated\\..*:DeprecationWarning") @pytest.fixture diff --git a/python/packages/core/README.md b/python/packages/core/README.md index 9d88cc4556..36f0f6e02c 100644 --- a/python/packages/core/README.md +++ b/python/packages/core/README.md @@ -29,8 +29,8 @@ Set as environment variables, or create a .env file at your project root: ```bash OPENAI_API_KEY=sk-... -OPENAI_CHAT_MODEL_ID=... -OPENAI_RESPONSES_MODEL_ID=... +OPENAI_CHAT_MODEL=... +OPENAI_RESPONSES_MODEL=... ... AZURE_OPENAI_API_KEY=... AZURE_OPENAI_ENDPOINT=... diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index.js b/python/packages/devui/agent_framework_devui/ui/assets/index.js index 6387ada58a..a71e62397f 100644 --- a/python/packages/devui/agent_framework_devui/ui/assets/index.js +++ b/python/packages/devui/agent_framework_devui/ui/assets/index.js @@ -481,7 +481,7 @@ services: environment: # OpenAI - OPENAI_API_KEY=\${OPENAI_API_KEY} - - OPENAI_CHAT_MODEL_ID=\${OPENAI_CHAT_MODEL_ID:-gpt-4o-mini} + - OPENAI_CHAT_MODEL=\${OPENAI_CHAT_MODEL:-gpt-4o-mini} # Or Azure OpenAI - AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY} - AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT} @@ -514,7 +514,10 @@ az acr build --registry myregistry \\ --target-port 8080 \\ --ingress 'external' \\ --registry-server myregistry.azurecr.io \\ - --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL_ID=gpt-4o-mini`})]}),o.jsxs("div",{className:"border-l-2 border-primary pl-3",children:[o.jsxs("div",{className:"flex items-center gap-2 mb-1",children:[o.jsx("div",{className:"w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold",children:"5"}),o.jsx("h5",{className:"font-medium text-sm",children:"Get Application URL"})]}),o.jsx("pre",{className:"bg-muted p-2 rounded text-xs overflow-x-auto border mt-2",children:`az containerapp show --name ${r.toLowerCase()}-app \\ + --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL=gpt-4o-mini`})] + }), o.jsxs("div", { + className: "border-l-2 border-primary pl-3", children: [o.jsxs("div", { className: "flex items-center gap-2 mb-1", children: [o.jsx("div", { className: "w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold", children: "5" }), o.jsx("h5", { className: "font-medium text-sm", children: "Get Application URL" })] }), o.jsx("pre", { + className: "bg-muted p-2 rounded text-xs overflow-x-auto border mt-2", children: `az containerapp show --name ${r.toLowerCase()}-app \\ --resource-group myResourceGroup \\ --query properties.configuration.ingress.fqdn`})]})]})]}),o.jsxs("div",{className:"bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3",children:[o.jsx("h4",{className:"text-sm font-semibold mb-2",children:"Learn More"}),o.jsx("p",{className:"text-xs text-muted-foreground mb-3",children:"Explore Azure Container Apps documentation for advanced features like scaling, monitoring, and CI/CD integration."}),o.jsx(Le,{size:"sm",variant:"outline",className:"w-full",asChild:!0,children:o.jsxs("a",{href:"https://learn.microsoft.com/azure/container-apps/",target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Hu,{className:"h-3 w-3 mr-1"}),"View Azure Container Apps Documentation"]})})]})]})]})]})})})]})})}function tD({className:e,...n}){return o.jsx("div",{"data-slot":"card",className:We("bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm",e),...n})}function nD({className:e,...n}){return o.jsx("div",{"data-slot":"card-header",className:We("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",e),...n})}function N2({className:e,...n}){return o.jsx("div",{"data-slot":"card-title",className:We("leading-none font-semibold",e),...n})}function sD({className:e,...n}){return o.jsx("div",{"data-slot":"card-description",className:We("text-muted-foreground text-sm",e),...n})}function rD({className:e,...n}){return o.jsx("div",{"data-slot":"card-content",className:We("px-6",e),...n})}function oD({className:e,...n}){return o.jsx("div",{"data-slot":"card-footer",className:We("flex items-center px-6 [.border-t]:pt-6",e),...n})}const Cr=[{id:"foundry-weather-agent",name:"Azure AI Weather Agent",description:"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/foundry_agent/agent.py",tags:["azure-ai","foundry","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure AI Agent integration","Azure CLI authentication","Mock weather tools"],requiredEnvVars:[{name:"AZURE_AI_PROJECT_ENDPOINT",description:"Azure AI Foundry project endpoint URL",required:!0,example:"https://your-project.api.azureml.ms"},{name:"FOUNDRY_MODEL_DEPLOYMENT_NAME",description:"Name of the deployed model in Azure AI Foundry",required:!0,example:"gpt-4o"}]},{id:"weather-agent-azure",name:"Azure OpenAI Weather Agent",description:"Weather agent using Azure OpenAI with API key authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/weather_agent_azure/agent.py",tags:["azure","openai","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure OpenAI integration","API key authentication","Function calling","Mock weather tools"],requiredEnvVars:[{name:"AZURE_OPENAI_API_KEY",description:"Azure OpenAI API key",required:!0},{name:"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME",description:"Name of the deployed model in Azure OpenAI",required:!0,example:"gpt-4o"},{name:"AZURE_OPENAI_ENDPOINT",description:"Azure OpenAI endpoint URL",required:!0,example:"https://your-resource.openai.azure.com"}]},{id:"spam-workflow",name:"Spam Detection Workflow",description:"5-step workflow demonstrating email spam detection with branching logic",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/spam_workflow/workflow.py",tags:["workflow","branching","multi-step"],author:"Microsoft",difficulty:"beginner",features:["Sequential execution","Conditional branching","Mock spam detection"]},{id:"fanout-workflow",name:"Complex Fan-In/Fan-Out Workflow",description:"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/fanout_workflow/workflow.py",tags:["workflow","fan-out","fan-in","parallel"],author:"Microsoft",difficulty:"advanced",features:["Fan-out pattern","Parallel execution","Complex state management","Multi-stage processing"]}];Cr.filter(e=>e.type==="agent"),Cr.filter(e=>e.type==="workflow"),Cr.filter(e=>e.difficulty==="beginner"),Cr.filter(e=>e.difficulty==="intermediate"),Cr.filter(e=>e.difficulty==="advanced");const aD=e=>{switch(e){case"beginner":return"bg-green-100 text-green-700 border-green-200";case"intermediate":return"bg-yellow-100 text-yellow-700 border-yellow-200";case"advanced":return"bg-red-100 text-red-700 border-red-200";default:return"bg-gray-100 text-gray-700 border-gray-200"}},j2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,role:"alert",className:We("relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",e),...n}));j2.displayName="Alert";const S2=w.forwardRef(({className:e,...n},r)=>o.jsx("h5",{ref:r,className:We("mb-1 font-medium leading-none tracking-tight",e),...n}));S2.displayName="AlertTitle";const _2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,className:We("text-sm [&_p]:leading-relaxed",e),...n}));_2.displayName="AlertDescription";function E2({children:e,copyable:n=!1}){const[r,a]=w.useState(!1),l=()=>{navigator.clipboard.writeText(e),a(!0),setTimeout(()=>a(!1),2e3)};return o.jsxs("div",{className:"relative",children:[o.jsx("pre",{className:"bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono",children:o.jsx("code",{children:e})}),n&&o.jsx(Le,{variant:"ghost",size:"sm",className:"absolute top-2 right-2 h-6 w-6 p-0",onClick:l,children:r?o.jsx(jo,{className:"h-3 w-3"}):o.jsx(uo,{className:"h-3 w-3"})})]})}function iu({number:e,title:n,description:r,code:a,action:l,copyable:c=!1}){return o.jsxs("div",{className:"flex gap-4",children:[o.jsx("div",{className:"flex-shrink-0",children:o.jsx("div",{className:"flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold",children:e})}),o.jsxs("div",{className:"flex-1 space-y-2",children:[o.jsx("h4",{className:"font-semibold",children:n}),r&&o.jsx("p",{className:"text-sm text-muted-foreground",children:r}),a&&o.jsx(E2,{copyable:c,children:a}),l&&o.jsx("div",{children:l})]})]})}function iD({sample:e,open:n,onOpenChange:r}){const a=e.requiredEnvVars&&e.requiredEnvVars.length>0,l=a?0:-1;return o.jsx(Ir,{open:n,onOpenChange:r,children:o.jsxs(Lr,{className:"max-w-3xl",children:[o.jsxs($r,{className:"px-6 pt-6 pb-2",children:[o.jsxs(Pr,{children:["Setup: ",e.name]}),o.jsxs(OR,{children:["Follow these steps to run this sample ",e.type," locally"]})]}),o.jsx("div",{className:"px-6 pb-6",children:o.jsx(Wn,{className:"h-[500px]",children:o.jsxs("div",{className:"space-y-6 pr-4",children:[o.jsx(iu,{number:1,title:"Download the sample file",action:o.jsx(Le,{asChild:!0,size:"sm",children:o.jsxs("a",{href:e.url,download:`${e.id}.py`,target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Pu,{className:"h-4 w-4 mr-2"}),"Download ",e.id,".py"]})})}),o.jsx(iu,{number:2,title:"Create a project folder",description:"Create a dedicated folder for this sample and move the downloaded file there:",code:`mkdir -p ~/my-agents/${e.id} mv ~/Downloads/${e.id}.py ~/my-agents/${e.id}/`,copyable:!0}),a&&o.jsx(iu,{number:3,title:"Set up environment variables",description:"Create a .env file in the project folder with these required variables:",code:e.requiredEnvVars.map(c=>`${c.name}=${c.example||"your-value-here"} diff --git a/python/packages/devui/dev.md b/python/packages/devui/dev.md index 5a4166112d..0566e75429 100644 --- a/python/packages/devui/dev.md +++ b/python/packages/devui/dev.md @@ -33,7 +33,7 @@ Then edit `.env` and add your API keys: ```bash # For OpenAI (minimum required) OPENAI_API_KEY="your-api-key-here" -OPENAI_CHAT_MODEL_ID="gpt-4o-mini" +OPENAI_CHAT_MODEL="gpt-4o-mini" # Or for Azure OpenAI AZURE_OPENAI_ENDPOINT="your-endpoint" diff --git a/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx b/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx index 5a90a5350a..dd5ae81180 100644 --- a/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx +++ b/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx @@ -243,7 +243,7 @@ services: environment: # OpenAI - OPENAI_API_KEY=\${OPENAI_API_KEY} - - OPENAI_CHAT_MODEL_ID=\${OPENAI_CHAT_MODEL_ID:-gpt-4o-mini} + - OPENAI_CHAT_MODEL=\${OPENAI_CHAT_MODEL:-gpt-4o-mini} # Or Azure OpenAI - AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY} - AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT} @@ -802,7 +802,7 @@ az acr build --registry myregistry \\ --target-port 8080 \\ --ingress 'external' \\ --registry-server myregistry.azurecr.io \\ - --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL_ID=gpt-4o-mini`} + --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL=gpt-4o-mini`} diff --git a/python/packages/foundry/agent_framework_foundry/__init__.py b/python/packages/foundry/agent_framework_foundry/__init__.py index 3f7a5d5095..50c500ad4e 100644 --- a/python/packages/foundry/agent_framework_foundry/__init__.py +++ b/python/packages/foundry/agent_framework_foundry/__init__.py @@ -2,10 +2,9 @@ import importlib.metadata -from ._foundry_agent import FoundryAgent, RawFoundryAgent -from ._foundry_agent_client import RawFoundryAgentChatClient -from ._foundry_chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient -from ._foundry_memory_provider import FoundryMemoryProvider +from ._agent import FoundryAgent, RawFoundryAgent, RawFoundryAgentChatClient +from ._chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient +from ._memory_provider import FoundryMemoryProvider try: __version__ = importlib.metadata.version(__name__) diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_agent_client.py b/python/packages/foundry/agent_framework_foundry/_agent.py similarity index 57% rename from python/packages/foundry/agent_framework_foundry/_foundry_agent_client.py rename to python/packages/foundry/agent_framework_foundry/_agent.py index 0976d5572a..67c6f6070d 100644 --- a/python/packages/foundry/agent_framework_foundry/_foundry_agent_client.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -1,30 +1,37 @@ # Copyright (c) Microsoft. All rights reserved. -"""Microsoft Foundry Agent client for connecting to pre-configured agents in Foundry. +"""Microsoft Foundry Agent for connecting to pre-configured agents in Foundry. -This module provides ``RawFoundryAgentClient`` and ``FoundryAgentClient`` for -communicating with PromptAgents and HostedAgents via the Responses API. +This module provides ``RawFoundryAgent`` and ``FoundryAgent`` — Agent subclasses +that connect to existing PromptAgents or HostedAgents in Foundry. Use +``FoundryAgent`` for the recommended experience with full middleware and telemetry. """ from __future__ import annotations import logging import sys -from collections.abc import Callable, Mapping, MutableMapping, Sequence +from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast -from agent_framework._middleware import ChatMiddlewareLayer -from agent_framework._settings import load_settings -from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT -from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool -from agent_framework._types import Message -from agent_framework.observability import ChatTelemetryLayer +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + AgentMiddlewareLayer, + BaseContextProvider, + ChatAndFunctionMiddlewareTypes, + ChatMiddlewareLayer, + FunctionInvocationConfiguration, + FunctionInvocationLayer, + FunctionTool, + Message, + RawAgent, + load_settings, +) +from agent_framework.observability import AgentTelemetryLayer, ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient - -from ._entra_id_authentication import AzureCredentialTypes - -logger: logging.Logger = logging.getLogger(__name__) +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -33,22 +40,25 @@ else: if sys.version_info >= (3, 12): from typing import override # type: ignore # pragma: no cover else: - from typing_extensions import override # type: ignore # pragma: no cover + from typing_extensions import override # type: ignore[import] # pragma: no cover if sys.version_info >= (3, 11): from typing import TypedDict # type: ignore # pragma: no cover else: from typing_extensions import TypedDict # type: ignore # pragma: no cover if TYPE_CHECKING: - from agent_framework import Agent, BaseContextProvider - from agent_framework._middleware import ( - ChatMiddleware, - ChatMiddlewareCallable, - FunctionMiddleware, - FunctionMiddlewareCallable, + from agent_framework import ( + Agent, + BaseContextProvider, + ChatAndFunctionMiddlewareTypes, MiddlewareTypes, + ToolTypes, ) - from agent_framework._tools import ToolTypes + +logger: logging.Logger = logging.getLogger("agent_framework.foundry") + +AzureTokenProvider = Callable[[], str | Awaitable[str]] +AzureCredentialTypes = TokenCredential | AsyncTokenCredential class FoundryAgentSettings(TypedDict, total=False): @@ -203,8 +213,6 @@ class RawFoundryAgentChatClient( # type: ignore[misc] **kwargs: Any, ) -> Agent[FoundryAgentOptionsT]: """Create a FoundryAgent that reuses this client's Foundry configuration.""" - from ._foundry_agent import FoundryAgent - function_tools = cast( FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None, tools, @@ -359,9 +367,7 @@ class _FoundryAgentChatClient( # type: ignore[misc] allow_preview: bool | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - middleware: ( - Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None - ) = None, + middleware: (Sequence[ChatAndFunctionMiddlewareTypes] | None) = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, **kwargs: Any, ) -> None: @@ -393,3 +399,236 @@ class _FoundryAgentChatClient( # type: ignore[misc] function_invocation_configuration=function_invocation_configuration, **kwargs, ) + + +class RawFoundryAgent( # type: ignore[misc] + RawAgent[FoundryAgentOptionsT], +): + """Raw Microsoft Foundry Agent without agent-level middleware or telemetry. + + Connects to an existing PromptAgent or HostedAgent in Foundry. + For full middleware and telemetry support, use :class:`FoundryAgent`. + + Examples: + .. code-block:: python + + from agent_framework.foundry import RawFoundryAgent + from azure.identity import AzureCliCredential + + agent = RawFoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-prompt-agent", + agent_version="1.0", + credential=AzureCliCredential(), + ) + result = await agent.run("Hello!") + """ + + def __init__( + self, + *, + project_endpoint: str | None = None, + agent_name: str | None = None, + agent_version: str | None = None, + credential: AzureCredentialTypes | None = None, + project_client: AIProjectClient | None = None, + allow_preview: bool | None = None, + tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, + client_type: type[RawFoundryAgentChatClient] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a Foundry Agent. + + Keyword Args: + project_endpoint: The Foundry project endpoint URL. + Can also be set via environment variable FOUNDRY_PROJECT_ENDPOINT. + agent_name: The name of the Foundry agent to connect to. + Can also be set via environment variable FOUNDRY_AGENT_NAME. + agent_version: The version of the agent (required for PromptAgents, optional for HostedAgents). + Can also be set via environment variable FOUNDRY_AGENT_VERSION. + credential: Azure credential for authentication. + project_client: An existing AIProjectClient to use. + allow_preview: Enables preview opt-in on internally-created AIProjectClient. + tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. + context_providers: Optional context providers for injecting dynamic context. + client_type: Custom client class to use (must be a subclass of ``RawFoundryAgentChatClient``). + Defaults to ``_FoundryAgentChatClient`` (full client middleware). + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + kwargs: Additional keyword arguments passed to the Agent base class. + """ + # Create the client + actual_client_type = client_type or _FoundryAgentChatClient + if not issubclass(actual_client_type, RawFoundryAgentChatClient): + raise TypeError( + f"client_type must be a subclass of RawFoundryAgentChatClient, got {actual_client_type.__name__}" + ) + + client = actual_client_type( + project_endpoint=project_endpoint, + agent_name=agent_name, + agent_version=agent_version, + credential=credential, + project_client=project_client, + allow_preview=allow_preview, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + super().__init__( + client=client, # type: ignore[arg-type] + tools=tools, # type: ignore[arg-type] + context_providers=context_providers, + **kwargs, + ) + + async def configure_azure_monitor( + self, + enable_sensitive_data: bool = False, + **kwargs: Any, + ) -> None: + """Setup observability with Azure Monitor (Microsoft Foundry integration). + + This method configures Azure Monitor for telemetry collection using the + connection string from the Foundry project client (accessed via the internal client). + + Args: + enable_sensitive_data: Enable sensitive data logging (prompts, responses). + Should only be enabled in development/test environments. Default is False. + **kwargs: Additional arguments passed to configure_azure_monitor(). + + Raises: + ImportError: If azure-monitor-opentelemetry-exporter is not installed. + """ + from azure.core.exceptions import ResourceNotFoundError + + client = self.client + if not isinstance(client, RawFoundryAgentChatClient): + raise TypeError("configure_azure_monitor requires a RawFoundryAgentChatClient-based client.") + + try: + conn_string = await client.project_client.telemetry.get_application_insights_connection_string() + except ResourceNotFoundError: + logger.warning( + "No Application Insights connection string found for the Foundry project. " + "Please ensure Application Insights is configured in your project, " + "or call configure_otel_providers() manually with custom exporters." + ) + return + + try: + from azure.monitor.opentelemetry import configure_azure_monitor # type: ignore[import] + except ImportError as exc: + raise ImportError( + "azure-monitor-opentelemetry is required for Azure Monitor integration. " + "Install it with: pip install azure-monitor-opentelemetry" + ) from exc + + from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation + + if "resource" not in kwargs: + kwargs["resource"] = create_resource() + + configure_azure_monitor( + connection_string=conn_string, + views=create_metric_views(), + **kwargs, + ) + + enable_instrumentation(enable_sensitive_data=enable_sensitive_data) + + +class FoundryAgent( # type: ignore[misc] + AgentMiddlewareLayer, + AgentTelemetryLayer, + RawFoundryAgent[FoundryAgentOptionsT], +): + """Microsoft Foundry Agent with full middleware and telemetry support. + + Connects to an existing PromptAgent or HostedAgent in Foundry. + This is the recommended class for production use. + + Examples: + .. code-block:: python + + from agent_framework.foundry import FoundryAgent + from azure.identity import AzureCliCredential + + # Connect to a PromptAgent + agent = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-prompt-agent", + agent_version="1.0", + credential=AzureCliCredential(), + tools=[my_function_tool], + ) + result = await agent.run("Hello!") + + # Connect to a HostedAgent (no version needed) + agent = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-hosted-agent", + credential=AzureCliCredential(), + ) + + # Custom client (e.g., raw client without client middleware) + agent = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-agent", + credential=AzureCliCredential(), + client_type=RawFoundryAgentChatClient, + ) + """ + + def __init__( + self, + *, + project_endpoint: str | None = None, + agent_name: str | None = None, + agent_version: str | None = None, + credential: AzureCredentialTypes | None = None, + project_client: AIProjectClient | None = None, + allow_preview: bool | None = None, + tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + client_type: type[RawFoundryAgentChatClient] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a Foundry Agent with full middleware and telemetry. + + Keyword Args: + project_endpoint: The Foundry project endpoint URL. + agent_name: The name of the Foundry agent to connect to. + agent_version: The version of the agent (for PromptAgents). + credential: Azure credential for authentication. + project_client: An existing AIProjectClient to use. + allow_preview: Enables preview opt-in on internally-created AIProjectClient. + tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. + context_providers: Optional context providers. + middleware: Optional agent-level middleware. + client_type: Custom client class (must subclass ``RawFoundryAgentChatClient``). + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + kwargs: Additional keyword arguments. + """ + super().__init__( + project_endpoint=project_endpoint, + agent_name=agent_name, + agent_version=agent_version, + credential=credential, + project_client=project_client, + allow_preview=allow_preview, + tools=tools, + context_providers=context_providers, + middleware=middleware, + client_type=client_type, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + **kwargs, + ) diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py similarity index 92% rename from python/packages/foundry/agent_framework_foundry/_foundry_chat_client.py rename to python/packages/foundry/agent_framework_foundry/_chat_client.py index 8397f29639..51d1b96bb3 100644 --- a/python/packages/foundry/agent_framework_foundry/_foundry_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -4,14 +4,17 @@ from __future__ import annotations import logging import sys -from collections.abc import Sequence +from collections.abc import Awaitable, Callable, Sequence from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal -from agent_framework._middleware import ChatMiddlewareLayer -from agent_framework._settings import load_settings -from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT -from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer -from agent_framework._types import Content +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + ChatMiddlewareLayer, + Content, + FunctionInvocationConfiguration, + FunctionInvocationLayer, + load_settings, +) from agent_framework.observability import ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient @@ -25,9 +28,8 @@ from azure.ai.projects.models import ( ) from azure.ai.projects.models import FileSearchTool as ProjectsFileSearchTool from azure.ai.projects.models import MCPTool as FoundryMCPTool - -from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider -from ._shared import resolve_file_ids +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -43,15 +45,13 @@ else: from typing_extensions import TypedDict # type: ignore # pragma: no cover if TYPE_CHECKING: - from agent_framework._middleware import ( - ChatMiddleware, - ChatMiddlewareCallable, - FunctionMiddleware, - FunctionMiddlewareCallable, - ) + from agent_framework import ChatAndFunctionMiddlewareTypes logger: logging.Logger = logging.getLogger("agent_framework.foundry") +AzureTokenProvider = Callable[[], str | Awaitable[str]] +AzureCredentialTypes = TokenCredential | AsyncTokenCredential + class FoundrySettings(TypedDict, total=False): """Settings for Microsoft FoundryChatClient resolved from args and environment. @@ -67,6 +67,33 @@ class FoundrySettings(TypedDict, total=False): project_endpoint: str | None +def resolve_file_ids(file_ids: Sequence[str | Content] | None) -> list[str] | None: + """Resolve file IDs from strings or hosted-file Content objects.""" + if not file_ids: + return None + + resolved: list[str] = [] + for item in file_ids: + if isinstance(item, str): + if not item: + raise ValueError("file_ids must not contain empty strings.") + resolved.append(item) + elif isinstance(item, Content): + if item.type != "hosted_file": + raise ValueError( + f"Unsupported Content type {item.type!r} for code interpreter file_ids. " + "Only Content.from_hosted_file() is supported." + ) + if item.file_id is None: + raise ValueError( + "Content.from_hosted_file() item is missing a file_id. " + "Ensure the Content object has a valid file_id before using it in file_ids." + ) + resolved.append(item.file_id) + + return resolved if resolved else None + + FoundryChatOptionsT = TypeVar( "FoundryChatOptionsT", bound=TypedDict, # type: ignore[valid-type] @@ -492,9 +519,7 @@ class FoundryChatClient( # type: ignore[misc] env_file_path: str | None = None, env_file_encoding: str | None = None, instruction_role: str | None = None, - middleware: ( - Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None - ) = None, + middleware: (Sequence[ChatAndFunctionMiddlewareTypes] | None) = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, **kwargs: Any, ) -> None: diff --git a/python/packages/foundry/agent_framework_foundry/_entra_id_authentication.py b/python/packages/foundry/agent_framework_foundry/_entra_id_authentication.py deleted file mode 100644 index b1ae8a4739..0000000000 --- a/python/packages/foundry/agent_framework_foundry/_entra_id_authentication.py +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import logging -from collections.abc import Awaitable, Callable -from typing import Union - -from agent_framework.exceptions import ChatClientInvalidAuthException -from azure.core.credentials import TokenCredential -from azure.core.credentials_async import AsyncTokenCredential - -logger: logging.Logger = logging.getLogger(__name__) - -AzureTokenProvider = Callable[[], Union[str, Awaitable[str]]] -"""A callable that returns a bearer token string, either synchronously or asynchronously.""" - -AzureCredentialTypes = Union[TokenCredential, AsyncTokenCredential] -"""Union of Azure credential types. - -Accepts: -- ``TokenCredential`` — synchronous Azure credential (e.g. ``DefaultAzureCredential()``) -- ``AsyncTokenCredential`` — asynchronous Azure credential (e.g. ``azure.identity.aio.DefaultAzureCredential()``) -""" - - -def resolve_credential_to_token_provider( - credential: AzureCredentialTypes | AzureTokenProvider, - token_endpoint: str | None, -) -> AzureTokenProvider: - """Convert an Azure credential or token provider into an ``ad_token_provider`` callable. - - If the credential is already a callable token provider, it is returned as-is - (``token_endpoint`` is not required in this case). - If it is a ``TokenCredential`` or ``AsyncTokenCredential``, it is wrapped using - ``azure.identity.get_bearer_token_provider`` (sync or async variant) which - handles token caching and automatic refresh. - - Args: - credential: An Azure credential or token provider callable. - token_endpoint: The token scope/endpoint - (e.g. ``"https://cognitiveservices.azure.com/.default"``). - Required when ``credential`` is a ``TokenCredential`` or ``AsyncTokenCredential``. - - Returns: - A callable that returns a bearer token string (sync or async). - - Raises: - ServiceInvalidAuthError: If the token endpoint is empty when needed for credential wrapping. - """ - # Already a token provider callable (not a credential object) — use directly - if callable(credential) and not isinstance(credential, (TokenCredential, AsyncTokenCredential)): - return credential - - if not token_endpoint: - raise ChatClientInvalidAuthException( - "A token endpoint must be provided either in settings, as an environment variable, or as an argument." - ) - - if isinstance(credential, AsyncTokenCredential): - from azure.identity.aio import get_bearer_token_provider as get_async_bearer_token_provider - - return get_async_bearer_token_provider(credential, token_endpoint) - - from azure.identity import get_bearer_token_provider - - return get_bearer_token_provider(credential, token_endpoint) # type: ignore[arg-type] diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_agent.py b/python/packages/foundry/agent_framework_foundry/_foundry_agent.py deleted file mode 100644 index 19c298eaf8..0000000000 --- a/python/packages/foundry/agent_framework_foundry/_foundry_agent.py +++ /dev/null @@ -1,287 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Microsoft Foundry Agent for connecting to pre-configured agents in Foundry. - -This module provides ``RawFoundryAgent`` and ``FoundryAgent`` — Agent subclasses -that connect to existing PromptAgents or HostedAgents in Foundry. Use -``FoundryAgent`` for the recommended experience with full middleware and telemetry. -""" - -from __future__ import annotations - -import logging -import sys -from collections.abc import Callable, Sequence -from typing import TYPE_CHECKING, Any - -from agent_framework import ( - AgentMiddlewareLayer, - BaseContextProvider, - RawAgent, -) -from agent_framework.observability import AgentTelemetryLayer -from azure.ai.projects.aio import AIProjectClient - -from ._entra_id_authentication import AzureCredentialTypes -from ._foundry_agent_client import ( - RawFoundryAgentChatClient, - _FoundryAgentChatClient, # pyright: ignore[reportPrivateUsage] -) - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar # type: ignore # pragma: no cover -if sys.version_info >= (3, 11): - from typing import TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - -if TYPE_CHECKING: - from agent_framework._middleware import MiddlewareTypes - from agent_framework._tools import FunctionTool - from agent_framework_openai._chat_client import OpenAIChatOptions - -logger: logging.Logger = logging.getLogger("agent_framework.foundry") - -FoundryAgentOptionsT = TypeVar( - "FoundryAgentOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="OpenAIChatOptions", - covariant=True, -) - - -class RawFoundryAgent( # type: ignore[misc] - RawAgent[FoundryAgentOptionsT], -): - """Raw Microsoft Foundry Agent without agent-level middleware or telemetry. - - Connects to an existing PromptAgent or HostedAgent in Foundry. - For full middleware and telemetry support, use :class:`FoundryAgent`. - - Examples: - .. code-block:: python - - from agent_framework.foundry import RawFoundryAgent - from azure.identity import AzureCliCredential - - agent = RawFoundryAgent( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-prompt-agent", - agent_version="1.0", - credential=AzureCliCredential(), - ) - result = await agent.run("Hello!") - """ - - def __init__( - self, - *, - project_endpoint: str | None = None, - agent_name: str | None = None, - agent_version: str | None = None, - credential: AzureCredentialTypes | None = None, - project_client: AIProjectClient | None = None, - allow_preview: bool | None = None, - tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - client_type: type[RawFoundryAgentChatClient] | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - **kwargs: Any, - ) -> None: - """Initialize a Foundry Agent. - - Keyword Args: - project_endpoint: The Foundry project endpoint URL. - Can also be set via environment variable FOUNDRY_PROJECT_ENDPOINT. - agent_name: The name of the Foundry agent to connect to. - Can also be set via environment variable FOUNDRY_AGENT_NAME. - agent_version: The version of the agent (required for PromptAgents, optional for HostedAgents). - Can also be set via environment variable FOUNDRY_AGENT_VERSION. - credential: Azure credential for authentication. - project_client: An existing AIProjectClient to use. - allow_preview: Enables preview opt-in on internally-created AIProjectClient. - tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. - context_providers: Optional context providers for injecting dynamic context. - client_type: Custom client class to use (must be a subclass of ``RawFoundryAgentChatClient``). - Defaults to ``_FoundryAgentChatClient`` (full client middleware). - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - kwargs: Additional keyword arguments passed to the Agent base class. - """ - # Create the client - actual_client_type = client_type or _FoundryAgentChatClient - if not issubclass(actual_client_type, RawFoundryAgentChatClient): - raise TypeError( - f"client_type must be a subclass of RawFoundryAgentChatClient, got {actual_client_type.__name__}" - ) - - client = actual_client_type( - project_endpoint=project_endpoint, - agent_name=agent_name, - agent_version=agent_version, - credential=credential, - project_client=project_client, - allow_preview=allow_preview, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - super().__init__( - client=client, # type: ignore[arg-type] - tools=tools, # type: ignore[arg-type] - context_providers=context_providers, - **kwargs, - ) - - async def configure_azure_monitor( - self, - enable_sensitive_data: bool = False, - **kwargs: Any, - ) -> None: - """Setup observability with Azure Monitor (Microsoft Foundry integration). - - This method configures Azure Monitor for telemetry collection using the - connection string from the Foundry project client (accessed via the internal client). - - Args: - enable_sensitive_data: Enable sensitive data logging (prompts, responses). - Should only be enabled in development/test environments. Default is False. - **kwargs: Additional arguments passed to configure_azure_monitor(). - - Raises: - ImportError: If azure-monitor-opentelemetry-exporter is not installed. - """ - from azure.core.exceptions import ResourceNotFoundError - - from ._foundry_agent_client import RawFoundryAgentChatClient - - client = self.client - if not isinstance(client, RawFoundryAgentChatClient): - raise TypeError("configure_azure_monitor requires a RawFoundryAgentChatClient-based client.") - - try: - conn_string = await client.project_client.telemetry.get_application_insights_connection_string() - except ResourceNotFoundError: - logger.warning( - "No Application Insights connection string found for the Foundry project. " - "Please ensure Application Insights is configured in your project, " - "or call configure_otel_providers() manually with custom exporters." - ) - return - - try: - from azure.monitor.opentelemetry import configure_azure_monitor # type: ignore[import] - except ImportError as exc: - raise ImportError( - "azure-monitor-opentelemetry is required for Azure Monitor integration. " - "Install it with: pip install azure-monitor-opentelemetry" - ) from exc - - from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation - - if "resource" not in kwargs: - kwargs["resource"] = create_resource() - - configure_azure_monitor( - connection_string=conn_string, - views=create_metric_views(), - **kwargs, - ) - - enable_instrumentation(enable_sensitive_data=enable_sensitive_data) - - -class FoundryAgent( # type: ignore[misc] - AgentMiddlewareLayer, - AgentTelemetryLayer, - RawFoundryAgent[FoundryAgentOptionsT], -): - """Microsoft Foundry Agent with full middleware and telemetry support. - - Connects to an existing PromptAgent or HostedAgent in Foundry. - This is the recommended class for production use. - - Examples: - .. code-block:: python - - from agent_framework.foundry import FoundryAgent - from azure.identity import AzureCliCredential - - # Connect to a PromptAgent - agent = FoundryAgent( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-prompt-agent", - agent_version="1.0", - credential=AzureCliCredential(), - tools=[my_function_tool], - ) - result = await agent.run("Hello!") - - # Connect to a HostedAgent (no version needed) - agent = FoundryAgent( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-hosted-agent", - credential=AzureCliCredential(), - ) - - # Custom client (e.g., raw client without client middleware) - agent = FoundryAgent( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-agent", - credential=AzureCliCredential(), - client_type=RawFoundryAgentChatClient, - ) - """ - - def __init__( - self, - *, - project_endpoint: str | None = None, - agent_name: str | None = None, - agent_version: str | None = None, - credential: AzureCredentialTypes | None = None, - project_client: AIProjectClient | None = None, - allow_preview: bool | None = None, - tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - client_type: type[RawFoundryAgentChatClient] | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - **kwargs: Any, - ) -> None: - """Initialize a Foundry Agent with full middleware and telemetry. - - Keyword Args: - project_endpoint: The Foundry project endpoint URL. - agent_name: The name of the Foundry agent to connect to. - agent_version: The version of the agent (for PromptAgents). - credential: Azure credential for authentication. - project_client: An existing AIProjectClient to use. - allow_preview: Enables preview opt-in on internally-created AIProjectClient. - tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. - context_providers: Optional context providers. - middleware: Optional agent-level middleware. - client_type: Custom client class (must subclass ``RawFoundryAgentChatClient``). - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - kwargs: Additional keyword arguments. - """ - super().__init__( - project_endpoint=project_endpoint, - agent_name=agent_name, - agent_version=agent_version, - credential=credential, - project_client=project_client, - allow_preview=allow_preview, - tools=tools, - context_providers=context_providers, - middleware=middleware, - client_type=client_type, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - **kwargs, - ) diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_memory_provider.py b/python/packages/foundry/agent_framework_foundry/_memory_provider.py similarity index 94% rename from python/packages/foundry/agent_framework_foundry/_foundry_memory_provider.py rename to python/packages/foundry/agent_framework_foundry/_memory_provider.py index 3c24e3380e..36d4a27a43 100644 --- a/python/packages/foundry/agent_framework_foundry/_foundry_memory_provider.py +++ b/python/packages/foundry/agent_framework_foundry/_memory_provider.py @@ -13,25 +13,38 @@ import sys from contextlib import AbstractAsyncContextManager from typing import TYPE_CHECKING, Any, ClassVar -from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message -from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext -from agent_framework._settings import load_settings +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + AgentSession, + BaseContextProvider, + Message, + SessionContext, + load_settings, +) from azure.ai.projects.aio import AIProjectClient +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential from openai.types.responses import ResponseInputItemParam -from ._entra_id_authentication import AzureCredentialTypes -from ._shared import FoundryProjectSettings - if sys.version_info >= (3, 11): - from typing import Self # pragma: no cover + from typing import Self, TypedDict # pragma: no cover else: - from typing_extensions import Self # pragma: no cover + from typing_extensions import Self, TypedDict # pragma: no cover if TYPE_CHECKING: - from agent_framework._agents import SupportsAgentRun + from agent_framework import SupportsAgentRun + logger = logging.getLogger(__name__) +AzureCredentialTypes = TokenCredential | AsyncTokenCredential + + +class FoundryProjectSettings(TypedDict, total=False): + """Foundry project settings loaded from FOUNDRY_ environment variables.""" + + project_endpoint: str | None + class FoundryMemoryProvider(BaseContextProvider): """Foundry Memory context provider using the new BaseContextProvider hooks pattern. diff --git a/python/packages/foundry/agent_framework_foundry/_shared.py b/python/packages/foundry/agent_framework_foundry/_shared.py deleted file mode 100644 index 8eed50f067..0000000000 --- a/python/packages/foundry/agent_framework_foundry/_shared.py +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import logging -import sys -from collections.abc import Sequence - -from agent_framework import Content - -if sys.version_info >= (3, 11): - from typing import TypedDict # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - -logger = logging.getLogger("agent_framework.foundry") - - -class FoundryProjectSettings(TypedDict, total=False): - """Foundry project settings loaded from FOUNDRY_ environment variables.""" - - project_endpoint: str | None - - -def resolve_file_ids(file_ids: Sequence[str | Content] | None) -> list[str] | None: - """Resolve file IDs from strings or hosted-file Content objects.""" - if not file_ids: - return None - - resolved: list[str] = [] - for item in file_ids: - if isinstance(item, str): - if not item: - raise ValueError("file_ids must not contain empty strings.") - resolved.append(item) - elif isinstance(item, Content): - if item.type != "hosted_file": - raise ValueError( - f"Unsupported Content type {item.type!r} for code interpreter file_ids. " - "Only Content.from_hosted_file() is supported." - ) - if item.file_id is None: - raise ValueError( - "Content.from_hosted_file() item is missing a file_id. " - "Ensure the Content object has a valid file_id before using it in file_ids." - ) - resolved.append(item.file_id) - - return resolved if resolved else None diff --git a/python/packages/foundry/tests/assets/sample_image.jpg b/python/packages/foundry/tests/assets/sample_image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ea6486656fd5b603af043e29b941c99845baea7a GIT binary patch literal 182161 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%0Dk-Pm;7s}kHuGa7vE${d#SHv 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 UcKRcD&9zq&ZLV4!^WzTS~D|(tTOER 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 B_m}_x literal 0 HcmV?d00001 diff --git a/python/packages/foundry/tests/conftest.py b/python/packages/foundry/tests/foundry/conftest.py similarity index 100% rename from python/packages/foundry/tests/conftest.py rename to python/packages/foundry/tests/foundry/conftest.py diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py new file mode 100644 index 0000000000..2eb992d1a2 --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -0,0 +1,413 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import os +import sys +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import AgentResponse, ChatContext, ChatMiddleware, Message, tool +from azure.core.exceptions import ResourceNotFoundError +from azure.identity import AzureCliCredential + +from agent_framework_foundry._agent import ( + FoundryAgent, + RawFoundryAgent, + RawFoundryAgentChatClient, + _FoundryAgentChatClient, +) + +skip_if_foundry_agent_integration_tests_disabled = pytest.mark.skipif( + os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.services.ai.azure.com/") + or os.getenv("FOUNDRY_AGENT_NAME", "") == "", + reason="No real FOUNDRY_PROJECT_ENDPOINT or FOUNDRY_AGENT_NAME provided; skipping integration tests.", +) + +_FOUNDRY_AGENT_ENV_VARS = ( + "FOUNDRY_PROJECT_ENDPOINT", + "FOUNDRY_AGENT_NAME", + "FOUNDRY_AGENT_VERSION", +) + + +@pytest.fixture(autouse=True) +def clear_foundry_agent_settings_env(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: + """Prevent unit tests from inheriting Foundry agent settings from the shell.""" + + if request.node.get_closest_marker("integration") is not None: + return + + for env_var in _FOUNDRY_AGENT_ENV_VARS: + monkeypatch.delenv(env_var, raising=False) + + +def test_raw_foundry_agent_chat_client_init_requires_agent_name() -> None: + """Test that agent_name is required.""" + + with pytest.raises(ValueError, match="Agent name is required"): + RawFoundryAgentChatClient( + project_client=MagicMock(), + ) + + +def test_raw_foundry_agent_chat_client_init_with_agent_name() -> None: + """Test construction with agent_name and project_client.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + assert client.agent_name == "test-agent" + assert client.agent_version == "1.0" + + +def test_raw_foundry_agent_chat_client_get_agent_reference_with_version() -> None: + """Test agent reference includes version when provided.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="my-agent", + agent_version="2.0", + ) + + ref = client._get_agent_reference() + assert ref == {"name": "my-agent", "version": "2.0", "type": "agent_reference"} + + +def test_raw_foundry_agent_chat_client_get_agent_reference_without_version() -> None: + """Test agent reference omits version for HostedAgents.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="hosted-agent", + ) + + ref = client._get_agent_reference() + assert ref == {"name": "hosted-agent", "type": "agent_reference"} + assert "version" not in ref + + +def test_raw_foundry_agent_chat_client_as_agent_preserves_client_type() -> None: + """Test that as_agent() wraps the client in FoundryAgent using the same client class.""" + + class CustomClient(RawFoundryAgentChatClient): + pass + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = CustomClient( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + agent = client.as_agent(instructions="You are helpful.") + + assert isinstance(agent, FoundryAgent) + assert agent.name == "test-agent" + assert isinstance(agent.client, CustomClient) + assert agent.client.project_client is mock_project + assert agent.client.agent_name == "test-agent" + assert agent.client.agent_version == "1.0" + + named_agent = client.as_agent(name="display-name", instructions="You are helpful.") + assert named_agent.name == "display-name" + assert named_agent.client.agent_name == "test-agent" + + +async def test_raw_foundry_agent_chat_client_prepare_options_validates_tools() -> None: + """Test that _prepare_options rejects non-FunctionTool objects.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + with pytest.raises(TypeError, match="Only FunctionTool objects are accepted"): + await client._prepare_options( + messages=[Message(role="user", contents="hi")], + options={"tools": [{"type": "function", "function": {"name": "bad"}}]}, + ) + + +async def test_raw_foundry_agent_chat_client_prepare_options_accepts_function_tools() -> None: + """Test that _prepare_options accepts FunctionTool objects.""" + + mock_project = MagicMock() + mock_openai = MagicMock() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + @tool(approval_mode="never_require") + def my_func() -> str: + """A test function.""" + + return "ok" + + with patch( + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", + new_callable=AsyncMock, + return_value={}, + ): + result = await client._prepare_options( + messages=[Message(role="user", contents="hi")], + options={"tools": [my_func]}, + ) + + assert "extra_body" in result + assert result["extra_body"]["agent_reference"]["name"] == "test-agent" + + +def test_raw_foundry_agent_chat_client_check_model_presence_is_noop() -> None: + """Test that _check_model_presence does nothing (model is on service).""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + options: dict[str, Any] = {} + client._check_model_presence(options) + assert "model" not in options + + +def test_foundry_agent_chat_client_init() -> None: + """Test construction of the full-middleware client.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = _FoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + assert client.agent_name == "test-agent" + + +def test_raw_foundry_agent_init_creates_client() -> None: + """Test that RawFoundryAgent creates a client internally.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + assert agent.client is not None + assert agent.client.agent_name == "test-agent" + + +def test_raw_foundry_agent_init_with_custom_client_type() -> None: + """Test that client_type parameter is respected.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + client_type=RawFoundryAgentChatClient, + ) + + assert isinstance(agent.client, RawFoundryAgentChatClient) + + +def test_raw_foundry_agent_init_rejects_invalid_client_type() -> None: + """Test that invalid client_type raises TypeError.""" + + with pytest.raises(TypeError, match="must be a subclass of RawFoundryAgentChatClient"): + RawFoundryAgent( + project_client=MagicMock(), + agent_name="test-agent", + client_type=object, # type: ignore[arg-type] + ) + + +def test_raw_foundry_agent_init_with_function_tools() -> None: + """Test that FunctionTool and callables are accepted.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + @tool(approval_mode="never_require") + def my_func() -> str: + """A test function.""" + + return "ok" + + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + tools=[my_func], + ) + + assert agent.default_options.get("tools") is not None + + +def test_foundry_agent_init() -> None: + """Test construction of the full-middleware agent.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + agent = FoundryAgent( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + assert agent.client is not None + assert agent.client.agent_name == "test-agent" + + +def test_foundry_agent_init_with_middleware() -> None: + """Test that agent-level middleware is accepted.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + class MyMiddleware(ChatMiddleware): + async def process(self, context: ChatContext) -> None: + pass + + agent = FoundryAgent( + project_client=mock_project, + agent_name="test-agent", + middleware=[MyMiddleware()], + ) + + assert agent.client is not None + + +async def test_foundry_agent_configure_azure_monitor() -> None: + """Test configure_azure_monitor delegates through the underlying client.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + mock_project.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" + ) + agent = FoundryAgent(project_client=mock_project, agent_name="test-agent") + + mock_configure = MagicMock() + mock_views = MagicMock(return_value=[]) + mock_resource = MagicMock() + mock_enable = MagicMock() + + with ( + patch.dict( + "sys.modules", + {"azure.monitor.opentelemetry": MagicMock(configure_azure_monitor=mock_configure)}, + ), + patch("agent_framework.observability.create_metric_views", mock_views), + patch("agent_framework.observability.create_resource", return_value=mock_resource), + patch("agent_framework.observability.enable_instrumentation", mock_enable), + ): + await agent.configure_azure_monitor(enable_sensitive_data=True) + + mock_project.telemetry.get_application_insights_connection_string.assert_called_once() + call_kwargs = mock_configure.call_args.kwargs + assert call_kwargs["connection_string"] == "InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" + assert call_kwargs["views"] == [] + assert call_kwargs["resource"] is mock_resource + mock_enable.assert_called_once_with(enable_sensitive_data=True) + + +async def test_foundry_agent_configure_azure_monitor_resource_not_found() -> None: + """Test configure_azure_monitor handles ResourceNotFoundError gracefully.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + mock_project.telemetry.get_application_insights_connection_string = AsyncMock( + side_effect=ResourceNotFoundError("No Application Insights found") + ) + agent = FoundryAgent(project_client=mock_project, agent_name="test-agent") + + await agent.configure_azure_monitor() + + mock_project.telemetry.get_application_insights_connection_string.assert_called_once() + + +async def test_foundry_agent_configure_azure_monitor_import_error() -> None: + """Test configure_azure_monitor raises ImportError when Azure Monitor is unavailable.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + mock_project.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key" + ) + agent = FoundryAgent(project_client=mock_project, agent_name="test-agent") + original_import = __import__ + + def _import_with_missing_azure_monitor( + name: str, + globals: dict[str, Any] | None = None, + locals: dict[str, Any] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> Any: + if name == "azure.monitor.opentelemetry": + raise ImportError("No module named 'azure.monitor.opentelemetry'") + return original_import(name, globals, locals, fromlist, level) + + with ( + patch.dict(sys.modules, {"azure.monitor.opentelemetry": None}), + patch("builtins.__import__", side_effect=_import_with_missing_azure_monitor), + pytest.raises(ImportError, match="azure-monitor-opentelemetry is required"), + ): + await agent.configure_azure_monitor() + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_agent_integration_tests_disabled +async def test_foundry_agent_basic_run() -> None: + """Smoke-test FoundryAgent against a real configured agent.""" + async with FoundryAgent(credential=AzureCliCredential()) as agent: + response = await agent.run("Please respond with exactly: 'This is a response test.'") + + assert isinstance(response, AgentResponse) + assert response.text is not None + assert "response test" in response.text.lower() + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_agent_integration_tests_disabled +async def test_foundry_agent_custom_client_run() -> None: + """Smoke-test FoundryAgent against a real configured agent.""" + async with FoundryAgent(credential=AzureCliCredential(), client_type=RawFoundryAgentChatClient) as agent: + response = await agent.run("Please respond with exactly: 'This is a response test.'") + + assert isinstance(response, AgentResponse) + assert response.text is not None + assert "response test" in response.text.lower() diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py new file mode 100644 index 0000000000..7489be1896 --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -0,0 +1,751 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import json +import os +import sys +from functools import wraps +from pathlib import Path +from typing import Annotated, Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import ChatResponse, Content, Message, SupportsChatGetResponse, tool +from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT +from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException +from agent_framework_openai import OpenAIContentFilterException +from azure.core.exceptions import ResourceNotFoundError +from azure.identity import AzureCliCredential +from openai import BadRequestError +from pydantic import BaseModel +from pytest import param + +from agent_framework_foundry import FoundryChatClient, RawFoundryChatClient + + +class OutputStruct(BaseModel): + """A structured output for testing purposes.""" + + location: str + weather: str | None = None + + +@tool(approval_mode="never_require") +async def get_weather(location: Annotated[str, "The location as a city name"]) -> str: + """Get the current weather in a given location.""" + return f"The current weather in {location} is sunny." + + +skip_if_foundry_integration_tests_disabled = pytest.mark.skipif( + os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.services.ai.azure.com/") + or os.getenv("FOUNDRY_MODEL", "") == "", + reason="No real FOUNDRY_PROJECT_ENDPOINT or FOUNDRY_MODEL provided; skipping integration tests.", +) + +_TEST_FOUNDRY_PROJECT_ENDPOINT = "https://test-project.services.ai.azure.com/" +_TEST_FOUNDRY_MODEL = "test-gpt-4o" +_FOUNDRY_CHAT_ENV_VARS = ("FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL") + + +@pytest.fixture(autouse=True) +def clear_foundry_chat_settings_env(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: + """Prevent unit tests from inheriting Foundry chat settings from the shell.""" + + if request.node.get_closest_marker("integration") is not None: + return + + for env_var in _FOUNDRY_CHAT_ENV_VARS: + monkeypatch.delenv(env_var, raising=False) + + +def _with_foundry_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as exc: + debug_message = ( + "Foundry debug: " + f"project_endpoint={os.getenv('FOUNDRY_PROJECT_ENDPOINT', '')}, " + f"model={os.getenv('FOUNDRY_MODEL', '')}" + ) + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + +def _make_mock_openai_client() -> MagicMock: + client = MagicMock() + client.default_headers = {} + client.responses = MagicMock() + client.responses.create = AsyncMock() + client.responses.parse = AsyncMock() + client.files = MagicMock() + client.files.create = AsyncMock() + client.files.delete = AsyncMock() + client.vector_stores = MagicMock() + client.vector_stores.create = AsyncMock() + client.vector_stores.delete = AsyncMock() + client.vector_stores.files = MagicMock() + client.vector_stores.files.create_and_poll = AsyncMock() + return client + + +async def create_vector_store(client: FoundryChatClient) -> tuple[str, Content]: + """Create a vector store with sample documents for testing.""" + file = await client.client.files.create( + file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), + purpose="user_data", + ) + vector_store = await client.client.vector_stores.create( + name="knowledge_base", + expires_after={"anchor": "last_active_at", "days": 1}, + ) + result = await client.client.vector_stores.files.create_and_poll( + vector_store_id=vector_store.id, + file_id=file.id, + poll_interval_ms=1000, + ) + if result.last_error is not None: + raise RuntimeError(f"Vector store file processing failed with status: {result.last_error.message}") + + return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id) + + +async def delete_vector_store(client: FoundryChatClient, file_id: str, vector_store_id: str) -> None: + """Delete the vector store after tests.""" + await client.client.vector_stores.delete(vector_store_id=vector_store_id) + await client.client.files.delete(file_id=file_id) + + +def test_init() -> None: + mock_openai_client = _make_mock_openai_client() + mock_project_client = MagicMock() + mock_project_client.get_openai_client.return_value = mock_openai_client + + client = FoundryChatClient(project_client=mock_project_client, model=_TEST_FOUNDRY_MODEL) + + assert client.model == _TEST_FOUNDRY_MODEL + assert isinstance(client, SupportsChatGetResponse) + assert client.project_client is mock_project_client + + +def test_init_with_default_header() -> None: + default_headers = {"X-Unit-Test": "test-guid"} + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + + client = FoundryChatClient( + project_client=project_client, + model=_TEST_FOUNDRY_MODEL, + default_headers=default_headers, + ) + + assert client.model == _TEST_FOUNDRY_MODEL + for key, value in default_headers.items(): + assert client.default_headers is not None + assert key in client.default_headers + assert client.default_headers[key] == value + + +def test_init_with_project_endpoint_creates_project_client() -> None: + credential = MagicMock() + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + + with patch("agent_framework_foundry._chat_client.AIProjectClient", return_value=project_client) as factory: + client = FoundryChatClient( + project_endpoint=_TEST_FOUNDRY_PROJECT_ENDPOINT, + model=_TEST_FOUNDRY_MODEL, + credential=credential, + allow_preview=True, + ) + + assert client.project_client is project_client + assert client.model == _TEST_FOUNDRY_MODEL + assert factory.call_args.kwargs["endpoint"] == _TEST_FOUNDRY_PROJECT_ENDPOINT + assert factory.call_args.kwargs["credential"] is credential + assert factory.call_args.kwargs["allow_preview"] is True + assert factory.call_args.kwargs["user_agent"] == AGENT_FRAMEWORK_USER_AGENT + + +def test_init_with_empty_model_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FOUNDRY_MODEL", raising=False) + mock_openai_client = _make_mock_openai_client() + mock_project_client = MagicMock() + mock_project_client.get_openai_client.return_value = mock_openai_client + + with pytest.raises(ValueError, match="Model is required"): + FoundryChatClient(project_client=mock_project_client) + + +def test_init_with_empty_project_source_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FOUNDRY_PROJECT_ENDPOINT", raising=False) + + with pytest.raises(ValueError, match="Either 'project_endpoint' or 'project_client' is required"): + FoundryChatClient(model=_TEST_FOUNDRY_MODEL) + + +def test_init_with_project_endpoint_requires_credential() -> None: + with pytest.raises(ValueError, match="Azure credential is required"): + FoundryChatClient( + project_endpoint=_TEST_FOUNDRY_PROJECT_ENDPOINT, + model=_TEST_FOUNDRY_MODEL, + ) + + +async def test_configure_azure_monitor() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + project_client.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" + ) + client = FoundryChatClient(project_client=project_client, model=_TEST_FOUNDRY_MODEL) + + mock_configure = MagicMock() + mock_views = MagicMock(return_value=[]) + mock_resource = MagicMock() + mock_enable = MagicMock() + + with ( + patch.dict( + "sys.modules", + {"azure.monitor.opentelemetry": MagicMock(configure_azure_monitor=mock_configure)}, + ), + patch("agent_framework.observability.create_metric_views", mock_views), + patch("agent_framework.observability.create_resource", return_value=mock_resource), + patch("agent_framework.observability.enable_instrumentation", mock_enable), + ): + await client.configure_azure_monitor(enable_sensitive_data=True) + + project_client.telemetry.get_application_insights_connection_string.assert_called_once() + mock_configure.assert_called_once() + call_kwargs = mock_configure.call_args.kwargs + assert call_kwargs["connection_string"] == "InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" + assert call_kwargs["views"] == [] + assert call_kwargs["resource"] is mock_resource + mock_enable.assert_called_once_with(enable_sensitive_data=True) + + +async def test_configure_azure_monitor_resource_not_found() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + project_client.telemetry.get_application_insights_connection_string = AsyncMock( + side_effect=ResourceNotFoundError("No Application Insights found") + ) + client = FoundryChatClient(project_client=project_client, model=_TEST_FOUNDRY_MODEL) + + await client.configure_azure_monitor() + + project_client.telemetry.get_application_insights_connection_string.assert_called_once() + + +async def test_configure_azure_monitor_import_error() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + project_client.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key" + ) + client = FoundryChatClient(project_client=project_client, model=_TEST_FOUNDRY_MODEL) + original_import = __import__ + + def _import_with_missing_azure_monitor( + name: str, + globals: dict[str, Any] | None = None, + locals: dict[str, Any] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> Any: + if name == "azure.monitor.opentelemetry": + raise ImportError("No module named 'azure.monitor.opentelemetry'") + return original_import(name, globals, locals, fromlist, level) + + with ( + patch.dict(sys.modules, {"azure.monitor.opentelemetry": None}), + patch("builtins.__import__", side_effect=_import_with_missing_azure_monitor), + pytest.raises(ImportError, match="azure-monitor-opentelemetry is required"), + ): + await client.configure_azure_monitor() + + +async def test_configure_azure_monitor_with_custom_resource() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + project_client.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key" + ) + client = FoundryChatClient(project_client=project_client, model=_TEST_FOUNDRY_MODEL) + + custom_resource = MagicMock() + mock_configure = MagicMock() + + with ( + patch.dict( + "sys.modules", + {"azure.monitor.opentelemetry": MagicMock(configure_azure_monitor=mock_configure)}, + ), + patch("agent_framework.observability.create_metric_views", return_value=[]), + patch("agent_framework.observability.create_resource") as mock_create_resource, + patch("agent_framework.observability.enable_instrumentation"), + ): + await client.configure_azure_monitor(resource=custom_resource) + + mock_create_resource.assert_not_called() + call_kwargs = mock_configure.call_args.kwargs + assert call_kwargs["resource"] is custom_resource + + +async def test_get_response_with_invalid_input() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + with pytest.raises(ChatClientInvalidRequestException, match="Messages are required"): + await client.get_response(messages=[]) + + +async def test_web_search_tool_with_location() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + web_search_tool = FoundryChatClient.get_web_search_tool( + user_location={ + "city": "Seattle", + "country": "US", + "region": "WA", + "timezone": "America/Los_Angeles", + } + ) + + assert web_search_tool.user_location.city == "Seattle" + assert web_search_tool.user_location.country == "US" + _, run_options, _ = await client._prepare_request( + messages=[Message(role="user", text="What's the weather?")], + options={"tools": [web_search_tool], "tool_choice": "auto"}, + ) + + assert run_options["tools"] == [web_search_tool] + assert run_options["tool_choice"] == "auto" + + +async def test_code_interpreter_tool_variations() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + code_tool = FoundryChatClient.get_code_interpreter_tool() + assert code_tool.container["type"] == "auto" + + _, run_options, _ = await client._prepare_request( + messages=[Message("user", ["Run some code"])], + options={"tools": [code_tool]}, + ) + + assert run_options["tools"] == [code_tool] + + code_tool_with_files = FoundryChatClient.get_code_interpreter_tool(file_ids=["file1", "file2"]) + assert code_tool_with_files.container.file_ids == ["file1", "file2"] + + _, run_options, _ = await client._prepare_request( + messages=[Message(role="user", text="Process these files")], + options={"tools": [code_tool_with_files]}, + ) + + assert run_options["tools"] == [code_tool_with_files] + + +async def test_hosted_file_search_tool_validation() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + with pytest.raises(ValueError, match="vector_store_ids"): + FoundryChatClient.get_file_search_tool(vector_store_ids=[]) + + file_search_tool = FoundryChatClient.get_file_search_tool(vector_store_ids=["vs_123"]) + assert file_search_tool.vector_store_ids == ["vs_123"] + + _, run_options, _ = await client._prepare_request( + messages=[Message("user", ["Test"])], + options={"tools": [file_search_tool]}, + ) + + assert run_options["tools"] == [file_search_tool] + + +async def test_chat_message_parsing_with_function_calls() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + function_call = Content.from_function_call( + call_id="test-call-id", + name="test_function", + arguments='{"param": "value"}', + additional_properties={"fc_id": "test-fc-id"}, + ) + function_result = Content.from_function_result(call_id="test-call-id", result="Function executed successfully") + messages = [ + Message(role="user", text="Call a function"), + Message(role="assistant", contents=[function_call]), + Message(role="tool", contents=[function_result]), + ] + + prepared_messages = client._prepare_messages_for_openai(messages) + + assert prepared_messages == [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Call a function"}], + }, + { + "call_id": "test-call-id", + "id": "fc_test-fc-id", + "type": "function_call", + "name": "test_function", + "arguments": '{"param": "value"}', + }, + { + "call_id": "test-call-id", + "type": "function_call_output", + "output": "Function executed successfully", + }, + ] + + +async def test_content_filter_exception() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + mock_error = BadRequestError( + message="Content filter error", + response=MagicMock(), + body={"error": {"code": "content_filter", "message": "Content filter error"}}, + ) + mock_error.code = "content_filter" + client.client.responses.create.side_effect = mock_error + + with pytest.raises(OpenAIContentFilterException) as exc_info: + await client.get_response(messages=[Message(role="user", text="Test message")]) + + assert "content error" in str(exc_info.value) + + +async def test_response_format_parse_path() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + mock_parsed_response = MagicMock() + mock_parsed_response.id = "parsed_response_123" + mock_parsed_response.text = "Parsed response" + mock_parsed_response.model = "test-model" + mock_parsed_response.created_at = 1000000000 + mock_parsed_response.metadata = {} + mock_parsed_response.output_parsed = None + mock_parsed_response.usage = None + mock_parsed_response.finish_reason = None + mock_parsed_response.conversation = None + client.client.responses.parse = AsyncMock(return_value=mock_parsed_response) + + response = await client.get_response( + messages=[Message(role="user", text="Test message")], + options={"response_format": OutputStruct, "store": True}, + ) + assert response.response_id == "parsed_response_123" + assert response.conversation_id == "parsed_response_123" + assert response.model == "test-model" + + +async def test_response_format_parse_path_with_conversation_id() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + mock_parsed_response = MagicMock() + mock_parsed_response.id = "parsed_response_123" + mock_parsed_response.text = "Parsed response" + mock_parsed_response.model = "test-model" + mock_parsed_response.created_at = 1000000000 + mock_parsed_response.metadata = {} + mock_parsed_response.output_parsed = None + mock_parsed_response.usage = None + mock_parsed_response.finish_reason = None + mock_parsed_response.conversation = MagicMock() + mock_parsed_response.conversation.id = "conversation_456" + client.client.responses.parse = AsyncMock(return_value=mock_parsed_response) + + response = await client.get_response( + messages=[Message(role="user", text="Test message")], + options={"response_format": OutputStruct, "store": True}, + ) + assert response.response_id == "parsed_response_123" + assert response.conversation_id == "conversation_456" + assert response.model == "test-model" + + +async def test_bad_request_error_non_content_filter() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + mock_error = BadRequestError( + message="Invalid request", + response=MagicMock(), + body={"error": {"code": "invalid_request", "message": "Invalid request"}}, + ) + mock_error.code = "invalid_request" + client.client.responses.parse = AsyncMock(side_effect=mock_error) + + with pytest.raises(ChatClientException) as exc_info: + await client.get_response( + messages=[Message(role="user", text="Test message")], + options={"response_format": OutputStruct}, + ) + + assert "failed to complete the prompt" in str(exc_info.value) + + +def test_get_mcp_tool_with_project_connection_id() -> None: + tool_config = FoundryChatClient.get_mcp_tool( + name="Docs MCP", + project_connection_id="conn-123", + allowed_tools=["search_docs"], + ) + + assert tool_config["project_connection_id"] == "conn-123" + assert tool_config["allowed_tools"] == ["search_docs"] + assert tool_config["server_label"] == "Docs_MCP" + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_integration_tests_disabled +@pytest.mark.parametrize( + "option_name,option_value,needs_validation", + [ + param("max_tokens", 500, False, id="max_tokens"), + param("seed", 123, False, id="seed"), + param("user", "test-user-id", False, id="user"), + param("metadata", {"test_key": "test_value"}, False, id="metadata"), + param("tool_choice", "none", True, id="tool_choice_none"), + param("tools", [get_weather], True, id="tools_function"), + param("tool_choice", "auto", True, id="tool_choice_auto"), + param("response_format", OutputStruct, True, id="response_format_pydantic"), + param( + "response_format", + { + "type": "json_schema", + "json_schema": { + "name": "WeatherDigest", + "strict": True, + "schema": { + "title": "WeatherDigest", + "type": "object", + "properties": { + "location": {"type": "string"}, + "conditions": {"type": "string"}, + }, + "required": ["location", "conditions"], + "additionalProperties": False, + }, + }, + }, + True, + id="response_format_runtime_json_schema", + ), + ], +) +@_with_foundry_debug() +async def test_integration_options( + option_name: str, + option_value: Any, + needs_validation: bool, +) -> None: + client = FoundryChatClient(credential=AzureCliCredential()) + client.function_invocation_configuration["max_iterations"] = 2 + + if option_name.startswith("tools") or option_name.startswith("tool_choice"): + messages = [Message(role="user", text="What is the weather in Seattle?")] + elif option_name.startswith("response_format"): + messages = [Message(role="user", text="The weather in Seattle is sunny")] + messages.append(Message(role="user", text="What is the weather in Seattle?")) + else: + messages = [Message(role="user", text="Say 'Hello World' briefly.")] + + options: dict[str, Any] = {option_name: option_value} + if option_name.startswith("tool_choice"): + options["tools"] = [get_weather] + + response = await client.get_response(messages=messages, options=options, stream=True).get_final_response() + + assert isinstance(response, ChatResponse) + assert response.text is not None + assert len(response.text) > 0 + + if needs_validation: + if option_name.startswith("tools") or option_name.startswith("tool_choice"): + text = response.text.lower() + assert "sunny" in text or "seattle" in text + elif option_name.startswith("response_format"): + if option_value == OutputStruct: + assert response.value is not None + assert isinstance(response.value, OutputStruct) + assert "seattle" in response.value.location.lower() + else: + assert response.value is None + response_value = json.loads(response.text) + assert isinstance(response_value, dict) + assert "location" in response_value + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_integration_tests_disabled +@_with_foundry_debug() +async def test_integration_web_search() -> None: + client = FoundryChatClient(credential=AzureCliCredential()) + + web_search_tool = FoundryChatClient.get_web_search_tool() + content = { + "messages": [ + Message( + role="user", + text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", + ) + ], + "options": {"tool_choice": "auto", "tools": [web_search_tool]}, + } + response = await client.get_response(stream=True, **content).get_final_response() + + assert isinstance(response, ChatResponse) + assert "Rumi" in response.text + assert "Mira" in response.text + assert "Zoey" in response.text + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_integration_tests_disabled +@_with_foundry_debug() +async def test_integration_tool_rich_content_image() -> None: + image_path = Path(__file__).parent.parent / "assets" / "sample_image.jpg" + image_bytes = image_path.read_bytes() + + @tool(approval_mode="never_require") + def get_test_image() -> Content: + return Content.from_data(data=image_bytes, media_type="image/jpeg") + + client = FoundryChatClient(credential=AzureCliCredential()) + client.function_invocation_configuration["max_iterations"] = 2 + + messages = [Message(role="user", text="Call the get_test_image tool and describe what you see.")] + options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"} + + response = await client.get_response(messages=messages, options=options, stream=True).get_final_response() + + assert isinstance(response, ChatResponse) + assert response.text is not None + assert len(response.text) > 0 + assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}" + + +def test_get_code_interpreter_tool() -> None: + """Test code interpreter tool creation.""" + + tool_obj = RawFoundryChatClient.get_code_interpreter_tool() + assert tool_obj is not None + + +def test_get_code_interpreter_tool_with_file_ids() -> None: + """Test code interpreter tool with file IDs.""" + + tool_obj = RawFoundryChatClient.get_code_interpreter_tool(file_ids=["file-abc123"]) + assert tool_obj is not None + + +def test_get_file_search_tool() -> None: + """Test file search tool creation.""" + + tool_obj = RawFoundryChatClient.get_file_search_tool(vector_store_ids=["vs_abc123"]) + assert tool_obj is not None + + +def test_get_file_search_tool_requires_vector_store_ids() -> None: + """Test that empty vector_store_ids raises ValueError.""" + + with pytest.raises(ValueError, match="vector_store_ids"): + RawFoundryChatClient.get_file_search_tool(vector_store_ids=[]) + + +def test_get_web_search_tool() -> None: + """Test web search tool creation.""" + + tool_obj = RawFoundryChatClient.get_web_search_tool() + assert tool_obj is not None + + +def test_get_web_search_tool_with_location() -> None: + """Test web search tool with user location.""" + + tool_obj = RawFoundryChatClient.get_web_search_tool( + user_location={"city": "Seattle", "country": "US"}, + search_context_size="high", + ) + assert tool_obj is not None + + +def test_get_image_generation_tool() -> None: + """Test image generation tool creation.""" + + tool_obj = RawFoundryChatClient.get_image_generation_tool() + assert tool_obj is not None + + +def test_get_mcp_tool() -> None: + """Test MCP tool creation.""" + + tool_obj = RawFoundryChatClient.get_mcp_tool( + name="my_mcp", + url="https://mcp.example.com", + ) + assert tool_obj is not None + + +def test_get_mcp_tool_with_connection_id() -> None: + """Test MCP tool with project connection ID.""" + + tool_obj = RawFoundryChatClient.get_mcp_tool( + name="github_mcp", + project_connection_id="conn_abc123", + description="GitHub MCP via Foundry", + ) + assert tool_obj is not None diff --git a/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py b/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py new file mode 100644 index 0000000000..005eed29ce --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py @@ -0,0 +1,501 @@ +# Copyright (c) Microsoft. All rights reserved. +# pyright: reportPrivateUsage=false + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from agent_framework import AGENT_FRAMEWORK_USER_AGENT, AgentResponse, Message +from agent_framework._sessions import AgentSession, SessionContext + +from agent_framework_foundry._memory_provider import FoundryMemoryProvider + + +@pytest.fixture +def mock_project_client() -> AsyncMock: + """Create a mock AIProjectClient.""" + mock_client = AsyncMock() + mock_client.beta = AsyncMock() + mock_client.beta.memory_stores = AsyncMock() + mock_client.beta.memory_stores.search_memories = AsyncMock() + mock_client.beta.memory_stores.begin_update_memories = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock() + return mock_client + + +@pytest.fixture +def mock_credential() -> Mock: + """Create a mock Azure credential.""" + return Mock() + + +# -- Initialization tests ------------------------------------------------------ + + +def test_init_with_all_params(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + source_id="custom_source", + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + context_prompt="Custom prompt", + update_delay=60, + ) + assert provider.source_id == "custom_source" + assert provider.project_client is mock_project_client + assert provider.memory_store_name == "test_store" + assert provider.scope == "user_123" + assert provider.context_prompt == "Custom prompt" + assert provider.update_delay == 60 + + +def test_init_default_source_id(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + assert provider.source_id == FoundryMemoryProvider.DEFAULT_SOURCE_ID + + +def test_init_default_context_prompt(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + assert provider.context_prompt == FoundryMemoryProvider.DEFAULT_CONTEXT_PROMPT + + +def test_init_default_update_delay(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + assert provider.update_delay == 300 + + +def test_init_with_project_endpoint_and_credential(mock_project_client: AsyncMock, mock_credential: Mock) -> None: + with patch("agent_framework_foundry._memory_provider.AIProjectClient") as mock_ai_project_client: + mock_ai_project_client.return_value = mock_project_client + provider = FoundryMemoryProvider( + project_endpoint="https://test.project.endpoint", + credential=mock_credential, # type: ignore[arg-type] + allow_preview=True, + memory_store_name="test_store", + scope="user_123", + ) + assert provider.project_client is mock_project_client + mock_ai_project_client.assert_called_once_with( + endpoint="https://test.project.endpoint", + credential=mock_credential, + allow_preview=True, + user_agent=AGENT_FRAMEWORK_USER_AGENT, + ) + + +def test_init_requires_project_endpoint_without_project_client() -> None: + with ( + patch("agent_framework_foundry._memory_provider.load_settings") as mock_load_settings, + patch.dict(os.environ, {}, clear=True), + pytest.raises(ValueError, match="project endpoint is required"), + ): + mock_load_settings.return_value = {"project_endpoint": None} + FoundryMemoryProvider( + memory_store_name="test_store", + scope="user_123", + ) + + +def test_init_requires_credential_without_project_client() -> None: + with pytest.raises(ValueError, match="Azure credential is required"): + FoundryMemoryProvider( + project_endpoint="https://test.project.endpoint", + memory_store_name="test_store", + scope="user_123", + ) + + +def test_init_requires_memory_store_name(mock_project_client: AsyncMock) -> None: + with pytest.raises(ValueError, match="memory_store_name is required"): + FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="", + scope="user_123", + ) + + +def test_init_requires_scope(mock_project_client: AsyncMock) -> None: + with pytest.raises(ValueError, match="scope is required"): + FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="", + ) + + +# -- before_run tests ---------------------------------------------------------- + + +async def test_retrieves_static_memories_on_first_run(mock_project_client: AsyncMock) -> None: + mem1 = Mock() + mem1.memory_item.content = "User prefers Python" + mem2 = Mock() + mem2.memory_item.content = "User is based in Seattle" + mock_search_result = Mock() + mock_search_result.memories = [mem1, mem2] + mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + # Should call search_memories twice: once for static, once for contextual + assert mock_project_client.beta.memory_stores.search_memories.call_count == 2 + # Static memories should be cached + assert len(session.state[provider.source_id]["static_memories"]) == 2 + assert session.state[provider.source_id]["initialized"] is True + + +async def test_contextual_memories_added_to_context(mock_project_client: AsyncMock) -> None: + # Mock static search (first call) + static_mem = Mock() + static_mem.memory_item.content = "User prefers Python" + static_result = Mock() + static_result.memories = [static_mem] + + # Mock contextual search (second call) + contextual_mem = Mock() + contextual_mem.memory_item.content = "Last discussed async patterns" + contextual_result = Mock() + contextual_result.memories = [contextual_mem] + contextual_result.search_id = "search-123" + + mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result] + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + # Check that memories were added to context + assert provider.source_id in ctx.context_messages + added = ctx.context_messages[provider.source_id] + assert len(added) == 1 + assert "User prefers Python" in added[0].text # type: ignore[operator] + assert "Last discussed async patterns" in added[0].text # type: ignore[operator] + assert provider.context_prompt in added[0].text # type: ignore[operator] + assert session.state[provider.source_id]["previous_search_id"] == "search-123" + + +async def test_empty_input_skips_contextual_search(mock_project_client: AsyncMock) -> None: + static_result = Mock() + static_result.memories = [] + mock_project_client.beta.memory_stores.search_memories.return_value = static_result + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="")], session_id="s1") + + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + # Should only call search_memories once for static memories + assert mock_project_client.beta.memory_stores.search_memories.call_count == 1 + assert provider.source_id not in ctx.context_messages + + +async def test_empty_search_results_no_messages(mock_project_client: AsyncMock) -> None: + mock_search_result = Mock() + mock_search_result.memories = [] + mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="test")], session_id="s1") + + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + assert provider.source_id not in ctx.context_messages + + +async def test_static_memories_only_retrieved_once(mock_project_client: AsyncMock) -> None: + static_mem = Mock() + static_mem.memory_item.content = "Static memory" + static_result = Mock() + static_result.memories = [static_mem] + contextual_result = Mock() + contextual_result.memories = [] + + mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result] + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + + # First call + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + assert mock_project_client.beta.memory_stores.search_memories.call_count == 2 + + # Reset mock for second call + mock_project_client.beta.memory_stores.search_memories.reset_mock() + contextual_result2 = Mock() + contextual_result2.memories = [] + mock_project_client.beta.memory_stores.search_memories.return_value = contextual_result2 + + # Second call - should only search contextual, not static + ctx2 = SessionContext(input_messages=[Message(role="user", text="World")], session_id="s1") + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {}) + ) + assert mock_project_client.beta.memory_stores.search_memories.call_count == 1 + + +async def test_handles_search_exception_gracefully(mock_project_client: AsyncMock) -> None: + mock_project_client.beta.memory_stores.search_memories.side_effect = Exception("API error") + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + + # Should not raise exception + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + # No memories added + assert provider.source_id not in ctx.context_messages + + +# -- after_run tests ----------------------------------------------------------- + + +async def test_stores_input_and_response(mock_project_client: AsyncMock) -> None: + mock_poller = Mock() + mock_poller.update_id = "update-456" + mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="question")], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", text="answer")]) + + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + mock_project_client.beta.memory_stores.begin_update_memories.assert_awaited_once() + call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs + assert call_kwargs["name"] == "test_store" + assert call_kwargs["scope"] == "user_123" + assert len(call_kwargs["items"]) == 2 + assert call_kwargs["items"][0]["content"] == "question" + assert call_kwargs["items"][1]["content"] == "answer" + assert session.state[provider.source_id]["previous_update_id"] == "update-456" + + +async def test_only_stores_user_assistant_system(mock_project_client: AsyncMock) -> None: + mock_poller = Mock() + mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext( + input_messages=[ + Message(role="user", text="hello"), + Message(role="tool", text="tool output"), + ], + session_id="s1", + ) + ctx._response = AgentResponse(messages=[Message(role="assistant", text="reply")]) + + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs + items = call_kwargs["items"] + assert len(items) == 2 + assert items[0]["content"] == "hello" + assert items[1]["content"] == "reply" + + +async def test_skips_empty_messages(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext( + input_messages=[ + Message(role="user", text=""), + Message(role="user", text=" "), + ], + session_id="s1", + ) + ctx._response = AgentResponse(messages=[]) + + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + mock_project_client.beta.memory_stores.begin_update_memories.assert_not_awaited() + + +async def test_uses_configured_update_delay(mock_project_client: AsyncMock) -> None: + mock_poller = Mock() + mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + update_delay=60, + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) + + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs + assert call_kwargs["update_delay"] == 60 + + +async def test_uses_previous_update_id_for_incremental_updates(mock_project_client: AsyncMock) -> None: + mock_poller1 = Mock() + mock_poller1.update_id = "update-1" + mock_poller2 = Mock() + mock_poller2.update_id = "update-2" + + mock_project_client.beta.memory_stores.begin_update_memories.side_effect = [mock_poller1, mock_poller2] + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx1 = SessionContext(input_messages=[Message(role="user", text="first")], session_id="s1") + ctx1._response = AgentResponse(messages=[Message(role="assistant", text="response1")]) + + # First update + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx1, state=session.state.setdefault(provider.source_id, {}) + ) + assert session.state[provider.source_id]["previous_update_id"] == "update-1" + + # Second update should use previous_update_id + ctx2 = SessionContext(input_messages=[Message(role="user", text="second")], session_id="s1") + ctx2._response = AgentResponse(messages=[Message(role="assistant", text="response2")]) + + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {}) + ) + + call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs + assert call_kwargs["previous_update_id"] == "update-1" + assert session.state[provider.source_id]["previous_update_id"] == "update-2" + + +async def test_handles_update_exception_gracefully(mock_project_client: AsyncMock) -> None: + mock_project_client.beta.memory_stores.begin_update_memories.side_effect = Exception("API error") + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) + + # Should not raise exception + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + +# -- Context manager tests ----------------------------------------------------- + + +async def test_aenter_delegates_to_client(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + result = await provider.__aenter__() + assert result is provider + mock_project_client.__aenter__.assert_awaited_once() + + +async def test_aexit_delegates_to_client(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + await provider.__aexit__(None, None, None) + mock_project_client.__aexit__.assert_awaited_once() + + +async def test_async_with_syntax(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + async with provider as p: + assert p is provider diff --git a/python/packages/foundry/tests/test_foundry_agent.py b/python/packages/foundry/tests/test_foundry_agent.py deleted file mode 100644 index 549d922ff9..0000000000 --- a/python/packages/foundry/tests/test_foundry_agent.py +++ /dev/null @@ -1,374 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Tests for FoundryAgentClient and FoundryAgent classes.""" - -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from agent_framework._tools import tool - - -class TestRawFoundryAgentChatClient: - """Tests for RawFoundryAgentChatClient.""" - - def test_init_requires_agent_name(self) -> None: - """Test that agent_name is required.""" - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - with pytest.raises(ValueError, match="Agent name is required"): - RawFoundryAgentChatClient( - project_client=MagicMock(), - ) - - def test_init_with_agent_name(self) -> None: - """Test construction with agent_name and project_client.""" - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - agent_version="1.0", - ) - - assert client.agent_name == "test-agent" - assert client.agent_version == "1.0" - - def test_get_agent_reference_with_version(self) -> None: - """Test agent reference includes version when provided.""" - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="my-agent", - agent_version="2.0", - ) - - ref = client._get_agent_reference() - assert ref == {"name": "my-agent", "version": "2.0", "type": "agent_reference"} - - def test_get_agent_reference_without_version(self) -> None: - """Test agent reference omits version for HostedAgents.""" - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="hosted-agent", - ) - - ref = client._get_agent_reference() - assert ref == {"name": "hosted-agent", "type": "agent_reference"} - assert "version" not in ref - - def test_as_agent_returns_foundry_agent_and_preserves_client_type(self) -> None: - """Test that as_agent() wraps the client in FoundryAgent using the same client class.""" - from agent_framework_foundry._foundry_agent import FoundryAgent - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - class CustomClient(RawFoundryAgentChatClient): - pass - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = CustomClient( - project_client=mock_project, - agent_name="test-agent", - agent_version="1.0", - ) - - agent = client.as_agent(instructions="You are helpful.") - - assert isinstance(agent, FoundryAgent) - assert agent.name == "test-agent" - assert isinstance(agent.client, CustomClient) - assert agent.client.project_client is mock_project - assert agent.client.agent_name == "test-agent" - assert agent.client.agent_version == "1.0" - - named_agent = client.as_agent(name="display-name", instructions="You are helpful.") - assert named_agent.name == "display-name" - assert named_agent.client.agent_name == "test-agent" - - async def test_prepare_options_validates_tools(self) -> None: - """Test that _prepare_options rejects non-FunctionTool objects.""" - from agent_framework import Message - - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - ) - - # A dict tool should be rejected - with pytest.raises(TypeError, match="Only FunctionTool objects are accepted"): - await client._prepare_options( - messages=[Message(role="user", contents="hi")], - options={"tools": [{"type": "function", "function": {"name": "bad"}}]}, - ) - - async def test_prepare_options_accepts_function_tools(self) -> None: - """Test that _prepare_options accepts FunctionTool objects.""" - from agent_framework import Message - - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_openai = MagicMock() - mock_project.get_openai_client.return_value = mock_openai - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - ) - - @tool(approval_mode="never_require") - def my_func() -> str: - """A test function.""" - return "ok" - - # Should not raise — patch the parent's _prepare_options - with patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - new_callable=AsyncMock, - return_value={}, - ): - result = await client._prepare_options( - messages=[Message(role="user", contents="hi")], - options={"tools": [my_func]}, - ) - assert "extra_body" in result - assert result["extra_body"]["agent_reference"]["name"] == "test-agent" - - def test_check_model_presence_is_noop(self) -> None: - """Test that _check_model_presence does nothing (model is on service).""" - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - ) - - options: dict[str, Any] = {} - client._check_model_presence(options) - assert "model" not in options - - -class TestFoundryAgentChatClient: - """Tests for _FoundryAgentChatClient (full middleware).""" - - def test_init(self) -> None: - """Test construction of the full-middleware client.""" - from agent_framework_foundry._foundry_agent_client import _FoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = _FoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - agent_version="1.0", - ) - - assert client.agent_name == "test-agent" - - -class TestRawFoundryAgent: - """Tests for RawFoundryAgent.""" - - def test_init_creates_client(self) -> None: - """Test that RawFoundryAgent creates a client internally.""" - from agent_framework_foundry._foundry_agent import RawFoundryAgent - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - agent = RawFoundryAgent( - project_client=mock_project, - agent_name="test-agent", - agent_version="1.0", - ) - - assert agent.client is not None - assert agent.client.agent_name == "test-agent" - - def test_init_with_custom_client_type(self) -> None: - """Test that client_type parameter is respected.""" - from agent_framework_foundry._foundry_agent import RawFoundryAgent - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - agent = RawFoundryAgent( - project_client=mock_project, - agent_name="test-agent", - client_type=RawFoundryAgentChatClient, - ) - - assert isinstance(agent.client, RawFoundryAgentChatClient) - - def test_init_rejects_invalid_client_type(self) -> None: - """Test that invalid client_type raises TypeError.""" - from agent_framework_foundry._foundry_agent import RawFoundryAgent - - with pytest.raises(TypeError, match="must be a subclass of RawFoundryAgentChatClient"): - RawFoundryAgent( - project_client=MagicMock(), - agent_name="test-agent", - client_type=object, # type: ignore[arg-type] - ) - - def test_init_with_function_tools(self) -> None: - """Test that FunctionTool and callables are accepted.""" - from agent_framework_foundry._foundry_agent import RawFoundryAgent - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - @tool(approval_mode="never_require") - def my_func() -> str: - """A test function.""" - return "ok" - - agent = RawFoundryAgent( - project_client=mock_project, - agent_name="test-agent", - tools=[my_func], - ) - - assert agent.default_options.get("tools") is not None - - -class TestFoundryAgent: - """Tests for FoundryAgent (full middleware).""" - - def test_init(self) -> None: - """Test construction of the full-middleware agent.""" - from agent_framework_foundry._foundry_agent import FoundryAgent - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - agent = FoundryAgent( - project_client=mock_project, - agent_name="test-agent", - agent_version="1.0", - ) - - assert agent.client is not None - assert agent.client.agent_name == "test-agent" - - def test_init_with_middleware(self) -> None: - """Test that agent-level middleware is accepted.""" - from agent_framework import ChatContext, ChatMiddleware - - from agent_framework_foundry._foundry_agent import FoundryAgent - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - class MyMiddleware(ChatMiddleware): - async def process(self, context: ChatContext) -> None: - pass - - agent = FoundryAgent( - project_client=mock_project, - agent_name="test-agent", - middleware=[MyMiddleware()], - ) - - assert agent.client is not None - - -class TestFoundryChatClientToolMethods: - """Tests for RawFoundryChatClient tool factory methods.""" - - def test_get_code_interpreter_tool(self) -> None: - """Test code interpreter tool creation.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_code_interpreter_tool() - assert tool_obj is not None - - def test_get_code_interpreter_tool_with_file_ids(self) -> None: - """Test code interpreter tool with file IDs.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_code_interpreter_tool(file_ids=["file-abc123"]) - assert tool_obj is not None - - def test_get_file_search_tool(self) -> None: - """Test file search tool creation.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_file_search_tool(vector_store_ids=["vs_abc123"]) - assert tool_obj is not None - - def test_get_file_search_tool_requires_vector_store_ids(self) -> None: - """Test that empty vector_store_ids raises ValueError.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - with pytest.raises(ValueError, match="vector_store_ids"): - RawFoundryChatClient.get_file_search_tool(vector_store_ids=[]) - - def test_get_web_search_tool(self) -> None: - """Test web search tool creation.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_web_search_tool() - assert tool_obj is not None - - def test_get_web_search_tool_with_location(self) -> None: - """Test web search tool with user location.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_web_search_tool( - user_location={"city": "Seattle", "country": "US"}, - search_context_size="high", - ) - assert tool_obj is not None - - def test_get_image_generation_tool(self) -> None: - """Test image generation tool creation.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_image_generation_tool() - assert tool_obj is not None - - def test_get_mcp_tool(self) -> None: - """Test MCP tool creation.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_mcp_tool( - name="my_mcp", - url="https://mcp.example.com", - ) - assert tool_obj is not None - - def test_get_mcp_tool_with_connection_id(self) -> None: - """Test MCP tool with project connection ID.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_mcp_tool( - name="github_mcp", - project_connection_id="conn_abc123", - description="GitHub MCP via Foundry", - ) - assert tool_obj is not None diff --git a/python/packages/foundry/tests/test_foundry_memory_provider.py b/python/packages/foundry/tests/test_foundry_memory_provider.py deleted file mode 100644 index f7e02f8a89..0000000000 --- a/python/packages/foundry/tests/test_foundry_memory_provider.py +++ /dev/null @@ -1,507 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -# pyright: reportPrivateUsage=false - -from __future__ import annotations - -import os -from unittest.mock import AsyncMock, Mock, patch - -import pytest -from agent_framework import AGENT_FRAMEWORK_USER_AGENT, AgentResponse, Message -from agent_framework._sessions import AgentSession, SessionContext - -from agent_framework_foundry._foundry_memory_provider import FoundryMemoryProvider - - -@pytest.fixture -def mock_project_client() -> AsyncMock: - """Create a mock AIProjectClient.""" - mock_client = AsyncMock() - mock_client.beta = AsyncMock() - mock_client.beta.memory_stores = AsyncMock() - mock_client.beta.memory_stores.search_memories = AsyncMock() - mock_client.beta.memory_stores.begin_update_memories = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock() - return mock_client - - -@pytest.fixture -def mock_credential() -> Mock: - """Create a mock Azure credential.""" - return Mock() - - -# -- Initialization tests ------------------------------------------------------ - - -class TestInit: - """Test FoundryMemoryProvider initialization.""" - - def test_init_with_all_params(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - source_id="custom_source", - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - context_prompt="Custom prompt", - update_delay=60, - ) - assert provider.source_id == "custom_source" - assert provider.project_client is mock_project_client - assert provider.memory_store_name == "test_store" - assert provider.scope == "user_123" - assert provider.context_prompt == "Custom prompt" - assert provider.update_delay == 60 - - def test_init_default_source_id(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - assert provider.source_id == FoundryMemoryProvider.DEFAULT_SOURCE_ID - - def test_init_default_context_prompt(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - assert provider.context_prompt == FoundryMemoryProvider.DEFAULT_CONTEXT_PROMPT - - def test_init_default_update_delay(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - assert provider.update_delay == 300 - - def test_init_with_project_endpoint_and_credential( - self, mock_project_client: AsyncMock, mock_credential: Mock - ) -> None: - with patch("agent_framework_foundry._foundry_memory_provider.AIProjectClient") as mock_ai_project_client: - mock_ai_project_client.return_value = mock_project_client - provider = FoundryMemoryProvider( - project_endpoint="https://test.project.endpoint", - credential=mock_credential, # type: ignore[arg-type] - allow_preview=True, - memory_store_name="test_store", - scope="user_123", - ) - assert provider.project_client is mock_project_client - mock_ai_project_client.assert_called_once_with( - endpoint="https://test.project.endpoint", - credential=mock_credential, - allow_preview=True, - user_agent=AGENT_FRAMEWORK_USER_AGENT, - ) - - def test_init_requires_project_endpoint_without_project_client(self) -> None: - with ( - patch("agent_framework_foundry._foundry_memory_provider.load_settings") as mock_load_settings, - patch.dict(os.environ, {}, clear=True), - pytest.raises(ValueError, match="project endpoint is required"), - ): - mock_load_settings.return_value = {"project_endpoint": None} - FoundryMemoryProvider( - memory_store_name="test_store", - scope="user_123", - ) - - def test_init_requires_credential_without_project_client(self) -> None: - with pytest.raises(ValueError, match="Azure credential is required"): - FoundryMemoryProvider( - project_endpoint="https://test.project.endpoint", - memory_store_name="test_store", - scope="user_123", - ) - - def test_init_requires_memory_store_name(self, mock_project_client: AsyncMock) -> None: - with pytest.raises(ValueError, match="memory_store_name is required"): - FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="", - scope="user_123", - ) - - def test_init_requires_scope(self, mock_project_client: AsyncMock) -> None: - with pytest.raises(ValueError, match="scope is required"): - FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="", - ) - - -# -- before_run tests ---------------------------------------------------------- - - -class TestBeforeRun: - """Test before_run hook.""" - - async def test_retrieves_static_memories_on_first_run(self, mock_project_client: AsyncMock) -> None: - """First call retrieves static (user profile) memories.""" - mem1 = Mock() - mem1.memory_item.content = "User prefers Python" - mem2 = Mock() - mem2.memory_item.content = "User is based in Seattle" - mock_search_result = Mock() - mock_search_result.memories = [mem1, mem2] - mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") - - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - # Should call search_memories twice: once for static, once for contextual - assert mock_project_client.beta.memory_stores.search_memories.call_count == 2 - # Static memories should be cached - assert len(session.state[provider.source_id]["static_memories"]) == 2 - assert session.state[provider.source_id]["initialized"] is True - - async def test_contextual_memories_added_to_context(self, mock_project_client: AsyncMock) -> None: - """Contextual search returns memories → messages added to context with prompt.""" - # Mock static search (first call) - static_mem = Mock() - static_mem.memory_item.content = "User prefers Python" - static_result = Mock() - static_result.memories = [static_mem] - - # Mock contextual search (second call) - contextual_mem = Mock() - contextual_mem.memory_item.content = "Last discussed async patterns" - contextual_result = Mock() - contextual_result.memories = [contextual_mem] - contextual_result.search_id = "search-123" - - mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result] - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") - - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - # Check that memories were added to context - assert provider.source_id in ctx.context_messages - added = ctx.context_messages[provider.source_id] - assert len(added) == 1 - assert "User prefers Python" in added[0].text # type: ignore[operator] - assert "Last discussed async patterns" in added[0].text # type: ignore[operator] - assert provider.context_prompt in added[0].text # type: ignore[operator] - assert session.state[provider.source_id]["previous_search_id"] == "search-123" - - async def test_empty_input_skips_contextual_search(self, mock_project_client: AsyncMock) -> None: - """Empty input messages → only static search performed, no contextual search.""" - static_result = Mock() - static_result.memories = [] - mock_project_client.beta.memory_stores.search_memories.return_value = static_result - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="")], session_id="s1") - - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - # Should only call search_memories once for static memories - assert mock_project_client.beta.memory_stores.search_memories.call_count == 1 - assert provider.source_id not in ctx.context_messages - - async def test_empty_search_results_no_messages(self, mock_project_client: AsyncMock) -> None: - """Empty search results → no messages added.""" - mock_search_result = Mock() - mock_search_result.memories = [] - mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="test")], session_id="s1") - - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - assert provider.source_id not in ctx.context_messages - - async def test_static_memories_only_retrieved_once(self, mock_project_client: AsyncMock) -> None: - """Static memories are only retrieved on the first call.""" - static_mem = Mock() - static_mem.memory_item.content = "Static memory" - static_result = Mock() - static_result.memories = [static_mem] - contextual_result = Mock() - contextual_result.memories = [] - - mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result] - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") - - # First call - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - assert mock_project_client.beta.memory_stores.search_memories.call_count == 2 - - # Reset mock for second call - mock_project_client.beta.memory_stores.search_memories.reset_mock() - contextual_result2 = Mock() - contextual_result2.memories = [] - mock_project_client.beta.memory_stores.search_memories.return_value = contextual_result2 - - # Second call - should only search contextual, not static - ctx2 = SessionContext(input_messages=[Message(role="user", text="World")], session_id="s1") - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {}) - ) - assert mock_project_client.beta.memory_stores.search_memories.call_count == 1 - - async def test_handles_search_exception_gracefully(self, mock_project_client: AsyncMock) -> None: - """Search exception is logged but doesn't fail the operation.""" - mock_project_client.beta.memory_stores.search_memories.side_effect = Exception("API error") - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") - - # Should not raise exception - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - # No memories added - assert provider.source_id not in ctx.context_messages - - -# -- after_run tests ----------------------------------------------------------- - - -class TestAfterRun: - """Test after_run hook.""" - - async def test_stores_input_and_response(self, mock_project_client: AsyncMock) -> None: - """Stores input+response messages via begin_update_memories.""" - mock_poller = Mock() - mock_poller.update_id = "update-456" - mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="question")], session_id="s1") - ctx._response = AgentResponse(messages=[Message(role="assistant", text="answer")]) - - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - mock_project_client.beta.memory_stores.begin_update_memories.assert_awaited_once() - call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs - assert call_kwargs["name"] == "test_store" - assert call_kwargs["scope"] == "user_123" - assert len(call_kwargs["items"]) == 2 - assert call_kwargs["items"][0]["content"] == "question" - assert call_kwargs["items"][1]["content"] == "answer" - assert session.state[provider.source_id]["previous_update_id"] == "update-456" - - async def test_only_stores_user_assistant_system(self, mock_project_client: AsyncMock) -> None: - """Only stores user/assistant/system messages with text.""" - mock_poller = Mock() - mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext( - input_messages=[ - Message(role="user", text="hello"), - Message(role="tool", text="tool output"), - ], - session_id="s1", - ) - ctx._response = AgentResponse(messages=[Message(role="assistant", text="reply")]) - - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs - items = call_kwargs["items"] - assert len(items) == 2 - assert items[0]["content"] == "hello" - assert items[1]["content"] == "reply" - - async def test_skips_empty_messages(self, mock_project_client: AsyncMock) -> None: - """Skips messages with empty text.""" - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext( - input_messages=[ - Message(role="user", text=""), - Message(role="user", text=" "), - ], - session_id="s1", - ) - ctx._response = AgentResponse(messages=[]) - - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - mock_project_client.beta.memory_stores.begin_update_memories.assert_not_awaited() - - async def test_uses_configured_update_delay(self, mock_project_client: AsyncMock) -> None: - """Uses the configured update_delay parameter.""" - mock_poller = Mock() - mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - update_delay=60, - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") - ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) - - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs - assert call_kwargs["update_delay"] == 60 - - async def test_uses_previous_update_id_for_incremental_updates(self, mock_project_client: AsyncMock) -> None: - """Uses previous_update_id for incremental updates.""" - mock_poller1 = Mock() - mock_poller1.update_id = "update-1" - mock_poller2 = Mock() - mock_poller2.update_id = "update-2" - - mock_project_client.beta.memory_stores.begin_update_memories.side_effect = [mock_poller1, mock_poller2] - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx1 = SessionContext(input_messages=[Message(role="user", text="first")], session_id="s1") - ctx1._response = AgentResponse(messages=[Message(role="assistant", text="response1")]) - - # First update - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx1, state=session.state.setdefault(provider.source_id, {}) - ) - assert session.state[provider.source_id]["previous_update_id"] == "update-1" - - # Second update should use previous_update_id - ctx2 = SessionContext(input_messages=[Message(role="user", text="second")], session_id="s1") - ctx2._response = AgentResponse(messages=[Message(role="assistant", text="response2")]) - - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {}) - ) - - call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs - assert call_kwargs["previous_update_id"] == "update-1" - assert session.state[provider.source_id]["previous_update_id"] == "update-2" - - async def test_handles_update_exception_gracefully(self, mock_project_client: AsyncMock) -> None: - """Update exception is logged but doesn't fail the operation.""" - mock_project_client.beta.memory_stores.begin_update_memories.side_effect = Exception("API error") - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") - ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) - - # Should not raise exception - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - -# -- Context manager tests ----------------------------------------------------- - - -class TestContextManager: - """Test __aenter__/__aexit__ delegation.""" - - async def test_aenter_delegates_to_client(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - result = await provider.__aenter__() - assert result is provider - mock_project_client.__aenter__.assert_awaited_once() - - async def test_aexit_delegates_to_client(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - await provider.__aexit__(None, None, None) - mock_project_client.__aexit__.assert_awaited_once() - - async def test_async_with_syntax(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - async with provider as p: - assert p is provider diff --git a/python/packages/lab/gaia/samples/openai_agent.py b/python/packages/lab/gaia/samples/openai_agent.py index a5709ecf2a..227b12c03c 100644 --- a/python/packages/lab/gaia/samples/openai_agent.py +++ b/python/packages/lab/gaia/samples/openai_agent.py @@ -7,7 +7,7 @@ configured for GAIA benchmark tasks using the OpenAI Responses API. Required Environment Variables: OPENAI_API_KEY: Your OpenAI API key - OPENAI_RESPONSES_MODEL_ID: Model to use with Responses API (e.g., gpt-4o, gpt-4o-mini) + OPENAI_RESPONSES_MODEL: Model to use with Responses API (e.g., gpt-4o, gpt-4o-mini) Optional Environment Variables: OPENAI_BASE_URL: Custom API base URL if using a proxy or compatible service @@ -19,7 +19,7 @@ Authentication: Example: export OPENAI_API_KEY="sk-..." - export OPENAI_RESPONSES_MODEL_ID="gpt-4o" + export OPENAI_RESPONSES_MODEL="gpt-4o" """ from collections.abc import AsyncIterator diff --git a/python/packages/openai/AGENTS.md b/python/packages/openai/AGENTS.md index d31506cf5d..48c3a306bd 100644 --- a/python/packages/openai/AGENTS.md +++ b/python/packages/openai/AGENTS.md @@ -27,6 +27,10 @@ agent_framework_openai/ All clients follow the Raw + Full-Featured pattern (e.g., `RawOpenAIChatClient` + `OpenAIChatClient`). +The generic OpenAI clients support both OpenAI and Azure OpenAI routing. Precedence is: +explicit Azure inputs (`credential`, `azure_endpoint`, `api_version`) → OpenAI API key +(`OPENAI_API_KEY`) → Azure environment fallback (`AZURE_OPENAI_*`). + ## Dependencies - `agent-framework-core` — core abstractions diff --git a/python/packages/openai/README.md b/python/packages/openai/README.md index 6ed4d20c03..e04a1f947a 100644 --- a/python/packages/openai/README.md +++ b/python/packages/openai/README.md @@ -1,17 +1,106 @@ # agent-framework-openai -OpenAI integration for Microsoft Agent Framework. Provides chat clients for the OpenAI Responses API and Chat Completions API. +OpenAI integration for Microsoft Agent Framework. + +This package provides: + +- `OpenAIChatClient` for the OpenAI Responses API +- `OpenAIChatCompletionClient` for the Chat Completions API +- `OpenAIEmbeddingClient` for embeddings ## Installation ```bash -pip install agent-framework-openai +pip install agent-framework-openai --pre ``` -## Usage +## Which chat client should I use? + +Use `OpenAIChatClient` for new work unless you specifically need the Chat Completions API. + +- `OpenAIChatClient` uses the Responses API and is the preferred general-purpose chat client. +- `OpenAIChatCompletionClient` uses the Chat Completions API and is mainly for compatibility with + existing Chat Completions-based integrations. + +The deprecated `OpenAIResponsesClient` alias points to `OpenAIChatClient`. + +## Environment variables + +### OpenAI + +These variables are used when the client is configured for OpenAI: + +| Variable | Purpose | +| --- | --- | +| `OPENAI_API_KEY` | OpenAI API key | +| `OPENAI_ORG_ID` | OpenAI organization ID | +| `OPENAI_BASE_URL` | Custom OpenAI-compatible base URL | +| `OPENAI_MODEL` | Generic fallback model | +| `OPENAI_RESPONSES_MODEL` | Preferred model for `OpenAIChatClient` | +| `OPENAI_CHAT_MODEL` | Preferred model for `OpenAIChatCompletionClient` | +| `OPENAI_EMBEDDING_MODEL` | Preferred model for `OpenAIEmbeddingClient` | + +Model lookup order: + +- `OpenAIChatClient`: `OPENAI_RESPONSES_MODEL` -> `OPENAI_MODEL` +- `OpenAIChatCompletionClient`: `OPENAI_CHAT_MODEL` -> `OPENAI_MODEL` +- `OpenAIEmbeddingClient`: `OPENAI_EMBEDDING_MODEL` -> `OPENAI_MODEL` + +### Azure OpenAI + +These variables are used when the client is configured for Azure OpenAI: + +| Variable | Purpose | +| --- | --- | +| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI resource endpoint | +| `AZURE_OPENAI_BASE_URL` | Full Azure OpenAI base URL (`.../openai/v1`) | +| `AZURE_OPENAI_API_KEY` | Azure OpenAI API key | +| `AZURE_OPENAI_API_VERSION` | Azure OpenAI API version | +| `AZURE_OPENAI_DEPLOYMENT_NAME` | Generic fallback deployment | +| `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` | Preferred deployment for `OpenAIChatClient` | +| `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` | Preferred deployment for `OpenAIChatCompletionClient` | +| `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` | Preferred deployment for `OpenAIEmbeddingClient` | + +Deployment lookup order: + +- `OpenAIChatClient`: `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` -> `AZURE_OPENAI_DEPLOYMENT_NAME` +- `OpenAIChatCompletionClient`: `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` -> `AZURE_OPENAI_DEPLOYMENT_NAME` +- `OpenAIEmbeddingClient`: `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` -> `AZURE_OPENAI_DEPLOYMENT_NAME` + +When both OpenAI and Azure environment variables are present, the generic clients prefer OpenAI +when `OPENAI_API_KEY` is configured. To use Azure explicitly, pass `azure_endpoint` or +`credential`. + +## OpenAI example ```python from agent_framework.openai import OpenAIChatClient -client = OpenAIChatClient(model_id="gpt-4o") +client = OpenAIChatClient(model="gpt-4.1") +``` + +## Azure OpenAI example + +```python +from azure.identity.aio import AzureCliCredential + +from agent_framework.openai import OpenAIChatClient + +client = OpenAIChatClient( + model="my-responses-deployment", + azure_endpoint="https://my-resource.openai.azure.com", + credential=AzureCliCredential(), +) +``` + +## ChatClient vs ChatCompletionClient + +Use `OpenAIChatClient` when you want the Responses API as your default chat surface. + +Use `OpenAIChatCompletionClient` when you specifically need the Chat Completions API: + +```python +from agent_framework.openai import OpenAIChatCompletionClient + +client = OpenAIChatCompletionClient(model="gpt-4o-mini") ``` diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 86af86895e..b0d56ee26f 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -14,7 +14,6 @@ from collections.abc import ( MutableMapping, Sequence, ) -from copy import copy from datetime import datetime, timezone from itertools import chain from typing import ( @@ -28,12 +27,11 @@ from typing import ( cast, overload, ) -from urllib.parse import urljoin, urlparse from agent_framework._clients import BaseChatClient -from agent_framework._middleware import ChatMiddlewareLayer +from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer from agent_framework._settings import SecretString -from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent +from agent_framework._telemetry import USER_AGENT_KEY from agent_framework._tools import ( SHELL_TOOL_KIND_VALUE, FunctionInvocationConfiguration, @@ -87,8 +85,7 @@ from pydantic import BaseModel from ._exceptions import OpenAIContentFilterException from ._shared import ( - DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION, - get_api_key, + AzureTokenProvider, load_openai_service_settings, maybe_append_azure_endpoint_guidance, ) @@ -107,14 +104,15 @@ else: from typing_extensions import TypedDict # type: ignore # pragma: no cover if TYPE_CHECKING: - from agent_framework._middleware import ( - ChatMiddleware, - ChatMiddlewareCallable, - FunctionMiddleware, - FunctionMiddlewareCallable, - ) + from azure.core.credentials import TokenCredential + from azure.core.credentials_async import AsyncTokenCredential + + AzureCredentialTypes = TokenCredential | AsyncTokenCredential logger = logging.getLogger("agent_framework.openai") + +DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION = "preview" + OPENAI_SHELL_ENVIRONMENT_KEY = "openai.responses.shell.environment" OPENAI_SHELL_OUTPUT_TYPE_KEY = "openai.responses.shell.output_type" OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY = "openai.responses.local_shell.call_item_id" @@ -139,7 +137,7 @@ class ReasoningOptions(TypedDict, total=False): See: https://platform.openai.com/docs/guides/reasoning """ - effort: Literal["low", "medium", "high"] + effort: Literal["none", "low", "medium", "high", "xhigh"] """The effort level for reasoning. Higher effort means more reasoning tokens.""" summary: Literal["auto", "concise", "detailed"] @@ -272,8 +270,8 @@ class RawOpenAIChatClient( # type: ignore[misc] @overload def __init__( self, - *, model: str | None = None, + *, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, base_url: str | None = None, @@ -282,31 +280,77 @@ class RawOpenAIChatClient( # type: ignore[misc] instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - ) -> None: ... + ) -> None: + """Initialize a raw OpenAI Chat client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_RESPONSES_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... @overload def __init__( self, - *, model: str | None = None, - api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, - base_url: str | None = None, + *, azure_endpoint: str, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, api_version: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + base_url: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - ) -> None: ... + ) -> None: + """Initialize a raw OpenAI Chat client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the Responses default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, + but ``credential`` is the preferred Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... def __init__( self, - *, model: str | None = None, + *, model_id: str | None = None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, base_url: str | None = None, azure_endpoint: str | None = None, @@ -318,29 +362,53 @@ class RawOpenAIChatClient( # type: ignore[misc] env_file_encoding: str | None = None, **kwargs: Any, ) -> None: - """Initialize a raw OpenAI Responses client. + """Initialize a raw OpenAI Chat client. Keyword Args: - model: OpenAI model name. + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_RESPONSES_MODEL`` and then ``OPENAI_MODEL`` for OpenAI, + or ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME`` for Azure. model_id: Deprecated alias for ``model``. - api_key: OpenAI API key, SecretString, or callable returning a key. - org_id: OpenAI organization ID. - base_url: Custom API base URL. - azure_endpoint: Azure OpenAI endpoint. When provided, the client uses - ``AsyncAzureOpenAI`` instead of ``AsyncOpenAI``. The value should be the - resource endpoint and should not end with ``/openai/v1``. For Azure OpenAI - key auth, either pass the resource endpoint without that suffix to - ``azure_endpoint`` or pass the full ``.../openai/v1`` URL to ``base_url``. - Can also be set via ``AZURE_OPENAI_ENDPOINT`` when no ``OPENAI_BASE_URL`` - is configured. - api_version: Azure OpenAI API version. Can also be set via - ``AZURE_OPENAI_API_VERSION``. + api_key: API key override. For OpenAI this maps to ``OPENAI_API_KEY``. + For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted for backwards compatibility, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI and resolved from + ``OPENAI_ORG_ID`` when not provided. + base_url: Base URL override. For OpenAI this maps to ``OPENAI_BASE_URL``. + For Azure this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use once Azure routing is selected. When + not provided explicitly, Azure routing falls back to + ``AZURE_OPENAI_API_VERSION`` and then the Responses default. default_headers: Additional HTTP headers. - async_client: Pre-configured AsyncOpenAI client (skips client creation). - instruction_role: Role for instruction messages (e.g. ``"system"``). - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. kwargs: Additional keyword arguments forwarded to ``BaseChatClient``. + + Notes: + Environment resolution and routing precedence are: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_RESPONSES_MODEL``, + ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing + reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME``, + ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. """ if model_id is not None and model is None: import warnings @@ -348,98 +416,39 @@ class RawOpenAIChatClient( # type: ignore[misc] warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) model = model_id - openai_settings: dict[str, Any] = {} - use_azure_client = isinstance(async_client, AsyncAzureOpenAI) - if not async_client: - resolved_settings, use_azure_client = load_openai_service_settings( - model=model, - api_key=api_key, - org_id=org_id, - base_url=base_url, - azure_endpoint=azure_endpoint, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - azure_model_env_vars=("AZURE_OPENAI_DEPLOYMENT_NAME",), - default_azure_api_version=DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION, - ) - openai_settings = dict(resolved_settings) + settings, client, use_azure_client = load_openai_service_settings( + model=model, + api_key=api_key, + credential=credential, + org_id=org_id, + base_url=base_url, + endpoint=azure_endpoint, + api_version=api_version, + default_azure_api_version=DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION, + default_headers=default_headers, + client=async_client, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + openai_model_fields=("responses_model", "model"), + azure_deployment_fields=("responses_deployment_name", "deployment_name"), + responses_mode=True, + ) - api_key_value = openai_settings.get("api_key") - if not api_key_value: - raise ValueError( - "OpenAI API key is required. Set via the 'api_key' parameter or the " - "'OPENAI_API_KEY' or 'AZURE_OPENAI_API_KEY' environment variables." - ) - resolved_model = openai_settings.get("model") or model - if not resolved_model: - raise ValueError( - "OpenAI model is required. Set via the 'model' parameter or the " - "'OPENAI_MODEL' or 'AZURE_OPENAI_DEPLOYMENT_NAME' environment variables." - ) - model = resolved_model - - resolved_api_key = get_api_key(api_key_value) - - # Merge APP_INFO into the headers - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - client_args: dict[str, Any] = {"api_key": resolved_api_key, "default_headers": merged_headers} - if use_azure_client: - endpoint_value = openai_settings.get("azure_endpoint") - if ( - not openai_settings.get("base_url") - and endpoint_value - and (hostname := urlparse(str(endpoint_value)).hostname) - and hostname.endswith(".openai.azure.com") - ): - openai_settings["base_url"] = urljoin(str(endpoint_value), "/openai/v1/") - - client_args.pop("api_key") - if resolved_api_version := openai_settings.get("api_version"): - client_args["api_version"] = resolved_api_version - if resolved_base_url := openai_settings.get("base_url"): - client_args["base_url"] = resolved_base_url - elif resolved_azure_endpoint := openai_settings.get("azure_endpoint"): - client_args["azure_endpoint"] = resolved_azure_endpoint - if callable(resolved_api_key): - client_args["azure_ad_token_provider"] = resolved_api_key - else: - client_args["api_key"] = resolved_api_key - client_args["azure_deployment"] = resolved_model - async_client = AsyncAzureOpenAI(**client_args) - else: - if resolved_org_id := openai_settings.get("org_id"): - client_args["organization"] = resolved_org_id - if resolved_base_url := openai_settings.get("base_url"): - client_args["base_url"] = resolved_base_url - - async_client = AsyncOpenAI(**client_args) - - self.client = async_client - self.model: str | None = model.strip() if model else None + self.client = client + self.model: str = settings.get("model") or settings.get("deployment_name") or "" # Store configuration for serialization - resolved_base_url = openai_settings.get("base_url") or base_url - resolved_azure_endpoint = openai_settings.get("azure_endpoint") or azure_endpoint - resolved_api_version = openai_settings.get("api_version") or api_version - self.org_id = openai_settings.get("org_id") or org_id - self.base_url = str(resolved_base_url) if resolved_base_url else None - self.azure_endpoint = str(resolved_azure_endpoint) if resolved_azure_endpoint else None - self.api_version = str(resolved_api_version) if use_azure_client and resolved_api_version else None + self.org_id = settings.get("org_id") + self.base_url = settings.get("base_url") + self.azure_endpoint = settings.get("endpoint") + self.api_version = settings.get("api_version") if default_headers: self.default_headers: dict[str, Any] | None = { k: v for k, v in default_headers.items() if k != USER_AGENT_KEY } else: self.default_headers = None - - if instruction_role is not None: - self.instruction_role = instruction_role - + self.instruction_role = instruction_role if use_azure_client: self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] @@ -2452,8 +2461,8 @@ class OpenAIChatClient( # type: ignore[misc] @overload def __init__( self, - *, model: str | None = None, + *, api_key: str | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, base_url: str | None = None, @@ -2462,38 +2471,84 @@ class OpenAIChatClient( # type: ignore[misc] instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - middleware: ( - Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None - ) = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, - ) -> None: ... + ) -> None: + """Initialize an OpenAI Responses client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_RESPONSES_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + """ + ... @overload def __init__( self, - *, model: str | None = None, - api_key: str | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, - base_url: str | None = None, - azure_endpoint: str, + *, + azure_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, api_version: str | None = None, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + base_url: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - middleware: ( - Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None - ) = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, - ) -> None: ... + ) -> None: + """Initialize an OpenAI Responses client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the Responses default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, but ``credential`` is the preferred + Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + """ + ... def __init__( self, - *, model: str | None = None, + *, api_key: str | Callable[[], str | Awaitable[str]] | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, base_url: str | None = None, azure_endpoint: str | None = None, @@ -2503,43 +2558,59 @@ class OpenAIChatClient( # type: ignore[misc] instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - middleware: ( - Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None - ) = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, **kwargs: Any, ) -> None: """Initialize an OpenAI Responses client. Keyword Args: - model: OpenAI model name, see https://platform.openai.com/docs/models. - Can also be set via environment variable OPENAI_MODEL. - api_key: The API key to use. If provided will override the env vars or .env file value. - Can also be set via environment variable OPENAI_API_KEY. - org_id: The org ID to use. If provided will override the env vars or .env file value. - Can also be set via environment variable OPENAI_ORG_ID. - base_url: The base URL to use. If provided will override the standard value. - Can also be set via environment variable OPENAI_BASE_URL. - azure_endpoint: Azure OpenAI endpoint. When provided, the client uses - ``AsyncAzureOpenAI``. The value should be the Azure resource endpoint and - should not end with ``/openai/v1``. For Azure OpenAI key auth, either pass - the resource endpoint without that suffix to ``azure_endpoint`` or pass the - full ``.../openai/v1`` URL to ``base_url`` instead. Can also be discovered - from ``AZURE_OPENAI_ENDPOINT`` when no OpenAI base URL is configured. - api_version: Azure OpenAI API version. Can also be set via - ``AZURE_OPENAI_API_VERSION``. - default_headers: The default headers mapping of string keys to - string values for HTTP requests. - async_client: An existing client to use. - instruction_role: The role to use for 'instruction' messages, for example, - "system" or "developer". If not provided, the default is "system". - env_file_path: Use the environment settings file as a fallback - to environment variables. - env_file_encoding: The encoding of the environment settings file. + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_RESPONSES_MODEL`` and then ``OPENAI_MODEL`` for OpenAI + routing, or ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME`` for Azure routing. + api_key: API key override. For OpenAI routing this maps to ``OPENAI_API_KEY``. + For Azure routing this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted for backwards compatibility, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI routing and resolved from + ``OPENAI_ORG_ID`` when not provided. + base_url: Base URL override. For OpenAI routing this maps to ``OPENAI_BASE_URL``. + For Azure routing this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure routing + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use once Azure routing is selected. When + not provided explicitly, Azure routing falls back to + ``AZURE_OPENAI_API_VERSION`` and then the Responses default. + default_headers: Default HTTP headers that are merged into each request. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role to use for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. middleware: Optional middleware to apply to the client. function_invocation_configuration: Optional function invocation configuration override. kwargs: Other keyword parameters. + Notes: + Environment resolution and routing precedence are: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_RESPONSES_MODEL``, + ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing + reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME``, + ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + Examples: .. code-block:: python @@ -2571,6 +2642,7 @@ class OpenAIChatClient( # type: ignore[misc] super().__init__( model=model, api_key=api_key, + credential=credential, org_id=org_id, base_url=base_url, azure_endpoint=azure_endpoint, diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index aa78079dd2..514d0a2991 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -13,16 +13,15 @@ from collections.abc import ( MutableMapping, Sequence, ) -from copy import copy from datetime import datetime, timezone from itertools import chain -from typing import Any, ClassVar, Generic, Literal, cast, overload +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast, overload from agent_framework._clients import BaseChatClient from agent_framework._docstrings import apply_layered_docstring from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer from agent_framework._settings import SecretString -from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent +from agent_framework._telemetry import USER_AGENT_KEY from agent_framework._tools import ( FunctionInvocationConfiguration, FunctionInvocationLayer, @@ -59,8 +58,7 @@ from pydantic import BaseModel from ._exceptions import OpenAIContentFilterException from ._shared import ( - DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION, - get_api_key, + AzureTokenProvider, load_openai_service_settings, maybe_append_azure_endpoint_guidance, ) @@ -78,8 +76,16 @@ if sys.version_info >= (3, 11): else: from typing_extensions import TypedDict # type: ignore # pragma: no cover +if TYPE_CHECKING: + from azure.core.credentials import TokenCredential + from azure.core.credentials_async import AsyncTokenCredential + + AzureCredentialTypes = TokenCredential | AsyncTokenCredential + logger = logging.getLogger("agent_framework.openai") +DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION = "2024-12-01-preview" + ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) @@ -179,8 +185,8 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] @overload def __init__( self, - *, model: str | None = None, + *, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, base_url: str | None = None, @@ -189,31 +195,77 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - ) -> None: ... + ) -> None: + """Initialize a raw OpenAI Chat completion client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... @overload def __init__( self, - *, model: str | None = None, - api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, - base_url: str | None = None, - azure_endpoint: str, + *, + azure_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, api_version: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + base_url: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - ) -> None: ... + ) -> None: + """Initialize a raw OpenAI Chat completion client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the Chat Completions default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, but ``credential`` is the preferred + Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... def __init__( self, - *, model: str | None = None, + *, model_id: str | None = None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, base_url: str | None = None, azure_endpoint: str | None = None, @@ -228,26 +280,50 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] """Initialize a raw OpenAI Chat completion client. Keyword Args: - model: OpenAI model name. + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL`` for OpenAI routing, + or ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME`` for Azure routing. model_id: Deprecated alias for ``model``. - api_key: OpenAI API key, SecretString, or callable returning a key. - org_id: OpenAI organization ID. - base_url: Custom API base URL. - azure_endpoint: Azure OpenAI endpoint. When provided, the client uses - ``AsyncAzureOpenAI`` instead of ``AsyncOpenAI``. The value should be the - resource endpoint and should not end with ``/openai/v1``. For Azure OpenAI - key auth, either pass the resource endpoint without that suffix to - ``azure_endpoint`` or pass the full ``.../openai/v1`` URL to ``base_url``. - Can also be set via ``AZURE_OPENAI_ENDPOINT`` when no ``OPENAI_BASE_URL`` - is configured. - api_version: Azure OpenAI API version. Can also be set via - ``AZURE_OPENAI_API_VERSION``. + api_key: API key override. For OpenAI routing this maps to ``OPENAI_API_KEY``. + For Azure routing this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted for backwards compatibility, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI routing and resolved from + ``OPENAI_ORG_ID`` when not provided. + base_url: Base URL override. For OpenAI routing this maps to ``OPENAI_BASE_URL``. + For Azure routing this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure routing + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use once Azure routing is selected. When + not provided explicitly, Azure routing falls back to + ``AZURE_OPENAI_API_VERSION`` and then the Chat Completions default. default_headers: Additional HTTP headers. - async_client: Pre-configured AsyncOpenAI client (skips client creation). - instruction_role: Role for instruction messages (e.g. ``"system"``). - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. kwargs: Additional keyword arguments forwarded to ``BaseChatClient``. + + Notes: + Environment resolution and routing precedence are: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_CHAT_MODEL``, + ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing + reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME``, + ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. """ if model_id is not None and model is None: import warnings @@ -255,89 +331,38 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) model = model_id - openai_settings: dict[str, Any] = {} - use_azure_client = isinstance(async_client, AsyncAzureOpenAI) - if not async_client: - resolved_settings, use_azure_client = load_openai_service_settings( - model=model, - api_key=api_key, - org_id=org_id, - base_url=base_url, - azure_endpoint=azure_endpoint, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - azure_model_env_vars=("AZURE_OPENAI_DEPLOYMENT_NAME",), - default_azure_api_version=DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION, - ) - openai_settings = dict(resolved_settings) + settings, client, use_azure_client = load_openai_service_settings( + model=model, + api_key=api_key, + credential=credential, + org_id=org_id, + base_url=base_url, + endpoint=azure_endpoint, + api_version=api_version, + default_azure_api_version=DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION, + default_headers=default_headers, + client=async_client, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + openai_model_fields=("chat_model", "model"), + azure_deployment_fields=("chat_deployment_name", "deployment_name"), + ) - api_key_value = openai_settings.get("api_key") - if not api_key_value: - raise ValueError( - "OpenAI API key is required. Set via the 'api_key' parameter or the " - "'OPENAI_API_KEY' or 'AZURE_OPENAI_API_KEY' environment variables." - ) - resolved_model = openai_settings.get("model") or model - if not resolved_model: - raise ValueError( - "OpenAI model is required. Set via the 'model' parameter or the " - "'OPENAI_MODEL' or 'AZURE_OPENAI_DEPLOYMENT_NAME' environment variables." - ) - model = resolved_model - - resolved_api_key = get_api_key(api_key_value) - - # Merge APP_INFO into the headers - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - client_args: dict[str, Any] = {"api_key": resolved_api_key, "default_headers": merged_headers} - if use_azure_client: - client_args.pop("api_key") - if resolved_api_version := openai_settings.get("api_version"): - client_args["api_version"] = resolved_api_version - if resolved_base_url := openai_settings.get("base_url"): - client_args["base_url"] = resolved_base_url - elif resolved_azure_endpoint := openai_settings.get("azure_endpoint"): - client_args["azure_endpoint"] = resolved_azure_endpoint - if callable(resolved_api_key): - client_args["azure_ad_token_provider"] = resolved_api_key - else: - client_args["api_key"] = resolved_api_key - client_args["azure_deployment"] = resolved_model - async_client = AsyncAzureOpenAI(**client_args) - else: - if resolved_org_id := openai_settings.get("org_id"): - client_args["organization"] = resolved_org_id - if resolved_base_url := openai_settings.get("base_url"): - client_args["base_url"] = resolved_base_url - - async_client = AsyncOpenAI(**client_args) - - self.client = async_client - self.model: str | None = model.strip() if model else None + self.client = client + self.model: str = settings.get("model") or settings.get("deployment_name") or "" # Store configuration for serialization - resolved_base_url = openai_settings.get("base_url") or base_url - resolved_azure_endpoint = openai_settings.get("azure_endpoint") or azure_endpoint - resolved_api_version = openai_settings.get("api_version") or api_version - self.org_id = openai_settings.get("org_id") or org_id - self.base_url = str(resolved_base_url) if resolved_base_url else None - self.azure_endpoint = str(resolved_azure_endpoint) if resolved_azure_endpoint else None - self.api_version = str(resolved_api_version) if use_azure_client and resolved_api_version else None + self.org_id = settings.get("org_id") + self.base_url = settings.get("base_url") + self.azure_endpoint = settings.get("endpoint") + self.api_version = settings.get("api_version") if default_headers: self.default_headers: dict[str, Any] | None = { k: v for k, v in default_headers.items() if k != USER_AGENT_KEY } else: self.default_headers = None - - if instruction_role is not None: - self.instruction_role = instruction_role - + self.instruction_role = instruction_role if use_azure_client: self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] @@ -977,6 +1002,202 @@ class OpenAIChatCompletionClient( # type: ignore[misc] OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] + @overload + def __init__( + self, + model: str | None = None, + *, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + org_id: str | None = None, + base_url: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncOpenAI | None = None, + instruction_role: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + ) -> None: + """Initialize an OpenAI Chat completion client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + instruction_role: Role for instruction messages (for example ``"system"``). + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests. + function_invocation_configuration: Optional configuration for function invocation support. + """ + ... + + @overload + def __init__( + self, + model: str | None = None, + *, + azure_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + api_version: str | None = None, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + base_url: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + instruction_role: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + ) -> None: + """Initialize an OpenAI Chat completion client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the Chat Completions default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, but ``credential`` is the preferred + Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests. + function_invocation_configuration: Optional configuration for function invocation support. + """ + ... + + def __init__( + self, + model: str | None = None, + *, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + org_id: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncOpenAI | None = None, + instruction_role: str | None = None, + base_url: str | None = None, + azure_endpoint: str | None = None, + api_version: str | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an OpenAI Chat completion client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL`` for OpenAI routing, + or ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME`` for Azure routing. + api_key: API key override. For OpenAI routing this maps to ``OPENAI_API_KEY``. + For Azure routing this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted for backwards compatibility, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI routing and resolved from + ``OPENAI_ORG_ID`` when not provided. + default_headers: Default HTTP headers that are merged into each request. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role to use for instruction messages (for example ``"system"``). + base_url: Base URL override. For OpenAI routing this maps to ``OPENAI_BASE_URL``. + For Azure routing this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure routing + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use once Azure routing is selected. When + not provided explicitly, Azure routing falls back to + ``AZURE_OPENAI_API_VERSION`` and then the Chat Completions default. + middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests. + function_invocation_configuration: Optional configuration for function invocation support. + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. + + Notes: + Environment resolution and routing precedence are: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_CHAT_MODEL``, + ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing + reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME``, + ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + + Examples: + .. code-block:: python + + from agent_framework.openai import OpenAIChatCompletionClient + + # Using environment variables + # Set OPENAI_API_KEY=sk-... + # Set OPENAI_MODEL= + client = OpenAIChatCompletionClient() + + # Or passing parameters directly + client = OpenAIChatCompletionClient(model="", api_key="sk-...") + + # Or loading from a .env file + client = OpenAIChatCompletionClient(env_file_path="path/to/.env") + + # Using custom ChatOptions with type safety: + from typing import TypedDict + from agent_framework.openai import OpenAIChatCompletionOptions + + + class MyOptions(OpenAIChatCompletionOptions, total=False): + my_custom_option: str + + + client: OpenAIChatCompletionClient[MyOptions] = OpenAIChatCompletionClient(model="") + response = await client.get_response("Hello", options={"my_custom_option": "value"}) + """ + super().__init__( + model=model, + api_key=api_key, + credential=credential, + org_id=org_id, + base_url=base_url, + azure_endpoint=azure_endpoint, + api_version=api_version, + default_headers=default_headers, + async_client=async_client, + instruction_role=instruction_role, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, + ) + @overload def get_response( self, @@ -1045,98 +1266,6 @@ class OpenAIChatCompletionClient( # type: ignore[misc] **kwargs, ) - def __init__( - self, - *, - model: str | None = None, - api_key: str | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, - default_headers: Mapping[str, str] | None = None, - async_client: AsyncOpenAI | None = None, - instruction_role: str | None = None, - base_url: str | None = None, - azure_endpoint: str | None = None, - api_version: str | None = None, - middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize an OpenAI Chat completion client. - - Keyword Args: - model: OpenAI model name, see https://platform.openai.com/docs/models. - Can also be set via environment variable OPENAI_MODEL. - api_key: The API key to use. If provided will override the env vars or .env file value. - Can also be set via environment variable OPENAI_API_KEY. - org_id: The org ID to use. If provided will override the env vars or .env file value. - Can also be set via environment variable OPENAI_ORG_ID. - default_headers: The default headers mapping of string keys to - string values for HTTP requests. - async_client: An existing client to use. - instruction_role: The role to use for 'instruction' messages, for example, - "system" or "developer". If not provided, the default is "system". - base_url: The base URL to use. If provided will override - the standard value for an OpenAI connector, the env vars or .env file value. - Can also be set via environment variable OPENAI_BASE_URL. - azure_endpoint: Azure OpenAI endpoint. When provided, the client uses - ``AsyncAzureOpenAI``. The value should be the Azure resource endpoint and - should not end with ``/openai/v1``. For Azure OpenAI key auth, either pass - the resource endpoint without that suffix to ``azure_endpoint`` or pass the - full ``.../openai/v1`` URL to ``base_url`` instead. Can also be discovered - from ``AZURE_OPENAI_ENDPOINT`` when no OpenAI base URL is configured. - api_version: Azure OpenAI API version. Can also be set via - ``AZURE_OPENAI_API_VERSION``. - middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests. - function_invocation_configuration: Optional configuration for function invocation support. - env_file_path: Use the environment settings file as a fallback - to environment variables. - env_file_encoding: The encoding of the environment settings file. - - Examples: - .. code-block:: python - - from agent_framework.openai import OpenAIChatCompletionClient - - # Using environment variables - # Set OPENAI_API_KEY=sk-... - # Set OPENAI_MODEL= - client = OpenAIChatCompletionClient() - - # Or passing parameters directly - client = OpenAIChatCompletionClient(model="", api_key="sk-...") - - # Or loading from a .env file - client = OpenAIChatCompletionClient(env_file_path="path/to/.env") - - # Using custom ChatOptions with type safety: - from typing import TypedDict - from agent_framework.openai import OpenAIChatCompletionOptions - - - class MyOptions(OpenAIChatCompletionOptions, total=False): - my_custom_option: str - - - client: OpenAIChatCompletionClient[MyOptions] = OpenAIChatCompletionClient(model="") - response = await client.get_response("Hello", options={"my_custom_option": "value"}) - """ - super().__init__( - model=model, - api_key=api_key, - org_id=org_id, - base_url=base_url, - azure_endpoint=azure_endpoint, - api_version=api_version, - default_headers=default_headers, - async_client=async_client, - instruction_role=instruction_role, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - middleware=middleware, - function_invocation_configuration=function_invocation_configuration, - ) - def _apply_openai_chat_completion_client_docstrings() -> None: """Align OpenAI chat completion client docstrings with the raw implementation.""" diff --git a/python/packages/openai/agent_framework_openai/_embedding_client.py b/python/packages/openai/agent_framework_openai/_embedding_client.py index ad959d5b39..9cb37ad4df 100644 --- a/python/packages/openai/agent_framework_openai/_embedding_client.py +++ b/python/packages/openai/agent_framework_openai/_embedding_client.py @@ -6,23 +6,31 @@ import base64 import struct import sys from collections.abc import Awaitable, Callable, Mapping, Sequence -from copy import copy -from typing import Any, ClassVar, Generic, Literal, TypedDict +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, TypedDict, overload from agent_framework._clients import BaseEmbeddingClient -from agent_framework._settings import SecretString, load_settings -from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent +from agent_framework._settings import SecretString +from agent_framework._telemetry import USER_AGENT_KEY from agent_framework._types import Embedding, EmbeddingGenerationOptions, GeneratedEmbeddings, UsageDetails from agent_framework.observability import EmbeddingTelemetryLayer -from openai import AsyncOpenAI +from openai import AsyncAzureOpenAI, AsyncOpenAI -from ._shared import OpenAISettings, get_api_key +from ._shared import AzureTokenProvider, load_openai_service_settings if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover else: from typing_extensions import TypeVar # type: ignore # pragma: no cover +if TYPE_CHECKING: + from azure.core.credentials import TokenCredential + from azure.core.credentials_async import AsyncTokenCredential + + AzureCredentialTypes = TokenCredential | AsyncTokenCredential + + +DEFAULT_AZURE_OPENAI_EMBEDDING_API_VERSION = "2024-10-21" + class OpenAIEmbeddingOptions(EmbeddingGenerationOptions, total=False): """OpenAI-specific embedding options. @@ -61,11 +69,11 @@ class RawOpenAIEmbeddingClient( INJECTABLE: ClassVar[set[str]] = {"client"} + @overload def __init__( self, *, model: str | None = None, - model_id: str | None = None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, base_url: str | None = None, @@ -73,21 +81,130 @@ class RawOpenAIEmbeddingClient( async_client: AsyncOpenAI | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, + ) -> None: + """Initialize a raw OpenAI embedding client. + + Keyword Args: + model: Embedding model identifier. When not provided, the constructor reads + ``OPENAI_EMBEDDING_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + + @overload + def __init__( + self, + *, + model: str | None = None, + azure_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + api_version: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + base_url: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a raw OpenAI embedding client. + + Keyword Args: + model: Embedding deployment name. When not provided, the constructor reads + ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the embedding default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, but ``credential`` is the preferred + Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI. + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + + def __init__( + self, + *, + model: str | None = None, + model_id: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + org_id: str | None = None, + base_url: str | None = None, + azure_endpoint: str | None = None, + api_version: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, **kwargs: Any, ) -> None: """Initialize a raw OpenAI embedding client. Keyword Args: - model: OpenAI embedding model name. + model: Embedding model or Azure OpenAI deployment name. When not provided, the + constructor reads ``OPENAI_EMBEDDING_MODEL`` and then ``OPENAI_MODEL`` + for OpenAI. For Azure it first checks ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` + and then ``AZURE_OPENAI_DEPLOYMENT_NAME``. model_id: Deprecated alias for ``model``. - api_key: OpenAI API key, SecretString, or callable returning a key. - org_id: OpenAI organization ID. - base_url: Custom API base URL. + api_key: API key override. For OpenAI this maps to ``OPENAI_API_KEY``. + For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key auth. + A callable token provider is also accepted for backwards compatibility, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI and resolved from + ``OPENAI_ORG_ID`` when not provided. + base_url: Base URL override. For OpenAI this maps to ``OPENAI_BASE_URL``. + For Azure this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use for Azure requests. When not provided explicitly, + Azure falls back to + ``AZURE_OPENAI_API_VERSION`` and then the embedding default. default_headers: Additional HTTP headers. - async_client: Pre-configured AsyncOpenAI client (skips client creation). - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI. + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. kwargs: Additional keyword arguments forwarded to ``BaseEmbeddingClient``. + + Notes: + Environment resolution precedence is: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + OpenAI reads ``OPENAI_API_KEY``, ``OPENAI_EMBEDDING_MODEL``, + ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure reads + ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME``, + ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. """ if model_id is not None and model is None: import warnings @@ -95,59 +212,40 @@ class RawOpenAIEmbeddingClient( warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) model = model_id - if not async_client: - openai_settings = load_settings( - OpenAISettings, - env_prefix="OPENAI_", - api_key=api_key, - org_id=org_id, - base_url=base_url, - embedding_model=model, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) + settings, client, use_azure_client = load_openai_service_settings( + model=model, + api_key=api_key, + credential=credential, + org_id=org_id, + base_url=base_url, + endpoint=azure_endpoint, + api_version=api_version, + default_azure_api_version=DEFAULT_AZURE_OPENAI_EMBEDDING_API_VERSION, + default_headers=default_headers, + client=async_client, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + openai_model_fields=("embedding_model", "model"), + azure_deployment_fields=("embedding_deployment_name", "deployment_name"), + ) - api_key_value = openai_settings.get("api_key") - resolved_model = openai_settings.get("embedding_model") or model - - # Only create a client when we have enough configuration. - # Subclasses that manage their own client pass no args here - if api_key_value: - if not resolved_model: - raise ValueError( - "OpenAI embedding model is required. " - "Set via 'model' parameter or 'OPENAI_EMBEDDING_MODEL' environment variable." - ) - model = resolved_model - - resolved_api_key = get_api_key(api_key_value) - - # Merge APP_INFO into the headers - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - client_args: dict[str, Any] = {"api_key": resolved_api_key, "default_headers": merged_headers} - if resolved_org_id := openai_settings.get("org_id"): - client_args["organization"] = resolved_org_id - if resolved_base_url := openai_settings.get("base_url"): - client_args["base_url"] = resolved_base_url - - async_client = AsyncOpenAI(**client_args) - - self.client = async_client - self.model: str | None = model.strip() if model else None + self.client = client + resolved_model = settings.get("model") or settings.get("deployment_name") + self.model: str | None = resolved_model.strip() if isinstance(resolved_model, str) and resolved_model else None # Store configuration for serialization - self.org_id = org_id - self.base_url = str(base_url) if base_url else None + self.org_id = settings.get("org_id") + self.base_url = settings.get("base_url") + self.azure_endpoint = settings.get("endpoint") + self.api_version = settings.get("api_version") if default_headers: self.default_headers: dict[str, Any] | None = { k: v for k, v in default_headers.items() if k != USER_AGENT_KEY } else: self.default_headers = None + if use_azure_client: + self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] super().__init__(**kwargs) @@ -225,45 +323,11 @@ class OpenAIEmbeddingClient( RawOpenAIEmbeddingClient[OpenAIEmbeddingOptionsT], Generic[OpenAIEmbeddingOptionsT], ): - """OpenAI embedding client with telemetry support. - - Keyword Args: - model: The embedding model (e.g. "text-embedding-3-small"). - Can also be set via environment variable OPENAI_EMBEDDING_MODEL. - model_id: Deprecated alias for ``model``. - api_key: OpenAI API key. - Can also be set via environment variable OPENAI_API_KEY. - org_id: OpenAI organization ID. - default_headers: Additional HTTP headers. - async_client: Pre-configured AsyncOpenAI client. - base_url: Custom API base URL. - otel_provider_name: Override the OpenTelemetry provider name for telemetry. - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - - Examples: - .. code-block:: python - - from agent_framework.openai import OpenAIEmbeddingClient - - # Using environment variables - # Set OPENAI_API_KEY=sk-... - # Set OPENAI_EMBEDDING_MODEL=text-embedding-3-small - client = OpenAIEmbeddingClient() - - # Or passing parameters directly - client = OpenAIEmbeddingClient( - model="text-embedding-3-small", - api_key="sk-...", - ) - - # Generate embeddings - result = await client.get_embeddings(["Hello, world!"]) - print(result[0].vector) - """ + """OpenAI embedding client with telemetry support.""" OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] + @overload def __init__( self, *, @@ -277,27 +341,165 @@ class OpenAIEmbeddingClient( env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: - """Initialize an OpenAI embedding client.""" + """Initialize an OpenAI embedding client. + + Keyword Args: + model: Embedding model identifier. When not provided, the constructor reads + ``OPENAI_EMBEDDING_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + otel_provider_name: Optional telemetry provider name override. + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + + @overload + def __init__( + self, + *, + model: str | None = None, + azure_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + api_version: str | None = None, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + base_url: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + otel_provider_name: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an OpenAI embedding client. + + Keyword Args: + model: Embedding deployment name. When not provided, the constructor reads + ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the embedding default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, but ``credential`` is the preferred + Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI. + otel_provider_name: Optional telemetry provider name override. + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + + def __init__( + self, + *, + model: str | None = None, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + org_id: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + base_url: str | None = None, + azure_endpoint: str | None = None, + api_version: str | None = None, + otel_provider_name: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an OpenAI embedding client. + + Keyword Args: + model: Embedding model or Azure OpenAI deployment name. When not provided, the + constructor reads ``OPENAI_EMBEDDING_MODEL`` and then ``OPENAI_MODEL`` + for OpenAI. For Azure it first checks ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` + and then ``AZURE_OPENAI_DEPLOYMENT_NAME``. + api_key: API key override. For OpenAI this maps to ``OPENAI_API_KEY``. + For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key auth. + A callable token provider is also accepted for backwards compatibility, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI and resolved from + ``OPENAI_ORG_ID`` when not provided. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI. + base_url: Base URL override. For OpenAI this maps to ``OPENAI_BASE_URL``. + For Azure this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use for Azure requests. When not provided explicitly, + Azure falls back to + ``AZURE_OPENAI_API_VERSION`` and then the embedding default. + otel_provider_name: Override the OpenTelemetry provider name. + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. + + Notes: + Environment resolution precedence is: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + OpenAI reads ``OPENAI_API_KEY``, ``OPENAI_EMBEDDING_MODEL``, + ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure reads + ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME``, + ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + + Examples: + .. code-block:: python + + from agent_framework.openai import OpenAIEmbeddingClient + + # Using environment variables + # Set OPENAI_API_KEY=sk-... + # Set OPENAI_EMBEDDING_MODEL=text-embedding-3-small + client = OpenAIEmbeddingClient() + + # Or passing OpenAI parameters directly + client = OpenAIEmbeddingClient( + model="text-embedding-3-small", + api_key="sk-...", + ) + + # Or using Azure OpenAI with an Azure credential + client = OpenAIEmbeddingClient( + model="text-embedding-3-small", + azure_endpoint="https://example-resource.openai.azure.com/", + credential=my_azure_credential, + ) + """ super().__init__( model=model, api_key=api_key, + credential=credential, org_id=org_id, base_url=base_url, + azure_endpoint=azure_endpoint, + api_version=api_version, default_headers=default_headers, async_client=async_client, + otel_provider_name=otel_provider_name, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) - if otel_provider_name is not None: - self.OTEL_PROVIDER_NAME = otel_provider_name # type: ignore[misc] - - # Validate that the client was created successfully (from explicit args or env vars) - if self.client is None: - raise ValueError( - "OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable." - ) - if not self.model: - raise ValueError( - "OpenAI embedding model is required. " - "Set via 'model' parameter or 'OPENAI_EMBEDDING_MODEL' environment variable." - ) diff --git a/python/packages/openai/agent_framework_openai/_shared.py b/python/packages/openai/agent_framework_openai/_shared.py index c3c280d950..feb30cc7d5 100644 --- a/python/packages/openai/agent_framework_openai/_shared.py +++ b/python/packages/openai/agent_framework_openai/_shared.py @@ -3,19 +3,18 @@ from __future__ import annotations import logging -import os import sys from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence from copy import copy -from typing import Any, ClassVar, Union, cast +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Union, cast import openai from agent_framework._serialization import SerializationMixin from agent_framework._settings import SecretString, load_settings from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent from agent_framework._tools import FunctionTool -from dotenv import dotenv_values -from openai import AsyncOpenAI, AsyncStream, _legacy_response # type: ignore +from agent_framework.exceptions import SettingNotFoundError +from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream, _legacy_response # type: ignore from openai.types import Completion from openai.types.audio import Transcription from openai.types.chat import ChatCompletion, ChatCompletionChunk @@ -24,10 +23,21 @@ from openai.types.responses.response import Response from openai.types.responses.response_stream_event import ResponseStreamEvent from packaging.version import parse +if sys.version_info >= (3, 11): + from typing import TypedDict # type: ignore # pragma: no cover +else: + from typing_extensions import TypedDict # type: ignore # pragma: no cover + +if TYPE_CHECKING: + from azure.core.credentials import TokenCredential + from azure.core.credentials_async import AsyncTokenCredential + + AzureCredentialTypes = TokenCredential | AsyncTokenCredential + + logger: logging.Logger = logging.getLogger("agent_framework.openai") -DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION = "2024-10-21" -DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION = "preview" +AZURE_OPENAI_TOKEN_SCOPE = "https://cognitiveservices.azure.com/.default" # noqa: S105 # nosec B105 RESPONSE_TYPE = Union[ @@ -43,12 +53,7 @@ RESPONSE_TYPE = Union[ _legacy_response.HttpxBinaryResponseContent, ] -OPTION_TYPE = dict[str, Any] - -if sys.version_info >= (3, 11): - from typing import TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover +AzureTokenProvider = Callable[[], str | Awaitable[str]] def _check_openai_version_for_callable_api_key() -> None: @@ -92,6 +97,10 @@ class OpenAISettings(TypedDict, total=False): Can be set via environment variable OPENAI_MODEL. embedding_model: The OpenAI embedding model to use, for example, text-embedding-3-small. Can be set via environment variable OPENAI_EMBEDDING_MODEL. + chat_model: The OpenAI chat-completions model to prefer before OPENAI_MODEL. + Can be set via environment variable OPENAI_CHAT_MODEL. + responses_model: The OpenAI responses model to prefer before OPENAI_MODEL. + Can be set via environment variable OPENAI_RESPONSES_MODEL. Examples: .. code-block:: python @@ -110,122 +119,232 @@ class OpenAISettings(TypedDict, total=False): settings = load_settings(OpenAISettings, env_prefix="OPENAI_", env_file_path="path/to/.env") """ - api_key: SecretString | Callable[[], str | Awaitable[str]] | None + api_key: SecretString | None base_url: str | None org_id: str | None model: str | None embedding_model: str | None - azure_endpoint: str | None + chat_model: str | None + responses_model: str | None + + +class AzureOpenAISettings(TypedDict, total=False): + """Azure OpenAI environment settings.""" + + endpoint: str | None + base_url: str | None + api_key: SecretString | None + deployment_name: str | None + embedding_deployment_name: str | None + chat_deployment_name: str | None + responses_deployment_name: str | None api_version: str | None -def _load_dotenv_values(*, env_file_path: str | None, env_file_encoding: str | None) -> dict[str, str]: - """Load dotenv values for non-standard environment variable aliases.""" - if env_file_path is None or not os.path.exists(env_file_path): - return {} +OpenAIModelSettingName = Literal["model", "embedding_model", "chat_model", "responses_model"] +AzureDeploymentSettingName = Literal[ + "deployment_name", "embedding_deployment_name", "chat_deployment_name", "responses_deployment_name" +] - raw_dotenv_values = dotenv_values(dotenv_path=env_file_path, encoding=env_file_encoding or "utf-8") - return {key: value for key, value in raw_dotenv_values.items() if value is not None} +OPENAI_MODEL_ENV_VARS: dict[OpenAIModelSettingName, str] = { + "model": "OPENAI_MODEL", + "embedding_model": "OPENAI_EMBEDDING_MODEL", + "chat_model": "OPENAI_CHAT_MODEL", + "responses_model": "OPENAI_RESPONSES_MODEL", +} + +AZURE_DEPLOYMENT_ENV_VARS: dict[AzureDeploymentSettingName, str] = { + "deployment_name": "AZURE_OPENAI_DEPLOYMENT_NAME", + "embedding_deployment_name": "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", + "chat_deployment_name": "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", + "responses_deployment_name": "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", +} -def _get_setting_from_alias( - name: str, - *, - dotenv_values_by_name: Mapping[str, str], +def _resolve_named_setting( + settings: Mapping[str, Any], + fields: Sequence[OpenAIModelSettingName | AzureDeploymentSettingName], ) -> str | None: - """Resolve a setting from an explicit env-var alias.""" - if dotenv_value := dotenv_values_by_name.get(name): - return dotenv_value - return os.getenv(name) + """Return the first populated value from ``fields``.""" + for field in fields: + value = settings.get(field) + if isinstance(value, str) and value: + return value + return None + + +def _join_env_names(env_names: Sequence[str]) -> str: + """Format env var names for user-facing error messages.""" + return ", ".join(f"'{env_name}'" for env_name in env_names) def load_openai_service_settings( *, model: str | None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None, + credential: AzureCredentialTypes | AzureTokenProvider | None, org_id: str | None, base_url: str | None, - azure_endpoint: str | None, + endpoint: str | None, api_version: str | None, + default_azure_api_version: str, + default_headers: Mapping[str, str] | None = None, + client: AsyncOpenAI | None = None, env_file_path: str | None, env_file_encoding: str | None, - azure_model_env_vars: Sequence[str], - default_azure_api_version: str, -) -> tuple[OpenAISettings, bool]: + openai_model_fields: Sequence[OpenAIModelSettingName] = ("model",), + azure_deployment_fields: Sequence[AzureDeploymentSettingName] = ("deployment_name",), + responses_mode: bool = False, +) -> tuple[dict[str, Any], AsyncOpenAI, bool]: """Load OpenAI settings, including Azure OpenAI aliases. - The generic OpenAI clients primarily read from ``OPENAI_*`` variables. When an - ``AZURE_OPENAI_ENDPOINT`` (or ``AZURE_OPENAI_BASE_URL``) is available and no - explicit OpenAI base URL is configured, this helper switches to Azure-specific - environment variables for endpoint, API key, model deployment, and API version. + The generic OpenAI clients primarily read from ``OPENAI_*`` variables. Azure-specific + environment variables are used only when an explicit Azure signal is present + (``endpoint`` or ``credential``) or when no explicit + OpenAI API key is available. """ - openai_settings = load_settings( - OpenAISettings, - env_prefix="OPENAI_", - api_key=api_key, - org_id=org_id, - base_url=base_url, - model=model, - azure_endpoint=azure_endpoint, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) + # Merge APP_INFO into the headers + merged_headers = dict(copy(default_headers)) if default_headers else {} + if APP_INFO: + merged_headers.update(APP_INFO) + merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - dotenv_values_by_name = _load_dotenv_values( - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - resolved_azure_endpoint = azure_endpoint - resolved_azure_base_url: str | None = None - if not openai_settings.get("base_url"): - if resolved_azure_endpoint is None: - resolved_azure_endpoint = _get_setting_from_alias( - "AZURE_OPENAI_ENDPOINT", - dotenv_values_by_name=dotenv_values_by_name, - ) - if resolved_azure_endpoint is None: - resolved_azure_base_url = _get_setting_from_alias( - "AZURE_OPENAI_BASE_URL", - dotenv_values_by_name=dotenv_values_by_name, - ) - if resolved_azure_base_url is not None: - openai_settings["base_url"] = resolved_azure_base_url - - use_azure_client = resolved_azure_endpoint is not None or resolved_azure_base_url is not None - if resolved_azure_endpoint is not None: - openai_settings["azure_endpoint"] = resolved_azure_endpoint - - if use_azure_client: - if api_key is None: - resolved_azure_api_key = _get_setting_from_alias( - "AZURE_OPENAI_API_KEY", - dotenv_values_by_name=dotenv_values_by_name, - ) - if resolved_azure_api_key is not None: - openai_settings["api_key"] = SecretString(resolved_azure_api_key) - - if model is None: - for env_var_name in azure_model_env_vars: - resolved_model = _get_setting_from_alias( - env_var_name, - dotenv_values_by_name=dotenv_values_by_name, + api_key_callable = api_key if callable(api_key) else None + api_key_str = api_key if not callable(api_key) else None + azure_client = isinstance(client, AsyncAzureOpenAI) + use_azure = azure_client or endpoint is not None or credential is not None + checked_openai = False + if not use_azure: + openai_settings_kwargs: dict[str, Any] = { + "api_key": api_key_str, + "org_id": org_id, + "base_url": base_url, + "env_file_path": env_file_path, + "env_file_encoding": env_file_encoding, + } + if model is not None: + openai_settings_kwargs[openai_model_fields[0]] = model + openai_settings = load_settings( + OpenAISettings, + env_prefix="OPENAI_", + **openai_settings_kwargs, + ) + if resolved_model := _resolve_named_setting(openai_settings, openai_model_fields): + openai_settings["model"] = resolved_model + if client: + return openai_settings, client, False # type: ignore[return-value] + if openai_settings.get("api_key") is not None or api_key_callable is not None: + resolved_model = _resolve_named_setting(openai_settings, openai_model_fields) + if not resolved_model: + raise SettingNotFoundError( + "Model must be specified via the 'model' parameter or the " + f"{_join_env_names([OPENAI_MODEL_ENV_VARS[field] for field in openai_model_fields])} " + "environment variable." ) - if resolved_model is not None: - openai_settings["model"] = resolved_model - break - if api_version is not None: - openai_settings["api_version"] = api_version - else: - resolved_api_version = _get_setting_from_alias( - "AZURE_OPENAI_API_VERSION", - dotenv_values_by_name=dotenv_values_by_name, + client_args: dict[str, Any] = { + "api_key": api_key_callable + if api_key_callable is not None + else openai_settings["api_key"].get_secret_value(), # type: ignore[reportOptionalMemberAccess, union-attr] + "organization": openai_settings.get("org_id"), + "default_headers": merged_headers, + } + if base_url := openai_settings.get("base_url"): + client_args["base_url"] = base_url + return openai_settings, AsyncOpenAI(**client_args), False # type: ignore[return-value] + checked_openai = True + azure_settings = load_settings( + AzureOpenAISettings, + env_prefix="AZURE_OPENAI_", + required_fields=None if client else [("base_url", "endpoint")], + api_key=api_key_str, + endpoint=endpoint, + base_url=base_url, + api_version=api_version or default_azure_api_version, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + if model is not None: + azure_settings[azure_deployment_fields[0]] = model + client_args = {} + resolved_azure_deployment = _resolve_named_setting(azure_settings, azure_deployment_fields) + if resolved_azure_deployment is None and client: + azure_deployment = getattr(client, "_azure_deployment", None) + if isinstance(azure_deployment, str) and azure_deployment: + resolved_azure_deployment = azure_deployment + if resolved_azure_deployment: + azure_settings["deployment_name"] = resolved_azure_deployment + client_args["azure_deployment"] = resolved_azure_deployment + else: + deployment_env_guidance = _join_env_names([ + AZURE_DEPLOYMENT_ENV_VARS[field] for field in azure_deployment_fields + ]) + has_azure_configuration = ( + client is not None + or azure_settings.get("endpoint") is not None + or azure_settings.get("base_url") is not None + ) + if checked_openai and not has_azure_configuration: + raise SettingNotFoundError( + "OpenAI credentials are required. Provide the 'api_key' parameter or set 'OPENAI_API_KEY'. " + "To use Azure OpenAI instead, pass 'azure_endpoint' or set 'AZURE_OPENAI_ENDPOINT' or " + "'AZURE_OPENAI_BASE_URL'." ) - openai_settings["api_version"] = resolved_api_version or default_azure_api_version + raise SettingNotFoundError( + "Azure OpenAI client requires a deployment name, which can be provided via the 'model' parameter, " + f"or the {deployment_env_guidance} environment variable." + ) + if client: + return azure_settings, client, True # type: ignore[return-value] + client_args["default_headers"] = merged_headers + if endpoint := azure_settings.get("endpoint"): + if responses_mode: + client_args["base_url"] = f"{endpoint.rstrip('/')}/openai/v1/" + else: + client_args["azure_endpoint"] = endpoint + if base_url := azure_settings.get("base_url"): + client_args["base_url"] = base_url + if api_key := azure_settings.get("api_key"): + client_args["api_key"] = api_key.get_secret_value() + if api_key_callable: + client_args["api_key"] = api_key_callable + if api_version := azure_settings.get("api_version"): + client_args["api_version"] = api_version + if credential: + client_args["azure_ad_token_provider"] = _resolve_azure_credential_to_token_provider(credential) + if "api_key" not in client_args and "azure_ad_token_provider" not in client_args: + raise SettingNotFoundError( + "Azure OpenAI client requires either an API key or an Azure AD token provider." + " This can be provided either as a callable api_key or via the credential parameter." + ) + return azure_settings, AsyncAzureOpenAI(**client_args), True # type: ignore[return-value] - return openai_settings, use_azure_client + +def _resolve_azure_credential_to_token_provider( + credential: AzureCredentialTypes | AzureTokenProvider, +) -> AzureTokenProvider: + """Resolve an Azure credential or token provider for Azure OpenAI auth.""" + if callable(credential): + return credential + + try: + from azure.core.credentials import TokenCredential + from azure.core.credentials_async import AsyncTokenCredential + from azure.identity import get_bearer_token_provider + from azure.identity.aio import get_bearer_token_provider as get_async_bearer_token_provider + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + "Azure credential auth requires the 'azure-identity' package. Install it with: pip install azure-identity" + ) from exc + + if isinstance(credential, AsyncTokenCredential): + return get_async_bearer_token_provider(credential, AZURE_OPENAI_TOKEN_SCOPE) + if isinstance(credential, TokenCredential): + return get_bearer_token_provider(credential, AZURE_OPENAI_TOKEN_SCOPE) # type: ignore[arg-type] + raise ValueError( + "The 'credential' parameter must be an Azure TokenCredential, AsyncTokenCredential, or a " + "callable token provider." + ) def maybe_append_azure_endpoint_guidance(message: str, *, azure_endpoint: str | None) -> str: diff --git a/python/packages/openai/tests/openai/conftest.py b/python/packages/openai/tests/openai/conftest.py index 1ef52baf81..34ea878a19 100644 --- a/python/packages/openai/tests/openai/conftest.py +++ b/python/packages/openai/tests/openai/conftest.py @@ -43,6 +43,8 @@ def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # "OPENAI_ORG_ID", "OPENAI_MODEL", "OPENAI_EMBEDDING_MODEL", + "OPENAI_CHAT_MODEL", + "OPENAI_RESPONSES_MODEL", "OPENAI_TEXT_MODEL_ID", "OPENAI_TEXT_TO_IMAGE_MODEL_ID", "OPENAI_AUDIO_TO_TEXT_MODEL_ID", @@ -53,6 +55,9 @@ def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL", "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", + "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", + "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", "AZURE_OPENAI_DEPLOYMENT_NAME", "AZURE_OPENAI_API_VERSION", ], @@ -97,6 +102,8 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic "OPENAI_ORG_ID", "OPENAI_MODEL", "OPENAI_EMBEDDING_MODEL", + "OPENAI_CHAT_MODEL", + "OPENAI_RESPONSES_MODEL", "OPENAI_TEXT_MODEL_ID", "OPENAI_TEXT_TO_IMAGE_MODEL_ID", "OPENAI_AUDIO_TO_TEXT_MODEL_ID", @@ -107,6 +114,9 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL", "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", + "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", + "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", "AZURE_OPENAI_DEPLOYMENT_NAME", "AZURE_OPENAI_API_VERSION", ], @@ -114,6 +124,9 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic env_vars = { "AZURE_OPENAI_ENDPOINT": "https://test-endpoint.openai.azure.com", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "test_chat_deployment", + "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME": "test_responses_deployment", + "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME": "test_embedding_deployment", "AZURE_OPENAI_DEPLOYMENT_NAME": "test_deployment", "AZURE_OPENAI_API_KEY": "test_api_key", "AZURE_OPENAI_API_VERSION": "2024-12-01-preview", diff --git a/python/packages/openai/tests/openai/test_assistant_provider.py b/python/packages/openai/tests/openai/test_assistant_provider.py index c05ea950a6..df811f6c37 100644 --- a/python/packages/openai/tests/openai/test_assistant_provider.py +++ b/python/packages/openai/tests/openai/test_assistant_provider.py @@ -1,6 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -import os from typing import Annotated, Any from unittest.mock import AsyncMock, MagicMock @@ -750,64 +749,3 @@ class TestToolMerging: # endregion - -# region Integration Tests - -skip_if_openai_integration_tests_disabled = pytest.mark.skipif( - os.getenv("OPENAI_API_KEY", "") in ("", "test-dummy-key"), - reason="No real OPENAI_API_KEY provided; skipping integration tests.", -) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_openai_integration_tests_disabled -class TestOpenAIAssistantProviderIntegration: - """Integration tests requiring real OpenAI API.""" - - async def test_create_and_run_agent(self) -> None: - """End-to-end test of creating and running an agent.""" - provider = OpenAIAssistantProvider() - - agent = await provider.create_agent( - name="IntegrationTestAgent", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant. Respond briefly.", - ) - - try: - result = await agent.run("Say 'hello' and nothing else.") - result_text = str(result) - assert "hello" in result_text.lower() - finally: - # Clean up the assistant - await provider._client.beta.assistants.delete(agent.id) # type: ignore[reportPrivateUsage, union-attr] - - async def test_create_agent_with_function_tools_integration(self) -> None: - """Integration test with function tools.""" - provider = OpenAIAssistantProvider() - - @tool(approval_mode="never_require") - def get_current_time() -> str: - """Get the current time.""" - from datetime import datetime - - return datetime.now().strftime("%H:%M") - - agent = await provider.create_agent( - name="TimeAgent", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant.", - tools=[get_current_time], - ) - - try: - result = await agent.run("What time is it? Use the get_current_time function.") - result_text = str(result) - # The response should contain time information - assert ":" in result_text or "time" in result_text.lower() - finally: - await provider._client.beta.assistants.delete(agent.id) # type: ignore[reportPrivateUsage, union-attr] - - -# endregion diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 897fe5f913..3c09839594 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -28,6 +28,7 @@ from agent_framework._sessions import ( from agent_framework.exceptions import ( ChatClientException, ChatClientInvalidRequestException, + SettingNotFoundError, ) from openai import BadRequestError from openai.types.responses.response_reasoning_item import Summary @@ -109,6 +110,14 @@ def test_init(openai_unit_test_env: dict[str, str]) -> None: assert isinstance(openai_responses_client, SupportsChatGetResponse) +def test_init_prefers_openai_responses_model(monkeypatch, openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_RESPONSES_MODEL", "test_responses_model_id") + + openai_responses_client = OpenAIChatClient() + + assert openai_responses_client.model == "test_responses_model_id" + + def test_init_validation_fail() -> None: # Test successful initialization with pytest.raises(ValueError): @@ -143,7 +152,7 @@ def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None: @pytest.mark.parametrize("exclude_list", [["OPENAI_MODEL"]], indirect=True) def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: - with pytest.raises(ValueError): + with pytest.raises(SettingNotFoundError): OpenAIChatClient() @@ -151,7 +160,7 @@ def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None: model_id = "test_model_id" - with pytest.raises(ValueError): + with pytest.raises(SettingNotFoundError): OpenAIChatClient( model=model_id, ) @@ -203,34 +212,56 @@ async def test_get_response_with_invalid_input() -> None: async def test_get_response_with_all_parameters() -> None: - """Test get_response with all possible parameters to cover parameter handling logic.""" + """Test request preparation with a comprehensive parameter set.""" client = OpenAIChatClient(model="test-model", api_key="test-key") - # Test with comprehensive parameter set - should fail due to invalid API key - with pytest.raises(ChatClientException): - await client.get_response( - messages=[Message(role="user", text="Test message")], - options={ - "include": ["message.output_text.logprobs"], - "instructions": "You are a helpful assistant", - "max_tokens": 100, - "parallel_tool_calls": True, - "model": "gpt-4", - "previous_response_id": "prev-123", - "reasoning": {"chain_of_thought": "enabled"}, - "service_tier": "auto", - "response_format": OutputStruct, - "seed": 42, - "store": True, - "temperature": 0.7, - "tool_choice": "auto", - "tools": [get_weather], - "top_p": 0.9, - "user": "test-user", - "truncation": "auto", - "timeout": 30.0, - "additional_properties": {"custom": "value"}, - }, - ) + _, run_options, _ = await client._prepare_request( + messages=[Message(role="user", text="Test message")], + options={ + "include": ["message.output_text.logprobs"], + "instructions": "You are a helpful assistant", + "max_tokens": 100, + "parallel_tool_calls": True, + "model": "gpt-4", + "previous_response_id": "prev-123", + "reasoning": {"chain_of_thought": "enabled"}, + "service_tier": "auto", + "response_format": OutputStruct, + "seed": 42, + "store": True, + "temperature": 0.7, + "tool_choice": "auto", + "tools": [get_weather], + "top_p": 0.9, + "user": "test-user", + "truncation": "auto", + "timeout": 30.0, + "additional_properties": {"custom": "value"}, + }, + ) + + assert run_options["include"] == ["message.output_text.logprobs"] + assert run_options["max_output_tokens"] == 100 + assert run_options["parallel_tool_calls"] is True + assert run_options["model"] == "gpt-4" + assert run_options["previous_response_id"] == "prev-123" + assert run_options["reasoning"] == {"chain_of_thought": "enabled"} + assert run_options["service_tier"] == "auto" + assert run_options["text_format"] is OutputStruct + assert run_options["store"] is True + assert run_options["temperature"] == 0.7 + assert run_options["tool_choice"] == "auto" + assert run_options["top_p"] == 0.9 + assert run_options["user"] == "test-user" + assert run_options["truncation"] == "auto" + assert run_options["timeout"] == 30.0 + assert run_options["additional_properties"] == {"custom": "value"} + assert len(run_options["tools"]) == 1 + assert run_options["tools"][0]["type"] == "function" + assert run_options["tools"][0]["name"] == "get_weather" + assert run_options["input"][0]["role"] == "system" + assert run_options["input"][0]["content"][0]["text"] == "You are a helpful assistant" + assert run_options["input"][1]["role"] == "user" + assert run_options["input"][1]["content"][0]["text"] == "Test message" @pytest.mark.asyncio @@ -248,12 +279,13 @@ async def test_web_search_tool_with_location() -> None: } ) - # Should raise an authentication error due to invalid API key - with pytest.raises(ChatClientException): - await client.get_response( - messages=[Message(role="user", text="What's the weather?")], - options={"tools": [web_search_tool], "tool_choice": "auto"}, - ) + _, run_options, _ = await client._prepare_request( + messages=[Message(role="user", text="What's the weather?")], + options={"tools": [web_search_tool], "tool_choice": "auto"}, + ) + + assert run_options["tools"] == [web_search_tool] + assert run_options["tool_choice"] == "auto" async def test_code_interpreter_tool_variations() -> None: @@ -263,20 +295,22 @@ async def test_code_interpreter_tool_variations() -> None: # Test code interpreter using static method code_tool = OpenAIChatClient.get_code_interpreter_tool() - with pytest.raises(ChatClientException): - await client.get_response( - messages=[Message("user", ["Run some code"])], - options={"tools": [code_tool]}, - ) + _, run_options, _ = await client._prepare_request( + messages=[Message("user", ["Run some code"])], + options={"tools": [code_tool]}, + ) + + assert run_options["tools"] == [code_tool] # Test code interpreter with files using static method code_tool_with_files = OpenAIChatClient.get_code_interpreter_tool(file_ids=["file1", "file2"]) - with pytest.raises(ChatClientException): - await client.get_response( - messages=[Message(role="user", text="Process these files")], - options={"tools": [code_tool_with_files]}, - ) + _, run_options, _ = await client._prepare_request( + messages=[Message(role="user", text="Process these files")], + options={"tools": [code_tool_with_files]}, + ) + + assert run_options["tools"] == [code_tool_with_files] async def test_content_filter_exception() -> None: @@ -300,23 +334,23 @@ async def test_content_filter_exception() -> None: @pytest.mark.asyncio async def test_hosted_file_search_tool_validation() -> None: - """Test get_response HostedFileSearchTool validation.""" + """Test HostedFileSearchTool validation and request preparation.""" client = OpenAIChatClient(model="test-model", api_key="test-key") # Test file search tool with vector store IDs file_search_tool = OpenAIChatClient.get_file_search_tool(vector_store_ids=["vs_123"]) - # Test using file search tool - may raise various exceptions depending on API response - with pytest.raises((ValueError, ChatClientInvalidRequestException, ChatClientException)): - await client.get_response( - messages=[Message("user", ["Test"])], - options={"tools": [file_search_tool]}, - ) + _, run_options, _ = await client._prepare_request( + messages=[Message("user", ["Test"])], + options={"tools": [file_search_tool]}, + ) + + assert run_options["tools"] == [file_search_tool] async def test_chat_message_parsing_with_function_calls() -> None: - """Test get_response message preparation with function call and result content types in conversation flow.""" + """Test message preparation with function call and function result content.""" client = OpenAIChatClient(model="test-model", api_key="test-key") # Create messages with function call and result content @@ -335,9 +369,27 @@ async def test_chat_message_parsing_with_function_calls() -> None: Message(role="tool", contents=[function_result]), ] - # This should exercise the message parsing logic - will fail due to invalid API key - with pytest.raises(ChatClientException): - await client.get_response(messages=messages) + prepared_messages = client._prepare_messages_for_openai(messages) + + assert prepared_messages == [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Call a function"}], + }, + { + "call_id": "test-call-id", + "id": "fc_test-fc-id", + "type": "function_call", + "name": "test_function", + "arguments": '{"param": "value"}', + }, + { + "call_id": "test-call-id", + "type": "function_call_output", + "output": "Function executed successfully", + }, + ] async def test_response_format_parse_path() -> None: @@ -3043,8 +3095,6 @@ def test_with_callable_api_key() -> None: "option_name,option_value,needs_validation", [ # Simple ChatOptions - just verify they don't fail - param("temperature", 0.7, False, id="temperature"), - param("top_p", 0.9, False, id="top_p"), param("max_tokens", 500, False, id="max_tokens"), param("seed", 123, False, id="seed"), param("user", "test-user-id", False, id="user"), @@ -3057,7 +3107,6 @@ def test_with_callable_api_key() -> None: # OpenAIChatOptions - just verify they don't fail param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"), param("truncation", "auto", False, id="truncation"), - param("top_logprobs", 5, False, id="top_logprobs"), param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"), param("max_tool_calls", 3, False, id="max_tool_calls"), # Complex options requiring output validation @@ -3113,70 +3162,56 @@ async def test_integration_options( they don't cause failures. Options marked with needs_validation also check that the feature actually works correctly. """ - openai_responses_client = OpenAIChatClient() + client = OpenAIChatClient() # Need at least 2 iterations for tool_choice tests: one to get function call, one to get final response - openai_responses_client.function_invocation_configuration["max_iterations"] = 2 + client.function_invocation_configuration["max_iterations"] = 2 - for streaming in [False, True]: - # Prepare test message + # Prepare test message + if option_name.startswith("tools") or option_name.startswith("tool_choice"): + # Use weather-related prompt for tool tests + messages = [Message(role="user", text="What is the weather in Seattle?")] + elif option_name.startswith("response_format"): + # Use prompt that works well with structured output + messages = [Message(role="user", text="The weather in Seattle is sunny")] + messages.append(Message(role="user", text="What is the weather in Seattle?")) + else: + # Generic prompt for simple options + messages = [Message(role="user", text="Say 'Hello World' briefly.")] + + # Build options dict + options: dict[str, Any] = {option_name: option_value} + + # Add tools if testing tool_choice to avoid errors + if option_name.startswith("tool_choice"): + options["tools"] = [get_weather] + + # Test streaming mode + response = await client.get_response(stream=True, messages=messages, options=options).get_final_response() + + assert response is not None + assert isinstance(response, ChatResponse) + assert response.text is not None, f"No text in response for option '{option_name}'" + assert len(response.text) > 0, f"Empty response for option '{option_name}'" + + # Validate based on option type + if needs_validation: if option_name.startswith("tools") or option_name.startswith("tool_choice"): - # Use weather-related prompt for tool tests - messages = [Message(role="user", text="What is the weather in Seattle?")] + # Should have called the weather function + text = response.text.lower() + assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" elif option_name.startswith("response_format"): - # Use prompt that works well with structured output - messages = [Message(role="user", text="The weather in Seattle is sunny")] - messages.append(Message(role="user", text="What is the weather in Seattle?")) - else: - # Generic prompt for simple options - messages = [Message(role="user", text="Say 'Hello World' briefly.")] - - # Build options dict - options: dict[str, Any] = {option_name: option_value} - - # Add tools if testing tool_choice to avoid errors - if option_name.startswith("tool_choice"): - options["tools"] = [get_weather] - - if streaming: - # Test streaming mode - response_stream = openai_responses_client.get_response( - stream=True, - messages=messages, - options=options, - ) - - response = await response_stream.get_final_response() - else: - # Test non-streaming mode - response = await openai_responses_client.get_response( - messages=messages, - options=options, - ) - - assert response is not None - assert isinstance(response, ChatResponse) - assert response.text is not None, f"No text in response for option '{option_name}'" - assert len(response.text) > 0, f"Empty response for option '{option_name}'" - - # Validate based on option type - if needs_validation: - if option_name.startswith("tools") or option_name.startswith("tool_choice"): - # Should have called the weather function - text = response.text.lower() - assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" - elif option_name.startswith("response_format"): - if option_value == OutputStruct: - # Should have structured output - assert response.value is not None, "No structured output" - assert isinstance(response.value, OutputStruct) - assert "seattle" in response.value.location.lower() - else: - # Runtime JSON schema - assert response.value is None, "No structured output, can't parse any json." - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value - assert "seattle" in response_value["location"].lower() + if option_value == OutputStruct: + # Should have structured output + assert response.value is not None, "No structured output" + assert isinstance(response.value, OutputStruct) + assert "seattle" in response.value.location.lower() + else: + # Runtime JSON schema + assert response.value is None, "No structured output, can't parse any json." + response_value = json.loads(response.text) + assert isinstance(response_value, dict) + assert "location" in response_value + assert "seattle" in response_value["location"].lower() @pytest.mark.timeout(300) @@ -3186,53 +3221,24 @@ async def test_integration_options( async def test_integration_web_search() -> None: client = OpenAIChatClient(model="gpt-5") - for streaming in [False, True]: - # Use static method for web search tool - web_search_tool = OpenAIChatClient.get_web_search_tool() - content = { - "messages": [ - Message( - role="user", - text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [web_search_tool], - }, - } - if streaming: - response = await client.get_response(stream=True, **content).get_final_response() - else: - response = await client.get_response(**content) - - assert response is not None - assert isinstance(response, ChatResponse) - assert "Rumi" in response.text - assert "Mira" in response.text - assert "Zoey" in response.text - - # Test that the client will use the web search tool with location - web_search_tool_with_location = OpenAIChatClient.get_web_search_tool( - user_location={"country": "US", "city": "Seattle"}, - ) - content = { - "messages": [ - Message( - role="user", - text="What is the current weather? Do not ask for my current location.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [web_search_tool_with_location], - }, - } - if streaming: - response = await client.get_response(stream=True, **content).get_final_response() - else: - response = await client.get_response(**content) - assert response.text is not None + # Test that the client will use the web search tool with location + web_search_tool_with_location = OpenAIChatClient.get_web_search_tool( + user_location={"country": "US", "city": "Seattle"}, + ) + content = { + "messages": [ + Message( + role="user", + text="What is the current weather? Do not ask for my current location.", + ) + ], + "options": { + "tool_choice": "auto", + "tools": [web_search_tool_with_location], + }, + } + response = await client.get_response(stream=True, **content).get_final_response() + assert response.text is not None @pytest.mark.skip( @@ -3351,7 +3357,6 @@ async def test_integration_tool_rich_content_image() -> None: assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}" -@pytest.mark.timeout(300) @pytest.mark.flaky @pytest.mark.integration @skip_if_openai_integration_tests_disabled @@ -3363,14 +3368,11 @@ async def test_integration_agent_replays_local_tool_history_without_stale_fc_id( async def search_hotels(city: Annotated[str, "The city to search for hotels in"]) -> str: return f"The only hotel option in {city} is {hotel_code}." - client = OpenAIChatClient() + # override with model that does not do reasoning by default + client = OpenAIChatClient(model="gpt-5.4") client.function_invocation_configuration["max_iterations"] = 2 - agent = Agent( - client=client, - tools=[search_hotels], - default_options={"store": False}, - ) + agent = Agent(client=client, tools=[search_hotels], default_options={"store": False}) session = agent.create_session() first_response = await agent.run( diff --git a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py index 8646f2d959..918fe98767 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py @@ -4,12 +4,16 @@ from __future__ import annotations import json import os +from functools import wraps from pathlib import Path from typing import Any +from unittest.mock import MagicMock, patch import pytest from agent_framework import Agent, AgentResponse, ChatResponse, Content, Message, SupportsChatGetResponse, tool -from azure.identity.aio import AzureCliCredential, get_bearer_token_provider +from agent_framework.exceptions import SettingNotFoundError +from azure.core.credentials_async import AsyncTokenCredential +from azure.identity.aio import AzureCliCredential from openai import AsyncAzureOpenAI from pydantic import BaseModel from pytest import param @@ -20,11 +24,40 @@ pytestmark = pytest.mark.azure skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.openai.azure.com") - or os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "", + or ( + os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "") == "" + and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" + ), reason="No real Azure OpenAI endpoint or responses deployment provided; skipping integration tests.", ) +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as exc: + model = os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME") or os.getenv( + "AZURE_OPENAI_DEPLOYMENT_NAME", "" + ) + api_version = os.getenv("AZURE_OPENAI_API_VERSION") or "preview" + endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") + debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + class OutputStruct(BaseModel): """A structured output for testing purposes.""" @@ -32,18 +65,6 @@ class OutputStruct(BaseModel): weather: str | None = None -def _create_azure_openai_chat_client( - *, - api_key: Any = None, -) -> OpenAIChatClient: - return OpenAIChatClient( - model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], - api_key=api_key or os.environ["AZURE_OPENAI_API_KEY"], - azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], - api_version=os.getenv("AZURE_OPENAI_API_VERSION"), - ) - - async def create_vector_store(client: OpenAIChatClient) -> tuple[str, Content]: """Create a vector store with sample documents for testing.""" file = await client.client.files.create( @@ -79,30 +100,117 @@ async def get_weather(location: str) -> str: def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: - client = _create_azure_openai_chat_client() + client = OpenAIChatClient(credential=AzureCliCredential()) - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] assert isinstance(client, SupportsChatGetResponse) assert isinstance(client.client, AsyncAzureOpenAI) assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" - assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] - assert client.api_version == azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"] + assert client.azure_endpoint.startswith(azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"]) def test_init_auto_detects_azure_env(azure_openai_unit_test_env: dict[str, str]) -> None: client = OpenAIChatClient() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True) -def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None: - client = _create_azure_openai_chat_client() +def test_openai_api_key_wins_over_azure_env(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + client = OpenAIChatClient() + + assert client.model == "gpt-5" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +def test_api_version_alone_does_not_override_openai_api_key( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + client = OpenAIChatClient(api_version="2024-10-21") + + assert client.model == "gpt-5" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + client = OpenAIChatClient(credential=lambda: "token") + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + + +def test_init_falls_back_to_generic_azure_deployment_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", raising=False) + + client = OpenAIChatClient() assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] - assert client.api_version == "preview" + assert isinstance(client.client, AsyncAzureOpenAI) + + +def test_init_does_not_fall_back_to_openai_responses_model_for_azure_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.setenv("OPENAI_RESPONSES_MODEL", "test_responses_model") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + OpenAIChatClient() + + +def test_init_does_not_fall_back_to_openai_model_for_azure_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("OPENAI_RESPONSES_MODEL", raising=False) + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + OpenAIChatClient() + + +def test_init_with_credential_wraps_async_token_credential( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + class TestAsyncTokenCredential(AsyncTokenCredential): + async def get_token(self, *scopes: str, **kwargs: object): + raise NotImplementedError + + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + credential = TestAsyncTokenCredential() + token_provider = MagicMock() + + with patch("azure.identity.aio.get_bearer_token_provider", return_value=token_provider) as mock_provider: + client = OpenAIChatClient(credential=credential) + + assert isinstance(client.client, AsyncAzureOpenAI) + mock_provider.assert_called_once_with(credential, "https://cognitiveservices.azure.com/.default") + + +@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True) +def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None: + client = OpenAIChatClient(credential=AzureCliCredential()) + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert client.api_version is not None def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: @@ -123,8 +231,6 @@ def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_ @pytest.mark.parametrize( "option_name,option_value,needs_validation", [ - param("temperature", 0.7, False, id="temperature"), - param("top_p", 0.9, False, id="top_p"), param("max_tokens", 500, False, id="max_tokens"), param("seed", 123, False, id="seed"), param("user", "test-user-id", False, id="user"), @@ -136,7 +242,6 @@ def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_ param("tool_choice", "none", True, id="tool_choice_none"), param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"), param("truncation", "auto", False, id="truncation"), - param("top_logprobs", 5, False, id="top_logprobs"), param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"), param("max_tool_calls", 3, False, id="max_tool_calls"), param("tools", [get_weather], True, id="tools_function"), @@ -174,15 +279,14 @@ def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_ ), ], ) +@_with_azure_openai_debug() async def test_integration_options( option_name: str, option_value: Any, needs_validation: bool, ) -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) client.function_invocation_configuration["max_iterations"] = 2 for streaming in [False, True]: @@ -233,64 +337,34 @@ async def test_integration_options( @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_web_search() -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) - for streaming in [False, True]: - content = { - "messages": [ - Message( - role="user", - text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [OpenAIChatClient.get_web_search_tool()], - }, - "stream": streaming, - } - if streaming: - response = await client.get_response(**content).get_final_response() - else: - response = await client.get_response(**content) - - assert isinstance(response, ChatResponse) - assert "Rumi" in response.text - assert "Mira" in response.text - assert "Zoey" in response.text - - content = { - "messages": [ - Message( - role="user", - text="What is the current weather? Do not ask for my current location.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [OpenAIChatClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"})], - }, - "stream": streaming, - } - if streaming: - response = await client.get_response(**content).get_final_response() - else: - response = await client.get_response(**content) - assert response.text is not None + response = await client.get_response( + messages=[ + Message( + role="user", + text="What is the current weather? Do not ask for my current location.", + ) + ], + options={ + "tools": [OpenAIChatClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"})], + }, + stream=True, + ).get_final_response() + assert isinstance(response, ChatResponse) + assert response.text is not None @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_file_search() -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) file_id, vector_store = await create_vector_store(client) try: response = await client.get_response( @@ -310,11 +384,10 @@ async def test_integration_client_file_search() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_file_search_streaming() -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) file_id, vector_store = await create_vector_store(client) try: response_stream = client.get_response( @@ -336,11 +409,10 @@ async def test_integration_client_file_search_streaming() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_hosted_mcp_tool() -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) response = await client.get_response( messages=[Message(role="user", text="How to create an Azure storage account using az cli?")], options={ @@ -361,11 +433,10 @@ async def test_integration_client_agent_hosted_mcp_tool() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_hosted_code_interpreter_tool() -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) response = await client.get_response( messages=[Message(role="user", text="Calculate the sum of numbers from 1 to 10 using Python code.")], @@ -381,14 +452,13 @@ async def test_integration_client_agent_hosted_code_interpreter_tool() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_existing_session() -> None: async with AzureCliCredential() as credential: preserved_session = None async with Agent( - client=_create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as first_agent: session = first_agent.create_session() @@ -403,9 +473,7 @@ async def test_integration_client_agent_existing_session() -> None: if preserved_session: async with Agent( - client=_create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as second_agent: second_response = await second_agent.run("What is my hobby?", session=preserved_session) @@ -418,6 +486,7 @@ async def test_integration_client_agent_existing_session() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_tool_rich_content_image() -> None: image_path = Path(__file__).parent.parent / "assets" / "sample_image.jpg" image_bytes = image_path.read_bytes() @@ -428,9 +497,7 @@ async def test_azure_openai_chat_client_tool_rich_content_image() -> None: return Content.from_data(data=image_bytes, media_type="image/jpeg") async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) client.function_invocation_configuration["max_iterations"] = 2 for streaming in [False, True]: diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index 391432958f..deee60ac7a 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -13,7 +13,7 @@ from agent_framework import ( SupportsChatGetResponse, tool, ) -from agent_framework.exceptions import ChatClientException +from agent_framework.exceptions import ChatClientException, SettingNotFoundError from openai import BadRequestError from openai.types.chat.chat_completion import ChatCompletion, Choice from openai.types.chat.chat_completion_message import ChatCompletionMessage @@ -37,6 +37,14 @@ def test_init(openai_unit_test_env: dict[str, str]) -> None: assert isinstance(open_ai_chat_completion, SupportsChatGetResponse) +def test_init_prefers_openai_chat_model(monkeypatch, openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_CHAT_MODEL", "test_chat_model_id") + + open_ai_chat_completion = OpenAIChatCompletionClient() + + assert open_ai_chat_completion.model == "test_chat_model_id" + + def test_init_validation_fail() -> None: # Test successful initialization with pytest.raises(ValueError): @@ -93,7 +101,7 @@ def test_init_base_url_from_settings_env() -> None: @pytest.mark.parametrize("exclude_list", [["OPENAI_MODEL"]], indirect=True) def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: - with pytest.raises(ValueError): + with pytest.raises(SettingNotFoundError): OpenAIChatCompletionClient() @@ -101,7 +109,7 @@ def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None: model_id = "test_model_id" - with pytest.raises(ValueError): + with pytest.raises(SettingNotFoundError): OpenAIChatCompletionClient( model=model_id, ) @@ -1480,71 +1488,61 @@ async def test_integration_options( # Need at least 2 iterations for tool_choice tests: one to get function call, one to get final response client.function_invocation_configuration["max_iterations"] = 2 - for streaming in [False, True]: - # Prepare test message + # Prepare test message + if option_name.startswith("tools") or option_name.startswith("tool_choice"): + # Use weather-related prompt for tool tests + messages = [Message(role="user", text="What is the weather in Seattle?")] + elif option_name.startswith("response_format"): + # Use prompt that works well with structured output + messages = [Message(role="user", text="The weather in Seattle is sunny")] + messages.append(Message(role="user", text="What is the weather in Seattle?")) + else: + # Generic prompt for simple options + messages = [Message(role="user", text="Say 'Hello World' briefly.")] + + # Build options dict + options: dict[str, Any] = {option_name: option_value} + + # Add tools if testing tool_choice to avoid errors + if option_name.startswith("tool_choice"): + options["tools"] = [get_weather] + + # Test streaming mode + response = await client.get_response( + messages=messages, + stream=True, + options=options, + ).get_final_response() + + assert response is not None + assert isinstance(response, ChatResponse) + assert response.messages is not None + if not option_name.startswith("tool_choice") and ( + (isinstance(option_value, str) and option_value != "required") + or (isinstance(option_value, dict) and option_value.get("mode") != "required") + ): + assert response.text is not None, f"No text in response for option '{option_name}'" + assert len(response.text) > 0, f"Empty response for option '{option_name}'" + + # Validate based on option type + if needs_validation: if option_name.startswith("tools") or option_name.startswith("tool_choice"): - # Use weather-related prompt for tool tests - messages = [Message(role="user", text="What is the weather in Seattle?")] + # Should have called the weather function + text = response.text.lower() + assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" elif option_name.startswith("response_format"): - # Use prompt that works well with structured output - messages = [Message(role="user", text="The weather in Seattle is sunny")] - messages.append(Message(role="user", text="What is the weather in Seattle?")) - else: - # Generic prompt for simple options - messages = [Message(role="user", text="Say 'Hello World' briefly.")] - - # Build options dict - options: dict[str, Any] = {option_name: option_value} - - # Add tools if testing tool_choice to avoid errors - if option_name.startswith("tool_choice"): - options["tools"] = [get_weather] - - if streaming: - # Test streaming mode - response_stream = client.get_response( - messages=messages, - stream=True, - options=options, - ) - - response = await response_stream.get_final_response() - else: - # Test non-streaming mode - response = await client.get_response( - messages=messages, - options=options, - ) - - assert response is not None - assert isinstance(response, ChatResponse) - assert response.messages is not None - if not option_name.startswith("tool_choice") and ( - (isinstance(option_value, str) and option_value != "required") - or (isinstance(option_value, dict) and option_value.get("mode") != "required") - ): - assert response.text is not None, f"No text in response for option '{option_name}'" - assert len(response.text) > 0, f"Empty response for option '{option_name}'" - - # Validate based on option type - if needs_validation: - if option_name.startswith("tools") or option_name.startswith("tool_choice"): - # Should have called the weather function - text = response.text.lower() - assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" - elif option_name.startswith("response_format"): - if option_value == OutputStruct: - # Should have structured output - assert response.value is not None, "No structured output" - assert isinstance(response.value, OutputStruct) - assert "seattle" in response.value.location.lower() - else: - # Runtime JSON schema - assert response.value is None, "No structured output, can't parse any json." - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value - assert "seattle" in response_value["location"].lower() + if option_value == OutputStruct: + # Should have structured output + assert response.value is not None, "No structured output" + assert isinstance(response.value, OutputStruct) + assert "seattle" in response.value.location.lower() + else: + # Runtime JSON schema + assert response.value is None, "No structured output, can't parse any json." + response_value = json.loads(response.text) + assert isinstance(response_value, dict) + assert "location" in response_value + assert "seattle" in response_value["location"].lower() @pytest.mark.flaky diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py index 148fcb68b9..f9edab227a 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py @@ -3,7 +3,9 @@ from __future__ import annotations import os -from collections.abc import Awaitable, Callable +from functools import wraps +from typing import Any +from unittest.mock import MagicMock, patch import pytest from agent_framework import ( @@ -16,7 +18,9 @@ from agent_framework import ( SupportsChatGetResponse, tool, ) -from azure.identity.aio import AzureCliCredential, get_bearer_token_provider +from agent_framework.exceptions import SettingNotFoundError +from azure.core.credentials_async import AsyncTokenCredential +from azure.identity.aio import AzureCliCredential from openai import AsyncAzureOpenAI from agent_framework_openai import OpenAIChatCompletionClient @@ -25,21 +29,37 @@ pytestmark = pytest.mark.azure skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.openai.azure.com") - or os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "", + or ( + os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", "") == "" and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" + ), reason="No real Azure OpenAI endpoint or chat deployment provided; skipping integration tests.", ) -def _create_azure_chat_completion_client( - *, - api_key: str | Callable[[], str | Awaitable[str]] | None = None, -) -> OpenAIChatCompletionClient: - return OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], - api_key=api_key or os.environ["AZURE_OPENAI_API_KEY"], - azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], - api_version=os.getenv("AZURE_OPENAI_API_VERSION"), - ) +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as exc: + model = os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME") or os.getenv( + "AZURE_OPENAI_DEPLOYMENT_NAME", "" + ) + api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") + endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") + debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator @tool(approval_mode="never_require") @@ -60,9 +80,9 @@ async def get_weather(location: str) -> str: def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: - client = _create_azure_chat_completion_client() + client = OpenAIChatCompletionClient(azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT")) - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] assert isinstance(client, SupportsChatGetResponse) assert isinstance(client.client, AsyncAzureOpenAI) assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" @@ -73,18 +93,86 @@ def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> def test_init_auto_detects_azure_env(azure_openai_unit_test_env: dict[str, str]) -> None: client = OpenAIChatCompletionClient() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True) -def test_init_uses_default_azure_api_version(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: - monkeypatch.setenv("OPENAI_API_VERSION", "preview") - client = _create_azure_chat_completion_client() +def test_openai_api_key_wins_over_azure_env(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + client = OpenAIChatCompletionClient() + + assert client.model == "gpt-5" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + client = OpenAIChatCompletionClient(credential=lambda: "token") + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + + +def test_init_falls_back_to_generic_azure_deployment_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", raising=False) + + client = OpenAIChatCompletionClient() assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] - assert client.api_version == "2024-10-21" + assert isinstance(client.client, AsyncAzureOpenAI) + + +def test_init_does_not_fall_back_to_openai_chat_model_for_azure_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.setenv("OPENAI_CHAT_MODEL", "test_chat_model") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + OpenAIChatCompletionClient() + + +def test_init_does_not_fall_back_to_openai_model_for_azure_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("OPENAI_CHAT_MODEL", raising=False) + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + OpenAIChatCompletionClient() + + +def test_init_with_credential_wraps_async_token_credential( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False) + + class TestAsyncTokenCredential(AsyncTokenCredential): + async def get_token(self, *scopes: str, **kwargs: object): + raise NotImplementedError + + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + credential = TestAsyncTokenCredential() + token_provider = MagicMock() + + with patch("azure.identity.aio.get_bearer_token_provider", return_value=token_provider) as mock_provider: + client = OpenAIChatCompletionClient(credential=credential) + + assert isinstance(client.client, AsyncAzureOpenAI) + mock_provider.assert_called_once_with(credential, "https://cognitiveservices.azure.com/.default") def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: @@ -102,11 +190,10 @@ def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_ @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_response() -> None: async with AzureCliCredential() as credential: - client = _create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatCompletionClient(credential=credential) assert isinstance(client, SupportsChatGetResponse) messages = [ @@ -134,11 +221,10 @@ async def test_azure_openai_chat_completion_client_response() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_response_tools() -> None: async with AzureCliCredential() as credential: - client = _create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatCompletionClient(credential=credential) response = await client.get_response( messages=[Message(role="user", text="who are Emily and David?")], @@ -153,11 +239,10 @@ async def test_azure_openai_chat_completion_client_response_tools() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_streaming() -> None: async with AzureCliCredential() as credential: - client = _create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatCompletionClient(credential=credential) response = client.get_response( messages=[ @@ -190,11 +275,10 @@ async def test_azure_openai_chat_completion_client_streaming() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_streaming_tools() -> None: async with AzureCliCredential() as credential: - client = _create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatCompletionClient(credential=credential) response = client.get_response( messages=[Message(role="user", text="who are Emily and David?")], @@ -215,13 +299,12 @@ async def test_azure_openai_chat_completion_client_streaming_tools() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_agent_basic_run() -> None: async with ( AzureCliCredential() as credential, Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatCompletionClient(credential=credential), ) as agent, ): response = await agent.run("Please respond with exactly: 'This is a response test.'") @@ -234,20 +317,14 @@ async def test_azure_openai_chat_completion_client_agent_basic_run() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_agent_basic_run_streaming() -> None: async with ( AzureCliCredential() as credential, - Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), - ) as agent, + Agent(client=OpenAIChatCompletionClient(credential=credential)) as agent, ): full_text = "" - async for chunk in agent.run( - "Please respond with exactly: 'This is a streaming response test.'", - stream=True, - ): + async for chunk in agent.run("Please respond with exactly: 'This is a streaming response test.'", stream=True): assert isinstance(chunk, AgentResponseUpdate) if chunk.text: full_text += chunk.text @@ -258,13 +335,12 @@ async def test_azure_openai_chat_completion_client_agent_basic_run_streaming() - @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_agent_session_persistence() -> None: async with ( AzureCliCredential() as credential, Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatCompletionClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as agent, ): @@ -281,14 +357,13 @@ async def test_azure_openai_chat_completion_client_agent_session_persistence() - @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_agent_existing_session() -> None: async with AzureCliCredential() as credential: preserved_session = None async with Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatCompletionClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as first_agent: session = first_agent.create_session() @@ -299,9 +374,7 @@ async def test_azure_openai_chat_completion_client_agent_existing_session() -> N if preserved_session: async with Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatCompletionClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as second_agent: second_response = await second_agent.run("What is my name?", session=preserved_session) @@ -314,13 +387,12 @@ async def test_azure_openai_chat_completion_client_agent_existing_session() -> N @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_chat_completion_client_agent_level_tool_persistence() -> None: async with ( AzureCliCredential() as credential, Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatCompletionClient(credential=credential), instructions="You are a helpful assistant that uses available tools.", tools=[get_weather], ) as agent, diff --git a/python/packages/openai/tests/openai/test_openai_embedding_client.py b/python/packages/openai/tests/openai/test_openai_embedding_client.py index 7117040ffc..4ef39697d6 100644 --- a/python/packages/openai/tests/openai/test_openai_embedding_client.py +++ b/python/packages/openai/tests/openai/test_openai_embedding_client.py @@ -6,6 +6,7 @@ import os from unittest.mock import AsyncMock, MagicMock import pytest +from agent_framework.exceptions import SettingNotFoundError from openai.types import CreateEmbeddingResponse from openai.types import Embedding as OpenAIEmbedding from openai.types.create_embedding_response import Usage @@ -32,13 +33,6 @@ def _make_openai_response( ) -@pytest.fixture -def openai_unit_test_env(monkeypatch: pytest.MonkeyPatch) -> None: - """Set up environment variables for OpenAI embedding client.""" - monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") - monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") - - # --- OpenAI unit tests --- @@ -50,24 +44,39 @@ def test_openai_construction_with_explicit_params() -> None: assert client.model == "text-embedding-3-small" -def test_openai_construction_from_env(openai_unit_test_env: None) -> None: +def test_openai_construction_from_env(openai_unit_test_env: dict[str, str]) -> None: client = OpenAIEmbeddingClient() + assert client.model == openai_unit_test_env["OPENAI_EMBEDDING_MODEL"] + + +def test_with_callable_api_key() -> None: + """Test OpenAIEmbeddingClient initialization with callable API key.""" + + async def get_api_key() -> str: + return "test-api-key-123" + + client = OpenAIEmbeddingClient(model="text-embedding-3-small", api_key=get_api_key) + assert client.model == "text-embedding-3-small" + assert client.client is not None -def test_openai_construction_missing_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - with pytest.raises(ValueError, match="API key is required"): +@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True) +def test_openai_construction_without_openai_or_azure_config_raises_clear_error( + openai_unit_test_env: dict[str, str], +) -> None: + with pytest.raises(SettingNotFoundError): OpenAIEmbeddingClient(model="text-embedding-3-small") -def test_openai_construction_missing_model_raises(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("OPENAI_EMBEDDING_MODEL", raising=False) - with pytest.raises(ValueError, match="embedding model is required"): - OpenAIEmbeddingClient(api_key="test-key") +@pytest.mark.parametrize("exclude_list", [["OPENAI_EMBEDDING_MODEL"]], indirect=True) +def test_openai_construction_falls_back_to_openai_model(openai_unit_test_env: dict[str, str]) -> None: + client = OpenAIEmbeddingClient() + + assert client.model == openai_unit_test_env["OPENAI_MODEL"] -async def test_openai_get_embeddings(openai_unit_test_env: None) -> None: +async def test_openai_get_embeddings(openai_unit_test_env: dict[str, str]) -> None: mock_response = _make_openai_response( embeddings=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], ) @@ -85,7 +94,7 @@ async def test_openai_get_embeddings(openai_unit_test_env: None) -> None: assert result[0].dimensions == 3 -async def test_openai_get_embeddings_usage(openai_unit_test_env: None) -> None: +async def test_openai_get_embeddings_usage(openai_unit_test_env: dict[str, str]) -> None: mock_response = _make_openai_response( embeddings=[[0.1]], prompt_tokens=10, @@ -103,7 +112,7 @@ async def test_openai_get_embeddings_usage(openai_unit_test_env: None) -> None: assert result.usage["total_token_count"] == 10 -async def test_openai_options_passthrough_dimensions(openai_unit_test_env: None) -> None: +async def test_openai_options_passthrough_dimensions(openai_unit_test_env: dict[str, str]) -> None: mock_response = _make_openai_response(embeddings=[[0.1]]) client = OpenAIEmbeddingClient() client.client = MagicMock() @@ -118,7 +127,7 @@ async def test_openai_options_passthrough_dimensions(openai_unit_test_env: None) assert result.options is options -async def test_openai_options_passthrough_encoding_format(openai_unit_test_env: None) -> None: +async def test_openai_options_passthrough_encoding_format(openai_unit_test_env: dict[str, str]) -> None: mock_response = _make_openai_response(embeddings=[[0.1]]) client = OpenAIEmbeddingClient() client.client = MagicMock() @@ -132,7 +141,7 @@ async def test_openai_options_passthrough_encoding_format(openai_unit_test_env: assert call_kwargs["encoding_format"] == "base64" -async def test_openai_base64_decoding(openai_unit_test_env: None) -> None: +async def test_openai_base64_decoding(openai_unit_test_env: dict[str, str]) -> None: import base64 import struct @@ -176,7 +185,7 @@ async def test_openai_error_when_no_model_id() -> None: await client.get_embeddings(["test"]) -async def test_openai_empty_values_returns_empty(openai_unit_test_env: None) -> None: +async def test_openai_empty_values_returns_empty(openai_unit_test_env: dict[str, str]) -> None: client = OpenAIEmbeddingClient() client.client = MagicMock() client.client.embeddings = MagicMock() diff --git a/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py b/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py new file mode 100644 index 0000000000..3cf62a064d --- /dev/null +++ b/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py @@ -0,0 +1,250 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import os +from functools import wraps +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from agent_framework.exceptions import SettingNotFoundError +from azure.core.credentials_async import AsyncTokenCredential +from azure.identity.aio import AzureCliCredential +from openai import AsyncAzureOpenAI + +from agent_framework_openai import OpenAIEmbeddingClient, OpenAIEmbeddingOptions + +pytestmark = pytest.mark.azure + +skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( + os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.openai.azure.com") + or ( + os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", "") == "" + and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" + ), + reason="No real Azure OpenAI endpoint or embedding deployment provided; skipping integration tests.", +) + + +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as exc: + model = os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.getenv( + "AZURE_OPENAI_DEPLOYMENT_NAME", "" + ) + api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") + endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") + debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + +def _get_azure_embedding_deployment_name() -> str: + return os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"] + + +def _create_azure_embedding_client( + *, + api_key: str | None = None, + credential: AsyncTokenCredential | None = None, +) -> OpenAIEmbeddingClient: + resolved_api_key = ( + api_key if api_key is not None else None if credential is not None else os.environ["AZURE_OPENAI_API_KEY"] + ) + return OpenAIEmbeddingClient( + model=_get_azure_embedding_deployment_name(), + api_key=resolved_api_key, + azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=credential, + ) + + +def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: + client = _create_azure_embedding_client() + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + assert client.api_version == azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"] + + +def test_init_auto_detects_azure_embedding_env(azure_openai_unit_test_env: dict[str, str]) -> None: + client = OpenAIEmbeddingClient() + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + + +def test_init_falls_back_to_generic_azure_deployment_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", raising=False) + + client = OpenAIEmbeddingClient() + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + + +def test_init_does_not_fall_back_to_openai_embedding_model_for_azure_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + OpenAIEmbeddingClient() + + +def test_init_does_not_fall_back_to_openai_model_for_azure_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("OPENAI_EMBEDDING_MODEL", raising=False) + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + OpenAIEmbeddingClient() + + +def test_openai_api_key_wins_over_azure_env(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + + client = OpenAIEmbeddingClient() + + assert client.model == "text-embedding-3-small" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +def test_api_version_alone_does_not_override_openai_api_key( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + + client = OpenAIEmbeddingClient(api_version="2024-10-21") + + assert client.model == "text-embedding-3-small" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + + client = OpenAIEmbeddingClient(credential=lambda: "token") + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + + +def test_init_with_credential_wraps_async_token_credential( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + class TestAsyncTokenCredential(AsyncTokenCredential): + async def get_token(self, *scopes: str, **kwargs: object): + raise NotImplementedError + + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + credential = TestAsyncTokenCredential() + token_provider = MagicMock() + + with patch("azure.identity.aio.get_bearer_token_provider", return_value=token_provider) as mock_provider: + client = OpenAIEmbeddingClient(credential=credential) + + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + mock_provider.assert_called_once_with(credential, "https://cognitiveservices.azure.com/.default") + + +@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True) +def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None: + client = _create_azure_embedding_client() + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert client.api_version == "2024-10-21" + + +def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + monkeypatch.setenv("OPENAI_BASE_URL", "https://custom-openai-endpoint.com/v1") + + client = OpenAIEmbeddingClient() + + assert client.model == "text-embedding-3-small" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() +async def test_azure_openai_get_embeddings() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_embedding_client(credential=credential) + + result = await client.get_embeddings(["hello world"]) + + assert len(result) == 1 + assert isinstance(result[0].vector, list) + assert len(result[0].vector) > 0 + assert all(isinstance(v, float) for v in result[0].vector) + assert result[0].model is not None + assert result.usage is not None + assert result.usage["input_token_count"] > 0 + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() +async def test_azure_openai_get_embeddings_multiple() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_embedding_client(credential=credential) + + result = await client.get_embeddings(["hello", "world", "test"]) + + assert len(result) == 3 + dims = [len(embedding.vector) for embedding in result] + assert all(dimension == dims[0] for dimension in dims) + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() +async def test_azure_openai_get_embeddings_with_dimensions() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_embedding_client(credential=credential) + + options: OpenAIEmbeddingOptions = {"dimensions": 256} + result = await client.get_embeddings(["hello world"], options=options) + + assert len(result) == 1 + assert len(result[0].vector) == 256 diff --git a/python/packages/openai/tests/openai/test_openai_shared.py b/python/packages/openai/tests/openai/test_openai_shared.py new file mode 100644 index 0000000000..b69feb7314 --- /dev/null +++ b/python/packages/openai/tests/openai/test_openai_shared.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential + +from agent_framework_openai._shared import AZURE_OPENAI_TOKEN_SCOPE, _resolve_azure_credential_to_token_provider + + +class _AsyncTokenCredentialStub(AsyncTokenCredential): + async def get_token(self, *scopes: str, **kwargs: object): + raise NotImplementedError + + +class _TokenCredentialStub(TokenCredential): + def get_token(self, *scopes: str, **kwargs: object): + raise NotImplementedError + + +def test_resolve_azure_async_credential_wraps_provider() -> None: + credential = _AsyncTokenCredentialStub() + token_provider = MagicMock() + + with patch("azure.identity.aio.get_bearer_token_provider", return_value=token_provider) as mock_provider: + resolved = _resolve_azure_credential_to_token_provider(credential) + + assert resolved is token_provider + mock_provider.assert_called_once_with(credential, AZURE_OPENAI_TOKEN_SCOPE) + + +def test_resolve_azure_sync_credential_wraps_provider() -> None: + credential = _TokenCredentialStub() + token_provider = MagicMock() + + with patch("azure.identity.get_bearer_token_provider", return_value=token_provider) as mock_provider: + resolved = _resolve_azure_credential_to_token_provider(credential) + + assert resolved is token_provider + mock_provider.assert_called_once_with(credential, AZURE_OPENAI_TOKEN_SCOPE) + + +def test_resolve_azure_callable_token_provider_passthrough() -> None: + token_provider = MagicMock() + + assert _resolve_azure_credential_to_token_provider(token_provider) is token_provider + + +def test_resolve_azure_invalid_credential_raises() -> None: + with pytest.raises(ValueError, match="credential"): + _resolve_azure_credential_to_token_provider(object()) # type: ignore[arg-type] diff --git a/python/samples/02-agents/chat_client/README.md b/python/samples/02-agents/chat_client/README.md index e03d532812..6650e510a9 100644 --- a/python/samples/02-agents/chat_client/README.md +++ b/python/samples/02-agents/chat_client/README.md @@ -57,8 +57,8 @@ Depending on the selected client, set the appropriate environment variables: **For OpenAI clients:** - `OPENAI_API_KEY`: Your OpenAI API key -- `OPENAI_CHAT_MODEL_ID`: The OpenAI model for `openai_chat` and `openai_assistants` -- `OPENAI_RESPONSES_MODEL_ID`: The OpenAI model for `openai_responses` +- `OPENAI_CHAT_MODEL`: The OpenAI model for `openai_chat` and `openai_assistants` +- `OPENAI_RESPONSES_MODEL`: The OpenAI model for `openai_responses` **For Anthropic client (`anthropic`):** - `ANTHROPIC_API_KEY`: Your Anthropic API key diff --git a/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py b/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py index 8cd8947ef6..98b7d66f88 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py +++ b/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py @@ -4,8 +4,9 @@ import asyncio import os from agent_framework import Agent -from agent_framework.azure import AzureAISearchContextProvider, AzureOpenAIEmbeddingClient +from agent_framework.azure import AzureAISearchContextProvider from agent_framework.foundry import FoundryChatClient +from agent_framework.openai import OpenAIEmbeddingClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -31,8 +32,8 @@ Prerequisites: - AZURE_SEARCH_INDEX_NAME: Your search index name - FOUNDRY_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint - AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name (e.g., "gpt-4o") - - AZURE_OPENAI_EMBEDDING_MODEL_ID: (Optional) Your embedding model for hybrid search (e.g., "text-embedding-3-small") - - AZURE_OPENAI_ENDPOINT: (Optional) Your Azure OpenAI resource URL, required if using an OpenAI embedding model for hybrid search + - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: (Optional) Your Azure OpenAI embedding deployment for hybrid search + - AZURE_OPENAI_ENDPOINT: (Optional) Your Azure OpenAI resource URL, required if using Azure OpenAI embeddings """ # Sample queries to demonstrate RAG @@ -55,13 +56,13 @@ async def main() -> None: project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] model_deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o") openai_endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") - embedding_model = os.environ.get("AZURE_OPENAI_EMBEDDING_MODEL_ID", "text-embedding-3-small") + embedding_deployment = os.environ.get("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") embedding_client = None - if openai_endpoint and embedding_model: - embedding_client = AzureOpenAIEmbeddingClient( - endpoint=openai_endpoint, - model=embedding_model, + if openai_endpoint and embedding_deployment: + embedding_client = OpenAIEmbeddingClient( + azure_endpoint=openai_endpoint, + model=embedding_deployment, credential=credential, ) diff --git a/python/samples/02-agents/devui/README.md b/python/samples/02-agents/devui/README.md index 2bdd6d2233..c5ce2095b8 100644 --- a/python/samples/02-agents/devui/README.md +++ b/python/samples/02-agents/devui/README.md @@ -85,7 +85,7 @@ Alternatively, set environment variables globally: ```bash export OPENAI_API_KEY="your-key-here" -export OPENAI_CHAT_MODEL_ID="gpt-4o" +export OPENAI_CHAT_MODEL="gpt-4o" ``` ## Using DevUI with Your Own Agents diff --git a/python/samples/02-agents/embeddings/azure_openai_embeddings.py b/python/samples/02-agents/embeddings/azure_openai_embeddings.py index 16669eb51f..460bbcf7de 100644 --- a/python/samples/02-agents/embeddings/azure_openai_embeddings.py +++ b/python/samples/02-agents/embeddings/azure_openai_embeddings.py @@ -2,55 +2,59 @@ # Run with: uv run samples/02-agents/embeddings/azure_openai_embeddings.py - import asyncio +import os -from agent_framework.azure import AzureOpenAIEmbeddingClient +from agent_framework.openai import OpenAIEmbeddingClient +from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv -load_dotenv() - -"""Azure OpenAI Embedding Client Example - -This sample demonstrates how to generate embeddings using the Azure OpenAI embedding client. -It supports both API key and Azure credential authentication. +"""This sample demonstrates Azure OpenAI embedding generation with ``OpenAIEmbeddingClient``. Prerequisites: - Set the following environment variables or add them to a .env file: - - AZURE_OPENAI_ENDPOINT: Your Azure OpenAI endpoint URL - - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: The embedding model deployment name - - AZURE_OPENAI_API_KEY: Your API key (or use Azure credential instead) + Set the following environment variables or add them to a local ``.env`` file: + - ``AZURE_OPENAI_ENDPOINT``: Your Azure OpenAI endpoint URL + - ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME``: The embedding deployment name + - ``AZURE_OPENAI_API_VERSION``: Optional API version override + + Sign in with ``az login`` before running the sample. """ +load_dotenv() + async def main() -> None: """Generate embeddings with Azure OpenAI.""" - # 1. Create a client using environment variables. - # Reads AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME, - # and AZURE_OPENAI_API_KEY from environment. - client = AzureOpenAIEmbeddingClient() + async with AzureCliCredential() as credential: + client = OpenAIEmbeddingClient( + model=os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"), + azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=credential, + ) - # 2. Generate a single embedding. - result = await client.get_embeddings(["Hello, world!"]) - print(f"Single embedding dimensions: {result[0].dimensions}") - print(f"First 5 values: {result[0].vector[:5]}") - print(f"Model: {result[0].model_id}") - print(f"Usage: {result.usage}") - print() + # 1. Generate a single embedding. + result = await client.get_embeddings(["Hello, world!"]) + print(f"Single embedding dimensions: {result[0].dimensions}") + print(f"First 5 values: {result[0].vector[:5]}") + print(f"Model: {result[0].model}") + print(f"Usage: {result.usage}") + print() - # 3. Generate embeddings for multiple inputs. - texts = [ - "The weather is sunny today.", - "It is raining outside.", - "Machine learning is fascinating.", - ] - result = await client.get_embeddings(texts) - print(f"Batch of {len(result)} embeddings, each with {result[0].dimensions} dimensions") - print() + # 2. Generate embeddings for multiple inputs. + texts = [ + "The weather is sunny today.", + "It is raining outside.", + "Machine learning is fascinating.", + ] + result = await client.get_embeddings(texts) + print(f"Batch of {len(result)} embeddings, each with {result[0].dimensions} dimensions") + print(f"First embedding vector: {result[0].vector[:5]}") + print() - # 4. Generate embeddings with custom dimensions. - result = await client.get_embeddings(["Custom dimensions example"], options={"dimensions": 256}) - print(f"Custom dimensions: {result[0].dimensions}") + # 3. Generate embeddings with custom dimensions. + result = await client.get_embeddings(["Custom dimensions example"], options={"dimensions": 256}) + print(f"Custom dimensions: {result[0].dimensions}") if __name__ == "__main__": diff --git a/python/samples/02-agents/embeddings/openai_embeddings.py b/python/samples/02-agents/embeddings/openai_embeddings.py index 001b6593f5..d49b3034dd 100644 --- a/python/samples/02-agents/embeddings/openai_embeddings.py +++ b/python/samples/02-agents/embeddings/openai_embeddings.py @@ -3,31 +3,32 @@ # Run with: uv run samples/02-agents/embeddings/openai_embeddings.py import asyncio +import os from agent_framework.openai import OpenAIEmbeddingClient from dotenv import load_dotenv -load_dotenv() - -"""OpenAI Embedding Client Example - -This sample demonstrates how to generate embeddings using the OpenAI embedding client. -It shows single and batch embedding generation, as well as custom dimensions. +"""This sample demonstrates OpenAI embedding generation with explicit constructor settings. Prerequisites: - Set the OPENAI_API_KEY environment variable or add it to a .env file. + Set ``OPENAI_API_KEY`` in your environment or in a local ``.env`` file. """ +load_dotenv() + async def main() -> None: """Generate embeddings with OpenAI.""" - client = OpenAIEmbeddingClient(model="text-embedding-3-small") + client = OpenAIEmbeddingClient( + model="text-embedding-3-small", + api_key=os.getenv("OPENAI_API_KEY"), + ) # 1. Generate a single embedding. result = await client.get_embeddings(["Hello, world!"]) print(f"Single embedding dimensions: {result[0].dimensions}") print(f"First 5 values: {result[0].vector[:5]}") - print(f"Model: {result[0].model_id}") + print(f"Model: {result[0].model}") print(f"Usage: {result.usage}") print() @@ -39,7 +40,7 @@ async def main() -> None: ] result = await client.get_embeddings(texts) print(f"Batch of {len(result)} embeddings, each with {result[0].dimensions} dimensions") - print(f"First embedding vector: {result[0].vector[:5]}") # Print first 5 values of the first embedding + print(f"First embedding vector: {result[0].vector[:5]}") print() # 3. Generate embeddings with custom dimensions. diff --git a/python/samples/02-agents/mcp/README.md b/python/samples/02-agents/mcp/README.md index 1df1a449b6..e07d63ddbd 100644 --- a/python/samples/02-agents/mcp/README.md +++ b/python/samples/02-agents/mcp/README.md @@ -17,7 +17,7 @@ The Model Context Protocol (MCP) is an open standard for connecting AI agents to ## Prerequisites - `OPENAI_API_KEY` environment variable -- `OPENAI_RESPONSES_MODEL_ID` environment variable +- `OPENAI_RESPONSES_MODEL` environment variable For `mcp_github_pat.py`: - `GITHUB_PAT` - Your GitHub Personal Access Token (create at https://github.com/settings/tokens) diff --git a/python/samples/02-agents/middleware/README.md b/python/samples/02-agents/middleware/README.md index 754f96e815..5bd318575c 100644 --- a/python/samples/02-agents/middleware/README.md +++ b/python/samples/02-agents/middleware/README.md @@ -25,7 +25,7 @@ The new usage tracking sample uses `OpenAIResponsesClient`, so set the usual Ope ```bash export OPENAI_API_KEY="your-openai-api-key" -export OPENAI_RESPONSES_MODEL_ID="gpt-4.1-mini" +export OPENAI_RESPONSES_MODEL="gpt-4.1-mini" ``` Then run: diff --git a/python/samples/02-agents/observability/.env.example b/python/samples/02-agents/observability/.env.example index 11f0a07810..c1c24a5a72 100644 --- a/python/samples/02-agents/observability/.env.example +++ b/python/samples/02-agents/observability/.env.example @@ -40,8 +40,8 @@ ENABLE_SENSITIVE_DATA=true # OpenAI specific variables # ========================== OPENAI_API_KEY="..." -OPENAI_RESPONSES_MODEL_ID="gpt-4o-2024-08-06" -OPENAI_CHAT_MODEL_ID="gpt-4o-2024-08-06" +OPENAI_RESPONSES_MODEL="gpt-4o-2024-08-06" +OPENAI_CHAT_MODEL="gpt-4o-2024-08-06" # Azure AI Foundry specific variables # ==================================== diff --git a/python/samples/02-agents/providers/azure/README.md b/python/samples/02-agents/providers/azure/README.md index 1e06bda482..cd34fe2717 100644 --- a/python/samples/02-agents/providers/azure/README.md +++ b/python/samples/02-agents/providers/azure/README.md @@ -1,12 +1,48 @@ # Azure Provider Samples -This folder contains Azure OpenAI chat completion samples for Agent Framework. +This folder contains Azure-backed samples for the generic OpenAI clients in +`agent_framework.openai`. -## Azure OpenAI ChatCompletionClient Samples +## Chat Completions API samples (`OpenAIChatCompletionClient`) | File | Description | |------|-------------| -| [`openai_chat_completion_client_azure_basic.py`](openai_chat_completion_client_azure_basic.py) | Azure OpenAI Chat Client Basic Example | -| [`openai_chat_completion_client_azure_with_explicit_settings.py`](openai_chat_completion_client_azure_with_explicit_settings.py) | Azure OpenAI Chat Client with Explicit Settings Example | -| [`openai_chat_completion_client_azure_with_function_tools.py`](openai_chat_completion_client_azure_with_function_tools.py) | Azure OpenAI Chat Client with Function Tools Example | -| [`openai_chat_completion_client_azure_with_session.py`](openai_chat_completion_client_azure_with_session.py) | Azure OpenAI Chat Client with Session Management Example | +| [`openai_chat_completion_client_basic.py`](openai_chat_completion_client_basic.py) | Basic Azure chat completions sample using explicit Azure settings and `credential=AzureCliCredential()`. | +| [`openai_chat_completion_client_with_explicit_settings.py`](openai_chat_completion_client_with_explicit_settings.py) | Azure chat completions sample with explicit settings. | +| [`openai_chat_completion_client_with_function_tools.py`](openai_chat_completion_client_with_function_tools.py) | Azure chat completions sample with function tools. | +| [`openai_chat_completion_client_with_session.py`](openai_chat_completion_client_with_session.py) | Azure chat completions sample with session management. | + +## Responses API samples (`OpenAIChatClient`) + +| File | Description | +|------|-------------| +| [`openai_client_basic.py`](openai_client_basic.py) | Basic Azure responses sample using explicit settings and `credential=AzureCliCredential()`. | +| [`openai_client_with_function_tools.py`](openai_client_with_function_tools.py) | Azure responses sample with function tools. | +| [`openai_client_with_session.py`](openai_client_with_session.py) | Azure responses sample with session management. | +| [`openai_client_with_structured_output.py`](openai_client_with_structured_output.py) | Azure responses sample with structured output. | + +## Environment Variables + +Set these before running the Azure provider samples: + +- `AZURE_OPENAI_ENDPOINT` +- `AZURE_OPENAI_DEPLOYMENT_NAME` + +Optionally, you can also set: + +- `AZURE_OPENAI_API_KEY` +- `AZURE_OPENAI_API_VERSION` +- `AZURE_OPENAI_BASE_URL` + +These Azure samples are written around explicit Azure inputs such as +`credential=AzureCliCredential()`, so they stay on Azure even if `OPENAI_API_KEY` is also present. + +## Optional Dependencies + +Credential-based samples require `azure-identity`: + +```bash +pip install azure-identity +``` + +Run `az login` before executing the credential-based samples. diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_basic.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py similarity index 70% rename from python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_basic.py rename to python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py index db5740cfc3..030828da89 100644 --- a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_basic.py +++ b/python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from random import randint from typing import Annotated @@ -16,14 +17,12 @@ load_dotenv() """ Azure OpenAI Chat Client Basic Example -This sample demonstrates basic usage of OpenAIChatCompletionClient for direct chat-based -interactions, showing both streaming and non-streaming responses. +This sample demonstrates basic usage of OpenAIChatCompletionClient with explicit Azure +settings and a credential, showing both streaming and non-streaming responses. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -37,11 +36,14 @@ async def non_streaming_example() -> None: """Example of non-streaming response (get the complete result at once).""" print("=== Non-streaming Response Example ===") - # Create agent with Azure Chat Client - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. agent = Agent( - client=OpenAIChatCompletionClient(credential=AzureCliCredential()), + client=OpenAIChatCompletionClient( + model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=AzureCliCredential(), + ), + name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -56,11 +58,14 @@ async def streaming_example() -> None: """Example of streaming response (get results as they are generated).""" print("=== Streaming Response Example ===") - # Create agent with Azure Chat Client - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. agent = Agent( - client=OpenAIChatCompletionClient(credential=AzureCliCredential()), + client=OpenAIChatCompletionClient( + model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=AzureCliCredential(), + ), + name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -75,7 +80,7 @@ async def streaming_example() -> None: async def main() -> None: - print("=== Basic Azure Chat Client Agent Example ===") + print("=== Basic Azure Chat Completion Client Agent Example ===") await non_streaming_example() await streaming_example() diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_explicit_settings.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py similarity index 71% rename from python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_explicit_settings.py rename to python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py index 16ecc8a091..cf26f6ba38 100644 --- a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_explicit_settings.py +++ b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py @@ -15,16 +15,16 @@ from pydantic import Field load_dotenv() """ -Azure OpenAI Chat Client with Explicit Settings Example +OpenAI Chat Completion Client with Explicit Settings Example -This sample demonstrates creating Azure OpenAI Chat Client with explicit configuration +This samples connects to Azure OpenAI. + +This sample demonstrates creating OpenAI Chat Completion Client with explicit configuration settings rather than relying on environment variable defaults. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -39,13 +39,12 @@ async def main() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - _client = OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], - credential=AzureCliCredential(), - ) agent = Agent( - client=_client, + client=OpenAIChatCompletionClient( + model=os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], + azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], + credential=AzureCliCredential(), + ), instructions="You are a helpful weather agent.", tools=[get_weather], ) diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_function_tools.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_function_tools.py similarity index 100% rename from python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_function_tools.py rename to python/samples/02-agents/providers/azure/openai_chat_completion_client_with_function_tools.py diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_session.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_session.py similarity index 100% rename from python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_session.py rename to python/samples/02-agents/providers/azure/openai_chat_completion_client_with_session.py diff --git a/python/samples/02-agents/providers/azure/openai_client_basic.py b/python/samples/02-agents/providers/azure/openai_client_basic.py new file mode 100644 index 0000000000..3029c03cda --- /dev/null +++ b/python/samples/02-agents/providers/azure/openai_client_basic.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from random import randint +from typing import Annotated + +from agent_framework import Agent, tool +from agent_framework.openai import OpenAIChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + +""" +Azure OpenAI Chat Client Basic Example + +This sample demonstrates basic usage of OpenAIChatClient with explicit Azure +settings and a credential, showing both streaming and non-streaming responses. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def non_streaming_example() -> None: + """Example of non-streaming response (get the complete result at once).""" + print("=== Non-streaming Response Example ===") + + agent = Agent( + client=OpenAIChatClient( + model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=AzureCliCredential(), + ), + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + query = "What's the weather in Seattle?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Result: {result}\n") + + +async def streaming_example() -> None: + """Example of streaming response (get results as they are generated).""" + print("=== Streaming Response Example ===") + + agent = Agent( + client=OpenAIChatClient( + model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=AzureCliCredential(), + ), + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + query = "What's the weather in Portland?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +async def main() -> None: + print("=== Basic Azure OpenAI Chat Client Agent Example ===") + + await non_streaming_example() + await streaming_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure/openai_client_with_function_tools.py b/python/samples/02-agents/providers/azure/openai_client_with_function_tools.py new file mode 100644 index 0000000000..8080b65418 --- /dev/null +++ b/python/samples/02-agents/providers/azure/openai_client_with_function_tools.py @@ -0,0 +1,137 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from datetime import datetime, timezone +from random import randint +from typing import Annotated + +from agent_framework import Agent, tool +from agent_framework.openai import OpenAIChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + +""" +Azure OpenAI Chat Client with Function Tools Example + +This sample demonstrates function tool integration with Azure OpenAI Chat Client, +showing both agent-level and query-level tool configuration patterns. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +@tool(approval_mode="never_require") +def get_time() -> str: + """Get the current UTC time.""" + current_time = datetime.now(timezone.utc) + return f"The current UTC time is {current_time.strftime('%Y-%m-%d %H:%M:%S')}." + + +async def tools_on_agent_level() -> None: + """Example showing tools defined when creating the agent.""" + print("=== Tools Defined on Agent Level ===") + + # Tools are provided when creating the agent + # The agent can use these tools for any query during its lifetime + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful assistant that can provide weather and time information.", + tools=[get_weather, get_time], # Tools defined at agent creation + ) + + # First query - agent can use weather tool + query1 = "What's the weather like in New York?" + print(f"User: {query1}") + result1 = await agent.run(query1) + print(f"Agent: {result1}\n") + + # Second query - agent can use time tool + query2 = "What's the current UTC time?" + print(f"User: {query2}") + result2 = await agent.run(query2) + print(f"Agent: {result2}\n") + + # Third query - agent can use both tools if needed + query3 = "What's the weather in London and what's the current UTC time?" + print(f"User: {query3}") + result3 = await agent.run(query3) + print(f"Agent: {result3}\n") + + +async def tools_on_run_level() -> None: + """Example showing tools passed to the run method.""" + print("=== Tools Passed to Run Method ===") + + # Agent created without tools + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful assistant.", + # No tools defined here + ) + + # First query with weather tool + query1 = "What's the weather like in Seattle?" + print(f"User: {query1}") + result1 = await agent.run(query1, tools=[get_weather]) # Tool passed to run method + print(f"Agent: {result1}\n") + + # Second query with time tool + query2 = "What's the current UTC time?" + print(f"User: {query2}") + result2 = await agent.run(query2, tools=[get_time]) # Different tool for this query + print(f"Agent: {result2}\n") + + # Third query with multiple tools + query3 = "What's the weather in Chicago and what's the current UTC time?" + print(f"User: {query3}") + result3 = await agent.run(query3, tools=[get_weather, get_time]) # Multiple tools + print(f"Agent: {result3}\n") + + +async def mixed_tools_example() -> None: + """Example showing both agent-level tools and run-method tools.""" + print("=== Mixed Tools Example (Agent + Run Method) ===") + + # Agent created with some base tools + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a comprehensive assistant that can help with various information requests.", + tools=[get_weather], # Base tool available for all queries + ) + + # Query using both agent tool and additional run-method tools + query = "What's the weather in Denver and what's the current UTC time?" + print(f"User: {query}") + + # Agent has access to get_weather (from creation) + additional tools from run method + result = await agent.run( + query, + tools=[get_time], # Additional tools for this specific query + ) + print(f"Agent: {result}\n") + + +async def main() -> None: + print("=== Azure OpenAI Chat Client Agent with Function Tools Examples ===\n") + + await tools_on_agent_level() + await tools_on_run_level() + await mixed_tools_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure/openai_client_with_session.py b/python/samples/02-agents/providers/azure/openai_client_with_session.py new file mode 100644 index 0000000000..ad1c87f2d2 --- /dev/null +++ b/python/samples/02-agents/providers/azure/openai_client_with_session.py @@ -0,0 +1,152 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from random import randint +from typing import Annotated + +from agent_framework import Agent, AgentSession, tool +from agent_framework.openai import OpenAIChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + +""" +Azure OpenAI Chat Client with Session Management Example + +This sample demonstrates session management with Azure OpenAI Chat Client, showing +persistent conversation context and simplified response handling. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def example_with_automatic_session_creation() -> None: + """Example showing automatic session creation.""" + print("=== Automatic Session Creation Example ===") + + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # First conversation - no session provided, will be created automatically + query1 = "What's the weather like in Seattle?" + print(f"User: {query1}") + result1 = await agent.run(query1) + print(f"Agent: {result1.text}") + + # Second conversation - still no session provided, will create another new session + query2 = "What was the last city I asked about?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2) + print(f"Agent: {result2.text}") + print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") + + +async def example_with_session_persistence_in_memory() -> None: + """ + Example showing session persistence across multiple conversations. + In this example, messages are stored in-memory. + """ + print("=== Session Persistence Example (In-Memory) ===") + + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Create a new session that will be reused + session = agent.create_session() + + # First conversation + query1 = "What's the weather like in Tokyo?" + print(f"User: {query1}") + result1 = await agent.run(query1, session=session, store=False) + print(f"Agent: {result1.text}") + + # Second conversation using the same session - maintains context + query2 = "How about London?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2, session=session, store=False) + print(f"Agent: {result2.text}") + + # Third conversation - agent should remember both previous cities + query3 = "Which of the cities I asked about has better weather?" + print(f"\nUser: {query3}") + result3 = await agent.run(query3, session=session, store=False) + print(f"Agent: {result3.text}") + print("Note: The agent remembers context from previous messages in the same session.\n") + + +async def example_with_existing_session_id() -> None: + """ + Example showing how to work with an existing session ID from the service. + In this example, messages are stored on the server using OpenAI conversation state. + """ + print("=== Existing Session ID Example ===") + + # First, create a conversation and capture the session ID + existing_session_id = None + + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Start a conversation and get the session ID + session = agent.create_session() + + query1 = "What's the weather in Paris?" + print(f"User: {query1}") + result1 = await agent.run(query1, session=session) + print(f"Agent: {result1.text}") + + # The session ID is set after the first response + existing_session_id = session.service_session_id + print(f"Session ID: {existing_session_id}") + + if existing_session_id: + print("\n--- Continuing with the same session ID in a new agent instance ---") + + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Create a session with the existing ID + session = AgentSession(service_session_id=existing_session_id) + + query2 = "What was the last city I asked about?" + print(f"User: {query2}") + result2 = await agent.run(query2, session=session) + print(f"Agent: {result2.text}") + print("Note: The agent continues the conversation from the previous session by using session ID.\n") + + +async def main() -> None: + print("=== Azure OpenAI Chat Client Session Management Examples ===\n") + + await example_with_automatic_session_creation() + await example_with_session_persistence_in_memory() + await example_with_existing_session_id() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure/openai_client_with_structured_output.py b/python/samples/02-agents/providers/azure/openai_client_with_structured_output.py new file mode 100644 index 0000000000..3c09efd6d4 --- /dev/null +++ b/python/samples/02-agents/providers/azure/openai_client_with_structured_output.py @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Agent, AgentResponse +from agent_framework.openai import OpenAIChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import BaseModel + +# Load environment variables from .env file +load_dotenv() + +""" +Azure OpenAI Chat Client with Structured Output Example + +This sample demonstrates using structured output capabilities with Azure OpenAI Chat Client, +showing Pydantic model integration for type-safe response parsing and data extraction. +""" + + +class OutputStruct(BaseModel): + """A structured output for testing purposes.""" + + city: str + description: str + + +async def non_streaming_example() -> None: + print("=== Non-streaming example ===") + + # Create an Azure OpenAI Chat agent + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + name="CityAgent", + instructions="You are a helpful agent that describes cities in a structured format.", + ) + + # Ask the agent about a city + query = "Tell me about Paris, France" + print(f"User: {query}") + + # Get structured response from the agent using response_format parameter + result = await agent.run(query, options={"response_format": OutputStruct}) + + # Access the structured output using the parsed value + if structured_data := result.value: + print("Structured Output Agent:") + print(f"City: {structured_data.city}") + print(f"Description: {structured_data.description}") + else: + print(f"Failed to parse response: {result.text}") + + +async def streaming_example() -> None: + print("=== Streaming example ===") + + # Create an Azure OpenAI Chat agent + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + name="CityAgent", + instructions="You are a helpful agent that describes cities in a structured format.", + ) + + # Ask the agent about a city + query = "Tell me about Tokyo, Japan" + print(f"User: {query}") + + # Get structured response from streaming agent using AgentResponse.from_update_generator + # This method collects all streaming updates and combines them into a single AgentResponse + result = await AgentResponse.from_update_generator( + agent.run(query, stream=True, options={"response_format": OutputStruct}), + output_format_type=OutputStruct, + ) + + # Access the structured output using the parsed value + if structured_data := result.value: + print("Structured Output (from streaming with AgentResponse.from_update_generator):") + print(f"City: {structured_data.city}") + print(f"Description: {structured_data.description}") + else: + print(f"Failed to parse response: {result.text}") + + +async def main() -> None: + print("=== Azure OpenAI Chat Client Agent with Structured Output ===") + + await non_streaming_example() + await streaming_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/custom/README.md b/python/samples/02-agents/providers/custom/README.md index ac58a77e69..766e5e0269 100644 --- a/python/samples/02-agents/providers/custom/README.md +++ b/python/samples/02-agents/providers/custom/README.md @@ -27,7 +27,7 @@ Both approaches allow you to extend the framework for your specific use cases wh ## Understanding Raw Client Classes -The framework provides `Raw...Client` classes (e.g., `RawOpenAIChatClient`, `RawOpenAIResponsesClient`, `RawAzureAIClient`) that are intermediate implementations without middleware, telemetry, or function invocation support. +The framework provides `Raw...Client` classes (e.g., `RawOpenAIChatClient`, `RawOpenAIChatCompletionClient`, `RawAzureAIClient`) that are intermediate implementations without middleware, telemetry, or function invocation support. ### Warning: Raw Clients Should Not Normally Be Used Directly @@ -60,8 +60,8 @@ class MyCustomClient( For most use cases, use the fully-featured public client classes which already have all layers correctly composed: -- `OpenAIChatClient` - OpenAI Chat completions with all layers -- `OpenAIResponsesClient` - OpenAI Responses API with all layers +- `OpenAIChatCompletionClient` - OpenAI Chat Completions API with all layers +- `OpenAIChatClient` - OpenAI Responses API with all layers - `AzureOpenAIChatClient` - Azure OpenAI Chat with all layers - `AzureOpenAIResponsesClient` - Azure OpenAI Responses with all layers - `AzureAIClient` - Azure AI Project with all layers diff --git a/python/samples/02-agents/providers/openai/README.md b/python/samples/02-agents/providers/openai/README.md index 20e757d421..db71abfa89 100644 --- a/python/samples/02-agents/providers/openai/README.md +++ b/python/samples/02-agents/providers/openai/README.md @@ -1,67 +1,63 @@ -# OpenAI Agent Framework Examples +# OpenAI Provider Samples -This folder contains examples demonstrating different ways to create and use agents with the OpenAI clients from the `agent_framework.openai` package. +This folder contains OpenAI provider samples for the generic clients in +`agent_framework.openai`. -## Examples +## Chat Completions API samples (`OpenAIChatCompletionClient`) | File | Description | |------|-------------| -| [`openai_assistants_basic.py`](openai_assistants_basic.py) | Basic usage of `OpenAIAssistantProvider` with streaming and non-streaming responses. | -| [`openai_assistants_provider_methods.py`](openai_assistants_provider_methods.py) | Demonstrates all `OpenAIAssistantProvider` methods: `create_agent()`, `get_agent()`, and `as_agent()`. | -| [`openai_assistants_with_code_interpreter.py`](openai_assistants_with_code_interpreter.py) | Using `OpenAIAssistantsClient.get_code_interpreter_tool()` with `OpenAIAssistantProvider` to execute Python code. | -| [`openai_assistants_with_existing_assistant.py`](openai_assistants_with_existing_assistant.py) | Working with pre-existing assistants using `get_agent()` and `as_agent()` methods. | -| [`openai_assistants_with_explicit_settings.py`](openai_assistants_with_explicit_settings.py) | Configuring `OpenAIAssistantProvider` with explicit settings including API key and model ID. | -| [`openai_assistants_with_file_search.py`](openai_assistants_with_file_search.py) | Using `OpenAIAssistantsClient.get_file_search_tool()` with `OpenAIAssistantProvider` for file search capabilities. | -| [`openai_assistants_with_function_tools.py`](openai_assistants_with_function_tools.py) | Function tools with `OpenAIAssistantProvider` at both agent-level and query-level. | -| [`openai_assistants_with_response_format.py`](openai_assistants_with_response_format.py) | Structured outputs with `OpenAIAssistantProvider` using Pydantic models. | -| [`openai_assistants_with_session.py`](openai_assistants_with_session.py) | Session management with `OpenAIAssistantProvider` for conversation context persistence. | -| [`openai_chat_client_basic.py`](openai_chat_client_basic.py) | The simplest way to create an agent using `Agent` with `OpenAIChatClient`. Shows both streaming and non-streaming responses for chat-based interactions with OpenAI models. | -| [`openai_chat_client_with_explicit_settings.py`](openai_chat_client_with_explicit_settings.py) | Shows how to initialize an agent with a specific chat client, configuring settings explicitly including API key and model ID. | -| [`openai_chat_client_with_function_tools.py`](openai_chat_client_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and query-level tools (provided with specific queries). | -| [`openai_chat_client_with_local_mcp.py`](openai_chat_client_with_local_mcp.py) | Shows how to integrate OpenAI agents with local Model Context Protocol (MCP) servers for enhanced functionality and tool integration. | -| [`openai_chat_client_with_session.py`](openai_chat_client_with_session.py) | Demonstrates session management with OpenAI agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | -| [`openai_chat_client_with_web_search.py`](openai_chat_client_with_web_search.py) | Shows how to use `OpenAIChatClient.get_web_search_tool()` for web search capabilities with OpenAI agents. | -| [`openai_chat_client_with_runtime_json_schema.py`](openai_chat_client_with_runtime_json_schema.py) | Shows how to supply a runtime JSON Schema via `additional_chat_options` for structured output without defining a Pydantic model. | -| [`openai_responses_client_basic.py`](openai_responses_client_basic.py) | The simplest way to create an agent using `Agent` with `OpenAIResponsesClient`. Shows both streaming and non-streaming responses for structured response generation with OpenAI models. | -| [`openai_responses_client_image_analysis.py`](openai_responses_client_image_analysis.py) | Demonstrates how to use vision capabilities with agents to analyze images. | -| [`openai_responses_client_image_generation.py`](openai_responses_client_image_generation.py) | Demonstrates how to use `OpenAIResponsesClient.get_image_generation_tool()` to create images based on text descriptions. | -| [`openai_responses_client_reasoning.py`](openai_responses_client_reasoning.py) | Demonstrates how to use reasoning capabilities with OpenAI agents, showing how the agent can provide detailed reasoning for its responses. | -| [`openai_responses_client_streaming_image_generation.py`](openai_responses_client_streaming_image_generation.py) | Demonstrates streaming image generation with partial images for real-time image creation feedback and improved user experience. | -| [`openai_responses_client_with_agent_as_tool.py`](openai_responses_client_with_agent_as_tool.py) | Shows how to use the agent-as-tool pattern with OpenAI Responses Client, where one agent delegates work to specialized sub-agents wrapped as tools using `as_tool()`. Demonstrates hierarchical agent architectures. | -| [`openai_responses_client_with_code_interpreter.py`](openai_responses_client_with_code_interpreter.py) | Shows how to use `OpenAIResponsesClient.get_code_interpreter_tool()` to write and execute Python code. | -| [`openai_responses_client_with_code_interpreter_files.py`](openai_responses_client_with_code_interpreter_files.py) | Shows how to use code interpreter with uploaded files for data analysis. | -| [`openai_responses_client_with_explicit_settings.py`](openai_responses_client_with_explicit_settings.py) | Shows how to initialize an agent with a specific responses client, configuring settings explicitly including API key and model ID. | -| [`openai_responses_client_with_file_search.py`](openai_responses_client_with_file_search.py) | Demonstrates how to use `OpenAIResponsesClient.get_file_search_tool()` for searching through uploaded files. | -| [`openai_responses_client_with_function_tools.py`](openai_responses_client_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and run-level tools (provided with specific queries). | -| [`openai_responses_client_with_hosted_mcp.py`](openai_responses_client_with_hosted_mcp.py) | Shows how to use `OpenAIResponsesClient.get_mcp_tool()` for hosted MCP servers, including approval workflows. | -| [`openai_responses_client_with_local_mcp.py`](openai_responses_client_with_local_mcp.py) | Shows how to integrate OpenAI agents with local Model Context Protocol (MCP) servers for enhanced functionality and tool integration. | -| [`openai_responses_client_with_runtime_json_schema.py`](openai_responses_client_with_runtime_json_schema.py) | Shows how to supply a runtime JSON Schema via `additional_chat_options` for structured output without defining a Pydantic model. | -| [`openai_responses_client_with_structured_output.py`](openai_responses_client_with_structured_output.py) | Demonstrates how to use structured outputs with OpenAI agents to get structured data responses in predefined formats. | -| [`openai_responses_client_with_session.py`](openai_responses_client_with_session.py) | Demonstrates session management with OpenAI agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | -| [`openai_responses_client_with_web_search.py`](openai_responses_client_with_web_search.py) | Shows how to use `OpenAIResponsesClient.get_web_search_tool()` for web search capabilities. | +| [`chat_completion_client_basic.py`](chat_completion_client_basic.py) | Basic non-streaming and streaming chat completion sample with an explicit `gpt-5.4-nano` model and API key. | +| [`chat_completion_client_with_explicit_settings.py`](chat_completion_client_with_explicit_settings.py) | Chat completion sample with explicit model and API key settings. | +| [`chat_completion_client_with_function_tools.py`](chat_completion_client_with_function_tools.py) | Function tools with agent-level and run-level patterns. | +| [`chat_completion_client_with_local_mcp.py`](chat_completion_client_with_local_mcp.py) | Local MCP integration with the chat completions client. | +| [`chat_completion_client_with_runtime_json_schema.py`](chat_completion_client_with_runtime_json_schema.py) | Runtime JSON schema output with the chat completions client. | +| [`chat_completion_client_with_session.py`](chat_completion_client_with_session.py) | Session management with the chat completions client. | +| [`chat_completion_client_with_web_search.py`](chat_completion_client_with_web_search.py) | Web search with the chat completions client. | + +## Responses API samples (`OpenAIChatClient`) + +| File | Description | +|------|-------------| +| [`client_basic.py`](client_basic.py) | Basic non-streaming and streaming responses sample with an explicit `gpt-5.4-nano` model and API key. | +| [`client_image_analysis.py`](client_image_analysis.py) | Analyze images with the responses client. | +| [`client_image_generation.py`](client_image_generation.py) | Generate images from text prompts. | +| [`client_reasoning.py`](client_reasoning.py) | Reasoning-focused sample for models such as `gpt-5`. | +| [`client_streaming_image_generation.py`](client_streaming_image_generation.py) | Streaming image generation sample. | +| [`client_with_agent_as_tool.py`](client_with_agent_as_tool.py) | Agent-as-tool orchestration pattern. | +| [`client_with_code_interpreter.py`](client_with_code_interpreter.py) | Code interpreter sample. | +| [`client_with_code_interpreter_files.py`](client_with_code_interpreter_files.py) | Code interpreter sample with uploaded files. | +| [`client_with_explicit_settings.py`](client_with_explicit_settings.py) | Responses client with explicit model and API key settings. | +| [`client_with_file_search.py`](client_with_file_search.py) | Hosted file search sample. | +| [`client_with_function_tools.py`](client_with_function_tools.py) | Function tools with agent-level and run-level patterns. | +| [`client_with_hosted_mcp.py`](client_with_hosted_mcp.py) | Hosted MCP tools and approval workflows. | +| [`client_with_local_mcp.py`](client_with_local_mcp.py) | Local MCP integration with the responses client. | +| [`client_with_local_shell.py`](client_with_local_shell.py) | Local shell tool sample. | +| [`client_with_runtime_json_schema.py`](client_with_runtime_json_schema.py) | Runtime JSON schema output with the responses client. | +| [`client_with_session.py`](client_with_session.py) | Session management with the responses client. | +| [`client_with_shell.py`](client_with_shell.py) | Hosted shell tool sample. | +| [`client_with_structured_output.py`](client_with_structured_output.py) | Structured output with Pydantic models. | +| [`client_with_web_search.py`](client_with_web_search.py) | Web search with the responses client. | ## Environment Variables -Make sure to set the following environment variables before running the examples: +Set these before running the OpenAI provider samples: -- `OPENAI_API_KEY`: Your OpenAI API key -- `OPENAI_CHAT_MODEL_ID`: The OpenAI model to use (e.g., `gpt-4o`, `gpt-4o-mini`, `gpt-3.5-turbo`) -- `OPENAI_RESPONSES_MODEL_ID`: The OpenAI model to use (e.g., `gpt-4o`, `gpt-4o-mini`, `gpt-3.5-turbo`) -- For image processing examples, use a vision-capable model like `gpt-4o` or `gpt-4o-mini` +- `OPENAI_API_KEY` +- `OPENAI_MODEL` -Optionally, you can set: -- `OPENAI_ORG_ID`: Your OpenAI organization ID (if applicable) -- `OPENAI_API_BASE_URL`: Your OpenAI base URL (if using a different base URL) +Optionally, you can also set: + +- `OPENAI_ORG_ID` +- `OPENAI_BASE_URL` + +If your shell also contains `AZURE_OPENAI_*` variables, these samples still stay on OpenAI as long as +`OPENAI_API_KEY` is present. To force Azure routing with the generic clients, pass an explicit Azure +input such as `credential`, `azure_endpoint`, or `api_version`, or use the Azure provider samples. ## Optional Dependencies -Some examples require additional dependencies: +Some samples need extra packages: -- **Image Generation Example**: The `openai_responses_client_image_generation.py` example requires PIL (Pillow) for image display. Install with: - ```bash - # Using uv - uv add pillow - - # Or using pip - pip install pillow - ``` +- `client_image_generation.py` and `client_streaming_image_generation.py` use Pillow for image display. +- MCP samples require the relevant MCP server/tooling you configure locally. diff --git a/python/samples/02-agents/providers/openai/chat_completion_client_basic.py b/python/samples/02-agents/providers/openai/chat_completion_client_basic.py new file mode 100644 index 0000000000..9573ef0b16 --- /dev/null +++ b/python/samples/02-agents/providers/openai/chat_completion_client_basic.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from random import randint +from typing import Annotated + +from agent_framework import Agent, tool +from agent_framework.openai import OpenAIChatCompletionClient +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + +""" +OpenAI Chat Completion Client Basic Example + +This sample demonstrates basic usage of OpenAIChatCompletionClient with explicit model and +API key settings, showing both streaming and non-streaming responses. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def non_streaming_example() -> None: + """Example of non-streaming response (get the complete result at once).""" + print("=== Non-streaming Response Example ===") + + agent = Agent( + client=OpenAIChatCompletionClient( + model="gpt-5.4-nano", + api_key=os.getenv("OPENAI_API_KEY"), + ), + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + query = "What's the weather like in Seattle?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Result: {result}\n") + + +async def streaming_example() -> None: + """Example of streaming response (get results as they are generated).""" + print("=== Streaming Response Example ===") + + agent = Agent( + client=OpenAIChatCompletionClient( + model="gpt-5.4-nano", + api_key=os.getenv("OPENAI_API_KEY"), + ), + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + query = "What's the weather like in Portland?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +async def main() -> None: + print("=== Basic OpenAI Chat Completion Client Agent Example ===") + + await non_streaming_example() + await streaming_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_explicit_settings.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_explicit_settings.py similarity index 74% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_explicit_settings.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_explicit_settings.py index 20a3f720d1..59d11a4fda 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_explicit_settings.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_explicit_settings.py @@ -6,7 +6,7 @@ from random import randint from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv from pydantic import Field @@ -14,9 +14,9 @@ from pydantic import Field load_dotenv() """ -OpenAI Responses Client with Explicit Settings Example +OpenAI Chat Completion Client with Explicit Settings Example -This sample demonstrates creating OpenAI Responses Client with explicit configuration +This sample demonstrates creating OpenAI Chat Completion Client with explicit configuration settings rather than relying on environment variable defaults. """ @@ -34,15 +34,13 @@ def get_weather( async def main() -> None: - print("=== OpenAI Responses Client with Explicit Settings ===") - - _client = OpenAIResponsesClient( - model=os.environ["OPENAI_MODEL"], - api_key=os.environ["OPENAI_API_KEY"], - ) + print("=== OpenAI Chat Completion Client with Explicit Settings ===") agent = Agent( - client=_client, + client=OpenAIChatCompletionClient( + model=os.environ["OPENAI_MODEL"], + api_key=os.environ["OPENAI_API_KEY"], + ), instructions="You are a helpful weather agent.", tools=get_weather, ) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_function_tools.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_function_tools.py similarity index 91% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_function_tools.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_function_tools.py index 55f0ed9e19..7bbc6b9744 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_function_tools.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_function_tools.py @@ -6,7 +6,7 @@ from random import randint from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv from pydantic import Field @@ -14,9 +14,9 @@ from pydantic import Field load_dotenv() """ -OpenAI Responses Client with Function Tools Example +OpenAI Chat Completion Client with Function Tools Example -This sample demonstrates function tool integration with OpenAI Responses Client, +This sample demonstrates function tool integration with OpenAI Chat Completion Client, showing both agent-level and query-level tool configuration patterns. """ @@ -47,7 +47,7 @@ async def tools_on_agent_level() -> None: # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful assistant that can provide weather and time information.", tools=[get_weather, get_time], # Tools defined at agent creation ) @@ -77,7 +77,7 @@ async def tools_on_run_level() -> None: # Agent created without tools agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful assistant.", # No tools defined here ) @@ -107,7 +107,7 @@ async def mixed_tools_example() -> None: # Agent created with some base tools agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatCompletionClient(), instructions="You are a comprehensive assistant that can help with various information requests.", tools=[get_weather], # Base tool available for all queries ) @@ -125,7 +125,7 @@ async def mixed_tools_example() -> None: async def main() -> None: - print("=== OpenAI Responses Client Agent with Function Tools Examples ===\n") + print("=== OpenAI Chat Completion Client Agent with Function Tools Examples ===\n") await tools_on_agent_level() await tools_on_run_level() diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_local_mcp.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_local_mcp.py similarity index 88% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_local_mcp.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_local_mcp.py index 00057dd76d..1c2387dc24 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_with_local_mcp.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_local_mcp.py @@ -3,17 +3,17 @@ import asyncio from agent_framework import Agent, MCPStreamableHTTPTool -from agent_framework.openai import OpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Chat Client with Local MCP Example +OpenAI Chat Completion Client with Local MCP Example This sample demonstrates integrating Model Context Protocol (MCP) tools with -OpenAI Chat Client for extended functionality and external service access. +OpenAI Chat Completion Client for extended functionality and external service access. The Agent Framework now supports enhanced metadata extraction from MCP tool results, including error states, token usage, costs, and other arbitrary @@ -34,7 +34,7 @@ async def mcp_tools_on_run_level() -> None: url="https://learn.microsoft.com/api/mcp", ) as mcp_server, Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", ) as agent, @@ -60,7 +60,7 @@ async def mcp_tools_on_agent_level() -> None: # The agent can use these tools for any query during its lifetime # The agent will connect to the MCP server through its context manager. async with Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", tools=MCPStreamableHTTPTool( # Tools defined at agent creation @@ -82,7 +82,7 @@ async def mcp_tools_on_agent_level() -> None: async def main() -> None: - print("=== OpenAI Chat Client Agent with MCP Tools Examples ===\n") + print("=== OpenAI Chat Completion Client Agent with MCP Tools Examples ===\n") await mcp_tools_on_agent_level() await mcp_tools_on_run_level() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_runtime_json_schema.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_runtime_json_schema.py similarity index 89% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_runtime_json_schema.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_runtime_json_schema.py index cdc4ce13fb..4cf5dfb844 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_runtime_json_schema.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_runtime_json_schema.py @@ -4,14 +4,14 @@ import asyncio import json from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatCompletionClient, OpenAIChatOptions from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Chat Client Runtime JSON Schema Example +OpenAI Chat Completion Client Runtime JSON Schema Example Demonstrates structured outputs when the schema is only known at runtime. Uses additional_chat_options to pass a JSON Schema payload directly to OpenAI @@ -38,7 +38,7 @@ async def non_streaming_example() -> None: print("=== Non-streaming runtime JSON schema example ===") agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatCompletionClient[OpenAIChatOptions](), name="RuntimeSchemaAgent", instructions="Return only JSON that matches the provided schema. Do not add commentary.", ) @@ -72,7 +72,7 @@ async def streaming_example() -> None: print("=== Streaming runtime JSON schema example ===") agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatCompletionClient(), name="RuntimeSchemaAgent", instructions="Return only JSON that matches the provided schema. Do not add commentary.", ) @@ -108,7 +108,7 @@ async def streaming_example() -> None: async def main() -> None: - print("=== OpenAI Chat Client with runtime JSON Schema ===") + print("=== OpenAI Chat Completion Client with runtime JSON Schema ===") await non_streaming_example() await streaming_example() diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_session.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_session.py similarity index 91% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_session.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_session.py index 773b18b3cc..99aac09e36 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_with_session.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_session.py @@ -5,7 +5,7 @@ from random import randint from typing import Annotated from agent_framework import Agent, AgentSession, InMemoryHistoryProvider, tool -from agent_framework.openai import OpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv from pydantic import Field @@ -13,9 +13,9 @@ from pydantic import Field load_dotenv() """ -OpenAI Chat Client with Session Management Example +OpenAI Chat Completion Client with Session Management Example -This sample demonstrates session management with OpenAI Chat Client, showing +This sample demonstrates session management with OpenAI Chat Completion Client, showing conversation sessions and message history preservation across interactions. """ @@ -37,7 +37,7 @@ async def example_with_automatic_session_creation() -> None: print("=== Automatic Session Creation Example ===") agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -62,7 +62,7 @@ async def example_with_session_persistence() -> None: print("Using the same session across multiple conversations to maintain context.\n") agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -95,7 +95,7 @@ async def example_with_existing_session_messages() -> None: print("=== Existing Session Messages Example ===") agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -118,7 +118,7 @@ async def example_with_existing_session_messages() -> None: # Create a new agent instance but use the existing session with its message history new_agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -142,7 +142,7 @@ async def example_with_existing_session_messages() -> None: async def main() -> None: - print("=== OpenAI Chat Client Agent Session Management Examples ===\n") + print("=== OpenAI Chat Completion Client Agent Session Management Examples ===\n") await example_with_automatic_session_creation() await example_with_session_persistence() diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_web_search.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_web_search.py similarity index 86% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_web_search.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_web_search.py index 623dc25f43..f5da9ba633 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_with_web_search.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_web_search.py @@ -3,22 +3,22 @@ import asyncio from agent_framework import Agent -from agent_framework.openai import OpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Chat Client with Web Search Example +OpenAI Chat Completion Client with Web Search Example -This sample demonstrates using get_web_search_tool() with OpenAI Chat Client +This sample demonstrates using get_web_search_tool() with OpenAI Chat Completion Client for real-time information retrieval and current data access. """ async def main() -> None: - client = OpenAIChatClient(model="gpt-4o-search-preview") + client = OpenAIChatCompletionClient(model="gpt-4o-search-preview") # Create web search tool with location context web_search_tool = client.get_web_search_tool( diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_basic.py b/python/samples/02-agents/providers/openai/client_basic.py similarity index 73% rename from python/samples/02-agents/providers/openai/openai_chat_client_basic.py rename to python/samples/02-agents/providers/openai/client_basic.py index d2834fe1e9..138f1e57b6 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_basic.py +++ b/python/samples/02-agents/providers/openai/client_basic.py @@ -1,12 +1,14 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from random import randint from typing import Annotated from agent_framework import Agent, tool from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv +from pydantic import Field # Load environment variables from .env file load_dotenv() @@ -14,17 +16,15 @@ load_dotenv() """ OpenAI Chat Client Basic Example -This sample demonstrates basic usage of OpenAIChatClient for direct chat-based -interactions, showing both streaming and non-streaming responses. +This sample demonstrates basic usage of OpenAIChatClient with explicit model and +API key settings, showing both streaming and non-streaming responses. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production. @tool(approval_mode="never_require") def get_weather( - location: Annotated[str, "The location to get the weather for."], + location: Annotated[str, Field(description="The location to get the weather for.")], ) -> str: """Get the weather for a given location.""" conditions = ["sunny", "cloudy", "rainy", "stormy"] @@ -36,13 +36,16 @@ async def non_streaming_example() -> None: print("=== Non-streaming Response Example ===") agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatClient( + model="gpt-5.4-nano", + api_key=os.getenv("OPENAI_API_KEY"), + ), name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) - query = "What's the weather like in Seattle?" + query = "What's the weather in Seattle?" print(f"User: {query}") result = await agent.run(query) print(f"Result: {result}\n") @@ -53,13 +56,16 @@ async def streaming_example() -> None: print("=== Streaming Response Example ===") agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatClient( + model="gpt-5.4-nano", + api_key=os.getenv("OPENAI_API_KEY"), + ), name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) - query = "What's the weather like in Portland?" + query = "What's the weather in Portland?" print(f"User: {query}") print("Agent: ", end="", flush=True) async for chunk in agent.run(query, stream=True): diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_image_analysis.py b/python/samples/02-agents/providers/openai/client_image_analysis.py similarity index 70% rename from python/samples/02-agents/providers/openai/openai_responses_client_image_analysis.py rename to python/samples/02-agents/providers/openai/client_image_analysis.py index 82fee38455..00c0207528 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_image_analysis.py +++ b/python/samples/02-agents/providers/openai/client_image_analysis.py @@ -3,26 +3,26 @@ import asyncio from agent_framework import Agent, Content -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client Image Analysis Example +OpenAI Chat Client Image Analysis Example -This sample demonstrates using OpenAI Responses Client for image analysis and vision tasks, +This sample demonstrates using OpenAI Chat Client for image analysis and vision tasks, showing multi-modal content handling with text and images. """ async def main(): - print("=== OpenAI Responses Agent with Image Analysis ===") + print("=== OpenAI Chat Client Agent with Image Analysis ===") - # 1. Create an OpenAI Responses agent with vision capabilities + # 1. Create an OpenAI Chat agent with vision capabilities agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="VisionAgent", instructions="You are a image analysist, you get a image and need to respond with what you see in the picture.", ) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_image_generation.py b/python/samples/02-agents/providers/openai/client_image_generation.py similarity index 90% rename from python/samples/02-agents/providers/openai/openai_responses_client_image_generation.py rename to python/samples/02-agents/providers/openai/client_image_generation.py index 6e01a4dbbd..84e50674d4 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_image_generation.py +++ b/python/samples/02-agents/providers/openai/client_image_generation.py @@ -7,17 +7,17 @@ import urllib.request as urllib_request from pathlib import Path from agent_framework import Agent, Content -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client Image Generation Example +OpenAI Chat Client Image Generation Example This sample demonstrates how to generate images using OpenAI's DALL-E models -through the Responses Client. Image generation capabilities enable AI to create visual content from text, +through the Chat Client. Image generation capabilities enable AI to create visual content from text, making it ideal for creative applications, content creation, design prototyping, and automated visual asset generation. """ @@ -57,10 +57,10 @@ def save_image(output: Content) -> None: async def main() -> None: - print("=== OpenAI Responses Image Generation Agent Example ===") + print("=== OpenAI Chat Image Generation Agent Example ===") # Create an agent with customized image generation options - client = OpenAIResponsesClient() + client = OpenAIChatClient() agent = Agent( client=client, instructions="You are a helpful AI that can generate images.", diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_reasoning.py b/python/samples/02-agents/providers/openai/client_reasoning.py similarity index 91% rename from python/samples/02-agents/providers/openai/openai_responses_client_reasoning.py rename to python/samples/02-agents/providers/openai/client_reasoning.py index a4fc3849b8..2eea8d2106 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_reasoning.py +++ b/python/samples/02-agents/providers/openai/client_reasoning.py @@ -3,14 +3,14 @@ import asyncio from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient, OpenAIResponsesOptions +from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client Reasoning Example +OpenAI Chat Client Reasoning Example This sample demonstrates advanced reasoning capabilities using OpenAI's gpt-5 models, showing step-by-step reasoning process visualization and complex problem-solving. @@ -25,7 +25,7 @@ In this case they are here: https://platform.openai.com/docs/api-reference/respo agent = Agent( - client=OpenAIResponsesClient[OpenAIResponsesOptions](model_id="gpt-5"), + client=OpenAIChatClient[OpenAIChatOptions](model_id="gpt-5"), name="MathHelper", instructions="You are a personal math tutor. When asked a math question, " "reason over how best to approach the problem and share your thought process.", @@ -76,7 +76,7 @@ async def streaming_reasoning_example() -> None: async def main() -> None: - print("\033[92m=== Basic OpenAI Responses Reasoning Agent Example ===\033[0m") + print("\033[92m=== Basic OpenAI Chat Reasoning Agent Example ===\033[0m") await reasoning_example() await streaming_reasoning_example() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_streaming_image_generation.py b/python/samples/02-agents/providers/openai/client_streaming_image_generation.py similarity index 96% rename from python/samples/02-agents/providers/openai/openai_responses_client_streaming_image_generation.py rename to python/samples/02-agents/providers/openai/client_streaming_image_generation.py index 7aafd6f704..412e6f8e6a 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_streaming_image_generation.py +++ b/python/samples/02-agents/providers/openai/client_streaming_image_generation.py @@ -7,12 +7,12 @@ from pathlib import Path import anyio from agent_framework import Agent, Content -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() -"""OpenAI Responses Client Streaming Image Generation Example +"""OpenAI Chat Client Streaming Image Generation Example Demonstrates streaming partial image generation using OpenAI's image generation tool. Shows progressive image rendering with partial images for improved user experience. Note: The number of partial images received depends on generation speed: @@ -42,7 +42,7 @@ async def main(): """Demonstrate streaming image generation with partial images.""" print("=== OpenAI Streaming Image Generation Example ===\n") # Create agent with streaming image generation enabled - client = OpenAIResponsesClient() + client = OpenAIChatClient() agent = Agent( client=client, instructions="You are a helpful agent that can generate images.", diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_agent_as_tool.py b/python/samples/02-agents/providers/openai/client_with_agent_as_tool.py similarity index 90% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_agent_as_tool.py rename to python/samples/02-agents/providers/openai/client_with_agent_as_tool.py index 567c7fcaef..d8a991242d 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_agent_as_tool.py +++ b/python/samples/02-agents/providers/openai/client_with_agent_as_tool.py @@ -4,14 +4,14 @@ import asyncio from collections.abc import Awaitable, Callable from agent_framework import Agent, FunctionInvocationContext -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client Agent-as-Tool Example +OpenAI Chat Client Agent-as-Tool Example Demonstrates hierarchical agent architectures where one agent delegates work to specialized sub-agents wrapped as tools using as_tool(). @@ -35,9 +35,9 @@ async def logging_middleware( async def main() -> None: - print("=== OpenAI Responses Client Agent-as-Tool Pattern ===") + print("=== OpenAI Chat Client Agent-as-Tool Pattern ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create a specialized writer agent writer = Agent( diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter.py b/python/samples/02-agents/providers/openai/client_with_code_interpreter.py similarity index 85% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter.py rename to python/samples/02-agents/providers/openai/client_with_code_interpreter.py index c3a8eba82a..f318c29419 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter.py +++ b/python/samples/02-agents/providers/openai/client_with_code_interpreter.py @@ -6,25 +6,25 @@ from agent_framework import ( Agent, Content, ) -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with Code Interpreter Example +OpenAI Chat Client with Code Interpreter Example -This sample demonstrates using get_code_interpreter_tool() with OpenAI Responses Client +This sample demonstrates using get_code_interpreter_tool() with OpenAI Chat Client for Python code execution and mathematical problem solving. """ async def main() -> None: - """Example showing how to use the code interpreter tool with OpenAI Responses.""" - print("=== OpenAI Responses Agent with Code Interpreter Example ===") + """Example showing how to use the code interpreter tool with OpenAI Chat.""" + print("=== OpenAI Chat Client Agent with Code Interpreter Example ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() agent = Agent( client=client, instructions="You are a helpful assistant that can write and execute Python code to solve problems.", diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter_files.py b/python/samples/02-agents/providers/openai/client_with_code_interpreter_files.py similarity index 92% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter_files.py rename to python/samples/02-agents/providers/openai/client_with_code_interpreter_files.py index 1636a22912..c286feff10 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter_files.py +++ b/python/samples/02-agents/providers/openai/client_with_code_interpreter_files.py @@ -5,7 +5,7 @@ import os import tempfile from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from openai import AsyncOpenAI @@ -13,9 +13,9 @@ from openai import AsyncOpenAI load_dotenv() """ -OpenAI Responses Client with Code Interpreter and Files Example +OpenAI Chat Client with Code Interpreter and Files Example -This sample demonstrates using get_code_interpreter_tool() with OpenAI Responses Client +This sample demonstrates using get_code_interpreter_tool() with OpenAI Chat Client for Python code execution and data analysis with uploaded files. """ @@ -69,8 +69,8 @@ async def main() -> None: temp_file_path, file_id = await create_sample_file_and_upload(openai_client) - # Create agent using OpenAI Responses client - client = OpenAIResponsesClient() + # Create agent using OpenAI Chat client + client = OpenAIChatClient() agent = Agent( client=client, instructions="You are a helpful assistant that can analyze data files using Python code.", diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_explicit_settings.py b/python/samples/02-agents/providers/openai/client_with_explicit_settings.py similarity index 100% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_explicit_settings.py rename to python/samples/02-agents/providers/openai/client_with_explicit_settings.py diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_file_search.py b/python/samples/02-agents/providers/openai/client_with_file_search.py similarity index 85% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_file_search.py rename to python/samples/02-agents/providers/openai/client_with_file_search.py index b6c9ac352f..042d888dff 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_file_search.py +++ b/python/samples/02-agents/providers/openai/client_with_file_search.py @@ -3,23 +3,23 @@ import asyncio from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with File Search Example +OpenAI Chat Client with File Search Example -This sample demonstrates using get_file_search_tool() with OpenAI Responses Client +This sample demonstrates using get_file_search_tool() with OpenAI Chat Client for direct document-based question answering and information retrieval. """ # Helper functions -async def create_vector_store(client: OpenAIResponsesClient) -> tuple[str, str]: +async def create_vector_store(client: OpenAIChatClient) -> tuple[str, str]: """Create a vector store with sample documents.""" file = await client.client.files.create( file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), purpose="user_data" @@ -35,14 +35,14 @@ async def create_vector_store(client: OpenAIResponsesClient) -> tuple[str, str]: return file.id, vector_store.id -async def delete_vector_store(client: OpenAIResponsesClient, file_id: str, vector_store_id: str) -> None: +async def delete_vector_store(client: OpenAIChatClient, file_id: str, vector_store_id: str) -> None: """Delete the vector store after using it.""" await client.client.vector_stores.delete(vector_store_id=vector_store_id) await client.client.files.delete(file_id=file_id) async def main() -> None: - client = OpenAIResponsesClient() + client = OpenAIChatClient() message = "What is the weather today? Do a file search to find the answer." diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_function_tools.py b/python/samples/02-agents/providers/openai/client_with_function_tools.py similarity index 100% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_function_tools.py rename to python/samples/02-agents/providers/openai/client_with_function_tools.py diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_hosted_mcp.py b/python/samples/02-agents/providers/openai/client_with_hosted_mcp.py similarity index 95% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_hosted_mcp.py rename to python/samples/02-agents/providers/openai/client_with_hosted_mcp.py index 45e7ae736a..ffcdadb8da 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_hosted_mcp.py +++ b/python/samples/02-agents/providers/openai/client_with_hosted_mcp.py @@ -4,7 +4,7 @@ import asyncio from typing import TYPE_CHECKING, Any from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv if TYPE_CHECKING: @@ -14,10 +14,10 @@ if TYPE_CHECKING: load_dotenv() """ -OpenAI Responses Client with Hosted MCP Example +OpenAI Chat Client with Hosted MCP Example This sample demonstrates integrating hosted Model Context Protocol (MCP) tools with -OpenAI Responses Client, including user approval workflows for function call security. +OpenAI Chat Client, including user approval workflows for function call security. """ @@ -102,7 +102,7 @@ async def run_hosted_mcp_without_session_and_specific_approval() -> None: """Example showing Mcp Tools with approvals without using a session.""" print("=== Mcp with approvals and without session ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create MCP tool with specific approval mode mcp_tool = client.get_mcp_tool( name="Microsoft Learn MCP", @@ -135,7 +135,7 @@ async def run_hosted_mcp_without_approval() -> None: """Example showing Mcp Tools without approvals.""" print("=== Mcp without approvals ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create MCP tool that never requires approval mcp_tool = client.get_mcp_tool( name="Microsoft Learn MCP", @@ -167,7 +167,7 @@ async def run_hosted_mcp_with_session() -> None: """Example showing Mcp Tools with approvals using a session.""" print("=== Mcp with approvals and with session ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create MCP tool that always requires approval mcp_tool = client.get_mcp_tool( name="Microsoft Learn MCP", @@ -200,7 +200,7 @@ async def run_hosted_mcp_with_session_streaming() -> None: """Example showing Mcp Tools with approvals using a session.""" print("=== Mcp with approvals and with session ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create MCP tool that always requires approval mcp_tool = client.get_mcp_tool( name="Microsoft Learn MCP", @@ -234,7 +234,7 @@ async def run_hosted_mcp_with_session_streaming() -> None: async def main() -> None: - print("=== OpenAI Responses Client Agent with Hosted Mcp Tools Examples ===\n") + print("=== OpenAI Chat Client Agent with Hosted Mcp Tools Examples ===\n") await run_hosted_mcp_without_approval() await run_hosted_mcp_without_session_and_specific_approval() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_local_mcp.py b/python/samples/02-agents/providers/openai/client_with_local_mcp.py similarity index 90% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_local_mcp.py rename to python/samples/02-agents/providers/openai/client_with_local_mcp.py index 8f136021bb..f7b14a24b2 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_local_mcp.py +++ b/python/samples/02-agents/providers/openai/client_with_local_mcp.py @@ -3,17 +3,17 @@ import asyncio from agent_framework import Agent, MCPStreamableHTTPTool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with Local MCP Example +OpenAI Chat Client with Local MCP Example This sample demonstrates integrating local Model Context Protocol (MCP) tools with -OpenAI Responses Client for direct response generation with external capabilities. +OpenAI Chat Client for direct response generation with external capabilities. """ @@ -27,7 +27,7 @@ async def streaming_with_mcp(show_raw_stream: bool = False) -> None: # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime async with Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", tools=MCPStreamableHTTPTool( # Tools defined at agent creation @@ -65,7 +65,7 @@ async def run_with_mcp() -> None: # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime async with Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", tools=MCPStreamableHTTPTool( # Tools defined at agent creation @@ -87,7 +87,7 @@ async def run_with_mcp() -> None: async def main() -> None: - print("=== OpenAI Responses Client Agent with Function Tools Examples ===\n") + print("=== OpenAI Chat Client Agent with Function Tools Examples ===\n") await run_with_mcp() await streaming_with_mcp() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py b/python/samples/02-agents/providers/openai/client_with_local_shell.py similarity index 96% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py rename to python/samples/02-agents/providers/openai/client_with_local_shell.py index b3135702a7..f4829cc5e7 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py +++ b/python/samples/02-agents/providers/openai/client_with_local_shell.py @@ -5,14 +5,14 @@ import subprocess from typing import Any from agent_framework import Agent, Message, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with Local Shell Tool Example +OpenAI Chat Client with Local Shell Tool Example This sample demonstrates implementing a local shell tool using get_shell_tool(func=...) that wraps Python's subprocess module. Unlike the hosted shell tool (get_shell_tool()), @@ -53,7 +53,7 @@ async def main() -> None: print("=== OpenAI Agent with Local Shell Tool Example ===") print("NOTE: Commands will execute on your local machine.\n") - client = OpenAIResponsesClient() + client = OpenAIChatClient() local_shell_tool = client.get_shell_tool( func=run_bash, ) diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_runtime_json_schema.py b/python/samples/02-agents/providers/openai/client_with_runtime_json_schema.py similarity index 95% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_runtime_json_schema.py rename to python/samples/02-agents/providers/openai/client_with_runtime_json_schema.py index ba21d0a325..3fcffeae6c 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_with_runtime_json_schema.py +++ b/python/samples/02-agents/providers/openai/client_with_runtime_json_schema.py @@ -4,7 +4,7 @@ import asyncio import json from agent_framework import Agent -from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -38,7 +38,7 @@ async def non_streaming_example() -> None: print("=== Non-streaming runtime JSON schema example ===") agent = Agent( - client=OpenAIChatClient[OpenAIChatOptions](), + client=OpenAIChatClient(), name="RuntimeSchemaAgent", instructions="Return only JSON that matches the provided schema. Do not add commentary.", ) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_session.py b/python/samples/02-agents/providers/openai/client_with_session.py similarity index 93% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_session.py rename to python/samples/02-agents/providers/openai/client_with_session.py index e62c3bdaea..0dffeaef6c 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_session.py +++ b/python/samples/02-agents/providers/openai/client_with_session.py @@ -5,7 +5,7 @@ from random import randint from typing import Annotated from agent_framework import Agent, AgentSession, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import Field @@ -13,9 +13,9 @@ from pydantic import Field load_dotenv() """ -OpenAI Responses Client with Session Management Example +OpenAI Chat Client with Session Management Example -This sample demonstrates session management with OpenAI Responses Client, showing +This sample demonstrates session management with OpenAI Chat Client, showing persistent conversation context and simplified response handling. """ @@ -37,7 +37,7 @@ async def example_with_automatic_session_creation() -> None: print("=== Automatic Session Creation Example ===") agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -64,7 +64,7 @@ async def example_with_session_persistence_in_memory() -> None: print("=== Session Persistence Example (In-Memory) ===") agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -103,7 +103,7 @@ async def example_with_existing_session_id() -> None: existing_session_id = None agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -124,7 +124,7 @@ async def example_with_existing_session_id() -> None: print("\n--- Continuing with the same session ID in a new agent instance ---") agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py b/python/samples/02-agents/providers/openai/client_with_shell.py similarity index 82% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py rename to python/samples/02-agents/providers/openai/client_with_shell.py index b86f36fde5..5043d8e4a1 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py +++ b/python/samples/02-agents/providers/openai/client_with_shell.py @@ -3,16 +3,16 @@ import asyncio from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with Shell Tool Example +OpenAI Chat Client with Shell Tool Example -This sample demonstrates using get_shell_tool() with OpenAI Responses Client +This sample demonstrates using get_shell_tool() with OpenAI Chat Client for executing shell commands in a managed container environment hosted by OpenAI. The shell tool allows the model to run commands like listing files, running scripts, @@ -21,10 +21,10 @@ or performing system operations within a secure, sandboxed container. async def main() -> None: - """Example showing how to use the shell tool with OpenAI Responses.""" - print("=== OpenAI Responses Agent with Shell Tool Example ===") + """Example showing how to use the shell tool with OpenAI Chat.""" + print("=== OpenAI Chat Client Agent with Shell Tool Example ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create a hosted shell tool with the default auto container environment shell_tool = client.get_shell_tool() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_structured_output.py b/python/samples/02-agents/providers/openai/client_with_structured_output.py similarity index 87% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_structured_output.py rename to python/samples/02-agents/providers/openai/client_with_structured_output.py index d2599c0bd8..57bade4412 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_structured_output.py +++ b/python/samples/02-agents/providers/openai/client_with_structured_output.py @@ -3,7 +3,7 @@ import asyncio from agent_framework import Agent, AgentResponse -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import BaseModel @@ -11,9 +11,9 @@ from pydantic import BaseModel load_dotenv() """ -OpenAI Responses Client with Structured Output Example +OpenAI Chat Client with Structured Output Example -This sample demonstrates using structured output capabilities with OpenAI Responses Client, +This sample demonstrates using structured output capabilities with OpenAI Chat Client, showing Pydantic model integration for type-safe response parsing and data extraction. """ @@ -28,9 +28,9 @@ class OutputStruct(BaseModel): async def non_streaming_example() -> None: print("=== Non-streaming example ===") - # Create an OpenAI Responses agent + # Create an OpenAI Chat agent agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="CityAgent", instructions="You are a helpful agent that describes cities in a structured format.", ) @@ -54,9 +54,9 @@ async def non_streaming_example() -> None: async def streaming_example() -> None: print("=== Streaming example ===") - # Create an OpenAI Responses agent + # Create an OpenAI Chat agent agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="CityAgent", instructions="You are a helpful agent that describes cities in a structured format.", ) @@ -82,7 +82,7 @@ async def streaming_example() -> None: async def main() -> None: - print("=== OpenAI Responses Agent with Structured Output ===") + print("=== OpenAI Chat Client Agent with Structured Output ===") await non_streaming_example() await streaming_example() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_web_search.py b/python/samples/02-agents/providers/openai/client_with_web_search.py similarity index 88% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_web_search.py rename to python/samples/02-agents/providers/openai/client_with_web_search.py index 9f22807ba9..d0bab5a87a 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_web_search.py +++ b/python/samples/02-agents/providers/openai/client_with_web_search.py @@ -3,22 +3,22 @@ import asyncio from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with Web Search Example +OpenAI Chat Client with Web Search Example -This sample demonstrates using get_web_search_tool() with OpenAI Responses Client +This sample demonstrates using get_web_search_tool() with OpenAI Chat Client for direct real-time information retrieval and current data access. """ async def main() -> None: - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create web search tool with location context web_search_tool = client.get_web_search_tool( diff --git a/python/samples/02-agents/providers/openai/openai_assistants_basic.py b/python/samples/02-agents/providers/openai/openai_assistants_basic.py deleted file mode 100644 index 5901b6ef38..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_basic.py +++ /dev/null @@ -1,98 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants Basic Example - -This sample demonstrates basic usage of OpenAIAssistantProvider with automatic -assistant lifecycle management, showing both streaming and non-streaming responses. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -async def non_streaming_example() -> None: - """Example of non-streaming response (get the complete result at once).""" - print("=== Non-streaming Response Example ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Create a new assistant via the provider - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - - try: - query = "What's the weather like in Seattle?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - finally: - # Clean up the assistant from OpenAI - await client.beta.assistants.delete(agent.id) - - -async def streaming_example() -> None: - """Example of streaming response (get results as they are generated).""" - print("=== Streaming Response Example ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Create a new assistant via the provider - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - - try: - query = "What's the weather like in Portland?" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - async for chunk in agent.run(query, stream=True): - if chunk.text: - print(chunk.text, end="", flush=True) - print("\n") - finally: - # Clean up the assistant from OpenAI - await client.beta.assistants.delete(agent.id) - - -async def main() -> None: - print("=== Basic OpenAI Assistants Provider Example ===") - - await non_streaming_example() - await streaming_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py b/python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py deleted file mode 100644 index 0cc9d33f73..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py +++ /dev/null @@ -1,158 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import Agent, tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistant Provider Methods Example - -This sample demonstrates the methods available on the OpenAIAssistantProvider class: -- create_agent(): Create a new assistant on the service -- get_agent(): Retrieve an existing assistant by ID -- as_agent(): Wrap an SDK Assistant object without making HTTP calls -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -async def create_agent_example() -> None: - """Create a new assistant using provider.create_agent().""" - print("\n--- create_agent() ---") - - async with ( - AsyncOpenAI() as client, - OpenAIAssistantProvider(client) as provider, - ): - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather assistant.", - tools=[get_weather], - ) - - try: - print(f"Created: {agent.name} (ID: {agent.id})") - result = await agent.run("What's the weather in Seattle?") - print(f"Response: {result}") - finally: - await client.beta.assistants.delete(agent.id) - - -async def get_agent_example() -> None: - """Retrieve an existing assistant by ID using provider.get_agent().""" - print("\n--- get_agent() ---") - - async with ( - AsyncOpenAI() as client, - OpenAIAssistantProvider(client) as provider, - ): - # Create an assistant directly with SDK (simulating pre-existing assistant) - sdk_assistant = await client.beta.assistants.create( - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - name="ExistingAssistant", - instructions="You always respond with 'Hello!'", - ) - - try: - # Retrieve using provider - agent = await provider.get_agent(sdk_assistant.id) - print(f"Retrieved: {agent.name} (ID: {agent.id})") - - result = await agent.run("Hi there!") - print(f"Response: {result}") - finally: - await client.beta.assistants.delete(sdk_assistant.id) - - -async def as_agent_example() -> None: - """Wrap an SDK Assistant object using Agent(client=provider, ...).""" - print("\n--- as_agent() ---") - - async with ( - AsyncOpenAI() as client, - OpenAIAssistantProvider(client) as provider, - ): - # Create assistant using SDK - sdk_assistant = await client.beta.assistants.create( - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - name="WrappedAssistant", - instructions="You respond with poetry.", - ) - - try: - # Wrap synchronously (no HTTP call) - agent = Agent(client=provider, agent=sdk_assistant) - print(f"Wrapped: {agent.name} (ID: {agent.id})") - - result = await agent.run("Tell me about the sunset.") - print(f"Response: {result}") - finally: - await client.beta.assistants.delete(sdk_assistant.id) - - -async def multiple_agents_example() -> None: - """Create and manage multiple assistants with a single provider.""" - print("\n--- Multiple Agents ---") - - async with ( - AsyncOpenAI() as client, - OpenAIAssistantProvider(client) as provider, - ): - weather_agent = await provider.create_agent( - name="WeatherSpecialist", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a weather specialist.", - tools=[get_weather], - ) - - greeter_agent = await provider.create_agent( - name="GreeterAgent", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a friendly greeter.", - ) - - try: - print(f"Created: {weather_agent.name}, {greeter_agent.name}") - - greeting = await greeter_agent.run("Hello!") - print(f"Greeter: {greeting}") - - weather = await weather_agent.run("What's the weather in Tokyo?") - print(f"Weather: {weather}") - finally: - await client.beta.assistants.delete(weather_agent.id) - await client.beta.assistants.delete(greeter_agent.id) - - -async def main() -> None: - print("OpenAI Assistant Provider Methods") - - await create_agent_example() - await get_agent_example() - await as_agent_example() - await multiple_agents_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py b/python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py deleted file mode 100644 index 044804e3c5..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os - -from agent_framework import AgentResponseUpdate, ChatResponseUpdate -from agent_framework.openai import OpenAIAssistantProvider, OpenAIAssistantsClient -from dotenv import load_dotenv -from openai import AsyncOpenAI -from openai.types.beta.threads.runs import ( - CodeInterpreterToolCallDelta, - RunStepDelta, - RunStepDeltaEvent, - ToolCallDeltaObject, -) -from openai.types.beta.threads.runs.code_interpreter_tool_call_delta import CodeInterpreter - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with Code Interpreter Example - -This sample demonstrates using get_code_interpreter_tool() with OpenAI Assistants -for Python code execution and mathematical problem solving. -""" - - -def get_code_interpreter_chunk(chunk: AgentResponseUpdate) -> str | None: - """Helper method to access code interpreter data.""" - if ( - isinstance(chunk.raw_representation, ChatResponseUpdate) - and isinstance(chunk.raw_representation.raw_representation, RunStepDeltaEvent) - and isinstance(chunk.raw_representation.raw_representation.delta, RunStepDelta) - and isinstance(chunk.raw_representation.raw_representation.delta.step_details, ToolCallDeltaObject) - and chunk.raw_representation.raw_representation.delta.step_details.tool_calls - ): - for tool_call in chunk.raw_representation.raw_representation.delta.step_details.tool_calls: - if ( - isinstance(tool_call, CodeInterpreterToolCallDelta) - and isinstance(tool_call.code_interpreter, CodeInterpreter) - and tool_call.code_interpreter.input is not None - ): - return tool_call.code_interpreter.input - return None - - -async def main() -> None: - """Example showing how to use the code interpreter tool with OpenAI Assistants.""" - print("=== OpenAI Assistants Provider with Code Interpreter Example ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - chat_client = OpenAIAssistantsClient(client=client) - - agent = await provider.create_agent( - name="CodeHelper", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant that can write and execute Python code to solve problems.", - tools=[chat_client.get_code_interpreter_tool()], - ) - - try: - query = "Use code to get the factorial of 100?" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - generated_code = "" - async for chunk in agent.run(query, stream=True): - if chunk.text: - print(chunk.text, end="", flush=True) - code_interpreter_chunk = get_code_interpreter_chunk(chunk) - if code_interpreter_chunk is not None: - generated_code += code_interpreter_chunk - - print(f"\nGenerated code:\n{generated_code}") - finally: - await client.beta.assistants.delete(agent.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py b/python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py deleted file mode 100644 index 563dbb38a4..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py +++ /dev/null @@ -1,118 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import Agent, tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with Existing Assistant Example - -This sample demonstrates working with pre-existing OpenAI Assistants -using the provider's get_agent() and as_agent() methods. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -async def example_get_agent_by_id() -> None: - """Example: Using get_agent() to retrieve an existing assistant by ID.""" - print("=== Get Existing Assistant by ID ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Create an assistant via SDK (simulating an existing assistant) - created_assistant = await client.beta.assistants.create( - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - name="WeatherAssistant", - tools=[ - { - "type": "function", - "function": { - "name": "get_weather", - "description": "Get the weather for a given location.", - "parameters": { - "type": "object", - "properties": {"location": {"type": "string", "description": "The location"}}, - "required": ["location"], - }, - }, - } - ], - ) - print(f"Created assistant: {created_assistant.id}") - - try: - # Use get_agent() to retrieve the existing assistant - agent = await provider.get_agent( - assistant_id=created_assistant.id, - tools=[get_weather], # Required: implementation for function tools - instructions="You are a helpful weather agent.", - ) - - result = await agent.run("What's the weather like in Tokyo?") - print(f"Agent: {result}\n") - finally: - await client.beta.assistants.delete(created_assistant.id) - print("Assistant deleted.\n") - - -async def example_as_agent_wrap_sdk_object() -> None: - """Example: Using as_agent() to wrap an existing SDK Assistant object.""" - print("=== Wrap Existing SDK Assistant Object ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Create and fetch an assistant via SDK - created_assistant = await client.beta.assistants.create( - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - name="SimpleAssistant", - instructions="You are a friendly assistant.", - ) - print(f"Created assistant: {created_assistant.id}") - - try: - # Use as_agent() to wrap the SDK object - agent = Agent( - client=provider, - agent=created_assistant, - instructions="You are an extremely helpful assistant. Be enthusiastic!", - ) - - result = await agent.run("Hello! What can you help me with?") - print(f"Agent: {result}\n") - finally: - await client.beta.assistants.delete(created_assistant.id) - print("Assistant deleted.\n") - - -async def main() -> None: - print("=== OpenAI Assistants Provider with Existing Assistant Examples ===\n") - - await example_get_agent_by_id() - await example_as_agent_wrap_sdk_object() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py b/python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py deleted file mode 100644 index d7adef004c..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with Explicit Settings Example - -This sample demonstrates creating OpenAI Assistants with explicit configuration -settings rather than relying on environment variable defaults. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -async def main() -> None: - print("=== OpenAI Assistants Provider with Explicit Settings ===") - - # Create client with explicit API key - client = AsyncOpenAI(api_key=os.environ["OPENAI_API_KEY"]) - provider = OpenAIAssistantProvider(client) - - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ["OPENAI_MODEL"], - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - - try: - query = "What's the weather like in New York?" - print(f"Query: {query}") - result = await agent.run(query) - print(f"Result: {result}\n") - finally: - await client.beta.assistants.delete(agent.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py b/python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py deleted file mode 100644 index ad67986d4e..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os - -from agent_framework import Content -from agent_framework.openai import OpenAIAssistantProvider, OpenAIAssistantsClient -from dotenv import load_dotenv -from openai import AsyncOpenAI - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with File Search Example - -This sample demonstrates using get_file_search_tool() with OpenAI Assistants -for document-based question answering and information retrieval. -""" - - -async def create_vector_store(client: AsyncOpenAI) -> tuple[str, Content]: - """Create a vector store with sample documents.""" - file = await client.files.create( - file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), purpose="user_data" - ) - vector_store = await client.vector_stores.create( - name="knowledge_base", - expires_after={"anchor": "last_active_at", "days": 1}, - ) - result = await client.vector_stores.files.create_and_poll(vector_store_id=vector_store.id, file_id=file.id) - if result.last_error is not None: - raise Exception(f"Vector store file processing failed with status: {result.last_error.message}") - - return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id) - - -async def delete_vector_store(client: AsyncOpenAI, file_id: str, vector_store_id: str) -> None: - """Delete the vector store after using it.""" - await client.vector_stores.delete(vector_store_id=vector_store_id) - await client.files.delete(file_id=file_id) - - -async def main() -> None: - print("=== OpenAI Assistants Provider with File Search Example ===\n") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - chat_client = OpenAIAssistantsClient(client=client) - - agent = await provider.create_agent( - name="SearchAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant that searches files in a knowledge base.", - tools=[chat_client.get_file_search_tool()], - ) - - try: - query = "What is the weather today? Do a file search to find the answer." - file_id, vector_store_content = await create_vector_store(client) - - print(f"User: {query}") - print("Agent: ", end="", flush=True) - async for chunk in agent.run( - query, - stream=True, - options={"tool_resources": {"file_search": {"vector_store_ids": [vector_store_content.vector_store_id]}}}, - ): - if chunk.text: - print(chunk.text, end="", flush=True) - - await delete_vector_store(client, file_id, vector_store_content.vector_store_id) - finally: - await client.beta.assistants.delete(agent.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py b/python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py deleted file mode 100644 index ffd64d9ca2..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py +++ /dev/null @@ -1,159 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from datetime import datetime, timezone -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with Function Tools Example - -This sample demonstrates function tool integration with OpenAI Assistants, -showing both agent-level and query-level tool configuration patterns. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -@tool(approval_mode="never_require") -def get_time() -> str: - """Get the current UTC time.""" - current_time = datetime.now(timezone.utc) - return f"The current UTC time is {current_time.strftime('%Y-%m-%d %H:%M:%S')}." - - -async def tools_on_agent_level() -> None: - """Example showing tools defined when creating the agent.""" - print("=== Tools Defined on Agent Level ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Tools are provided when creating the agent - # The agent can use these tools for any query during its lifetime - agent = await provider.create_agent( - name="InfoAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant that can provide weather and time information.", - tools=[get_weather, get_time], # Tools defined at agent creation - ) - - try: - # First query - agent can use weather tool - query1 = "What's the weather like in New York?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1}\n") - - # Second query - agent can use time tool - query2 = "What's the current UTC time?" - print(f"User: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2}\n") - - # Third query - agent can use both tools if needed - query3 = "What's the weather in London and what's the current UTC time?" - print(f"User: {query3}") - result3 = await agent.run(query3) - print(f"Agent: {result3}\n") - finally: - await client.beta.assistants.delete(agent.id) - - -async def tools_on_run_level() -> None: - """Example showing tools passed to the run method.""" - print("=== Tools Passed to Run Method ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Agent created with base tools, additional tools can be passed at run time - agent = await provider.create_agent( - name="FlexibleAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant.", - tools=[get_weather], # Base tool - ) - - try: - # First query using base weather tool - query1 = "What's the weather like in Seattle?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1}\n") - - # Second query with additional time tool - query2 = "What's the current UTC time?" - print(f"User: {query2}") - result2 = await agent.run(query2, tools=[get_time]) # Additional tool for this query - print(f"Agent: {result2}\n") - - # Third query with both tools - query3 = "What's the weather in Chicago and what's the current UTC time?" - print(f"User: {query3}") - result3 = await agent.run(query3, tools=[get_time]) # Time tool adds to weather - print(f"Agent: {result3}\n") - finally: - await client.beta.assistants.delete(agent.id) - - -async def mixed_tools_example() -> None: - """Example showing both agent-level tools and run-method tools.""" - print("=== Mixed Tools Example (Agent + Run Method) ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Agent created with some base tools - agent = await provider.create_agent( - name="ComprehensiveAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a comprehensive assistant that can help with various information requests.", - tools=[get_weather], # Base tool available for all queries - ) - - try: - # Query using both agent tool and additional run-method tools - query = "What's the weather in Denver and what's the current UTC time?" - print(f"User: {query}") - - # Agent has access to get_weather (from creation) + additional tools from run method - result = await agent.run( - query, - tools=[get_time], # Additional tools for this specific query - ) - print(f"Agent: {result}\n") - finally: - await client.beta.assistants.delete(agent.id) - - -async def main() -> None: - print("=== OpenAI Assistants Provider with Function Tools Examples ===\n") - - await tools_on_agent_level() - await tools_on_run_level() - await mixed_tools_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py b/python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py deleted file mode 100644 index 740b36107d..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py +++ /dev/null @@ -1,96 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os - -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import BaseModel, ConfigDict - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistant Provider Response Format Example - -This sample demonstrates using OpenAIAssistantProvider with response_format -for structured outputs in two ways: -1. Setting default response_format at agent creation time (default_options) -2. Overriding response_format at runtime (options parameter in agent.run) -""" - - -class WeatherInfo(BaseModel): - """Structured weather information.""" - - location: str - temperature: int - conditions: str - recommendation: str - model_config = ConfigDict(extra="forbid") - - -class CityInfo(BaseModel): - """Structured city information.""" - - city_name: str - population: int - country: str - model_config = ConfigDict(extra="forbid") - - -async def main() -> None: - """Example of using response_format at creation time and runtime.""" - - async with ( - AsyncOpenAI() as client, - OpenAIAssistantProvider(client) as provider, - ): - # Create agent with default response_format (WeatherInfo) - agent = await provider.create_agent( - name="StructuredReporter", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="Return structured JSON based on the requested format.", - default_options={"response_format": WeatherInfo}, - ) - - try: - # Request 1: Uses default response_format from agent creation - print("--- Request 1: Using default response_format (WeatherInfo) ---") - query1 = "What's the weather like in Paris today?" - print(f"User: {query1}") - - result1 = await agent.run(query1) - - try: - weather = result1.value - print("Agent:") - print(f" Location: {weather.location}") - print(f" Temperature: {weather.temperature}") - print(f" Conditions: {weather.conditions}") - print(f" Recommendation: {weather.recommendation}") - except Exception: - print(f"Failed to parse response: {result1.text}") - - # Request 2: Override response_format at runtime with CityInfo - print("\n--- Request 2: Runtime override with CityInfo ---") - query2 = "Tell me about Tokyo." - print(f"User: {query2}") - - result2 = await agent.run(query2, options={"response_format": CityInfo}) - - try: - city = result2.value - print("Agent:") - print(f" City: {city.city_name}") - print(f" Population: {city.population}") - print(f" Country: {city.country}") - except Exception: - print(f"Failed to parse response: {result2.text}") - finally: - await client.beta.assistants.delete(agent.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_session.py b/python/samples/02-agents/providers/openai/openai_assistants_with_session.py deleted file mode 100644 index 2259c5638d..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_session.py +++ /dev/null @@ -1,172 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import AgentSession, tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with Session Management Example - -This sample demonstrates session management with OpenAI Assistants, showing -persistent conversation sessions and context preservation across interactions. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -async def example_with_automatic_session_creation() -> None: - """Example showing automatic session creation (service-managed session).""" - print("=== Automatic Session Creation Example ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - - try: - # First conversation - no session provided, will be created automatically - query1 = "What's the weather like in Seattle?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1.text}") - - # Second conversation - still no session provided, will create another new session - query2 = "What was the last city I asked about?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2.text}") - print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") - finally: - await client.beta.assistants.delete(agent.id) - - -async def example_with_session_persistence() -> None: - """Example showing session persistence across multiple conversations.""" - print("=== Session Persistence Example ===") - print("Using the same session across multiple conversations to maintain context.\n") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - - try: - # Create a new session that will be reused - session = agent.create_session() - - # First conversation - query1 = "What's the weather like in Tokyo?" - print(f"User: {query1}") - result1 = await agent.run(query1, session=session) - print(f"Agent: {result1.text}") - - # Second conversation using the same session - maintains context - query2 = "How about London?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2, session=session) - print(f"Agent: {result2.text}") - - # Third conversation - agent should remember both previous cities - query3 = "Which of the cities I asked about has better weather?" - print(f"\nUser: {query3}") - result3 = await agent.run(query3, session=session) - print(f"Agent: {result3.text}") - print("Note: The agent remembers context from previous messages in the same session.\n") - finally: - await client.beta.assistants.delete(agent.id) - - -async def example_with_existing_session_id() -> None: - """Example showing how to work with an existing session ID from the service.""" - print("=== Existing Session ID Example ===") - print("Using a specific session ID to continue an existing conversation.\n") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # First, create a conversation and capture the session ID - existing_session_id = None - assistant_id = None - - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - assistant_id = agent.id - - try: - # Start a conversation and get the session ID - session = agent.create_session() - query1 = "What's the weather in Paris?" - print(f"User: {query1}") - result1 = await agent.run(query1, session=session) - print(f"Agent: {result1.text}") - - # The session ID is set after the first response - existing_session_id = session.service_session_id - print(f"Session ID: {existing_session_id}") - - if existing_session_id: - print("\n--- Continuing with the same session ID using get_agent ---") - - # Get the existing assistant by ID - agent2 = await provider.get_agent( - assistant_id=assistant_id, - tools=[get_weather], # Must provide function implementations - ) - - # Create a session with the existing ID - session = AgentSession(service_session_id=existing_session_id) - - query2 = "What was the last city I asked about?" - print(f"User: {query2}") - result2 = await agent2.run(query2, session=session) - print(f"Agent: {result2.text}") - print("Note: The agent continues the conversation from the previous session.\n") - finally: - if assistant_id: - await client.beta.assistants.delete(assistant_id) - - -async def main() -> None: - print("=== OpenAI Assistants Provider Session Management Examples ===\n") - - await example_with_automatic_session_creation() - await example_with_session_persistence() - await example_with_existing_session_id() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_basic.py b/python/samples/02-agents/providers/openai/openai_responses_client_basic.py deleted file mode 100644 index c615cf3252..0000000000 --- a/python/samples/02-agents/providers/openai/openai_responses_client_basic.py +++ /dev/null @@ -1,132 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from collections.abc import Awaitable, Callable -from random import randint -from typing import Annotated - -from agent_framework import ( - Agent, - ChatContext, - ChatResponse, - Message, - MiddlewareTermination, - Role, - chat_middleware, - tool, -) -from agent_framework.openai import OpenAIResponsesClient -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Responses Client Basic Example - -This sample demonstrates basic usage of OpenAIResponsesClient for structured -response generation, showing both streaming and non-streaming responses. -""" - - -@chat_middleware -async def security_and_override_middleware( - context: ChatContext, - call_next: Callable[[], Awaitable[None]], -) -> None: - """Function-based middleware that implements security filtering and response override.""" - print("[SecurityMiddleware] Processing input...") - - # Security check - block sensitive information - blocked_terms = ["password", "secret", "api_key", "token"] - - for message in context.messages: - if message.text: - message_lower = message.text.lower() - for term in blocked_terms: - if term in message_lower: - print(f"[SecurityMiddleware] BLOCKED: Found '{term}' in message") - - # Override the response instead of calling AI - context.result = ChatResponse( - messages=[ - Message( - role=Role.ASSISTANT, - text="I cannot process requests containing sensitive information. " - "Please rephrase your question without including passwords, secrets, or other " - "sensitive data.", - ) - ] - ) - - # Terminate middleware execution with the blocked response - raise MiddlewareTermination(result=context.result) - - # Continue to next middleware or AI execution - await call_next() - - print("[SecurityMiddleware] Response generated.") - print(type(context.result)) - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." - - -async def non_streaming_example() -> None: - """Example of non-streaming response (get the complete result at once).""" - print("=== Non-streaming Response Example ===") - - agent = Agent( - client=OpenAIResponsesClient(), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - query = "What's the weather like in Seattle?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Result: {result}\n") - - -async def streaming_example() -> None: - """Example of streaming response (get results as they are generated).""" - print("=== Streaming Response Example ===") - - agent = Agent( - client=OpenAIResponsesClient( - middleware=[security_and_override_middleware], - ), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - query = "What's the weather like in Portland?" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - response = agent.run(query, stream=True) - async for chunk in response: - if chunk.text: - print(chunk.text, end="", flush=True) - print("\n") - print(f"Final Result: {await response.get_final_response()}") - - -async def main() -> None: - print("=== Basic OpenAI Responses Client Agent Example ===") - - await streaming_example() - await non_streaming_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/05-end-to-end/m365-agent/.env.example b/python/samples/05-end-to-end/m365-agent/.env.example index 3c21a9e91c..100c2bf69d 100644 --- a/python/samples/05-end-to-end/m365-agent/.env.example +++ b/python/samples/05-end-to-end/m365-agent/.env.example @@ -1,6 +1,6 @@ # OpenAI Configuration OPENAI_API_KEY= -OPENAI_CHAT_MODEL_ID= +OPENAI_CHAT_MODEL= # Agent 365 Agentic Authentication Configuration USE_ANONYMOUS_MODE= diff --git a/python/samples/05-end-to-end/m365-agent/README.md b/python/samples/05-end-to-end/m365-agent/README.md index ecd1e6f632..6962a53229 100644 --- a/python/samples/05-end-to-end/m365-agent/README.md +++ b/python/samples/05-end-to-end/m365-agent/README.md @@ -21,7 +21,7 @@ export USE_ANONYMOUS_MODE=True # set to false if using auth # OpenAI export OPENAI_API_KEY="..." -export OPENAI_CHAT_MODEL_ID="..." +export OPENAI_CHAT_MODEL="..." ``` ## Installing Dependencies diff --git a/python/samples/README.md b/python/samples/README.md index fa091b78bc..82a008504c 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -61,6 +61,16 @@ client = OpenAIChatClient(env_file_path="path/to/custom.env") This allows different clients to use different configuration files if needed. +For the generic OpenAI clients (`OpenAIChatClient` and `OpenAIChatCompletionClient`), routing +precedence is: + +1. Explicit Azure inputs such as `credential`, `azure_endpoint`, or `api_version` +2. `OPENAI_API_KEY` / explicit OpenAI API-key parameters +3. Azure environment fallback such as `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_API_KEY` + +If you keep both OpenAI and Azure variables in your shell, the generic clients stay on OpenAI until +you pass an explicit Azure input. + For the getting-started samples, you'll need at minimum: ```bash AZURE_AI_PROJECT_ENDPOINT="your-foundry-project-endpoint" From 6b47cdbf5254ddd4e676dd8a9f29256a17f53891 Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:27:19 -0700 Subject: [PATCH 25/31] Python: Fix broken samples for GitHub Copilot, declarative, and Responses API (#4915) * Python: Fix broken samples for GitHub Copilot, declarative, and Responses API - Add missing on_permission_request handler to github_copilot_basic and github_copilot_with_session samples (required by copilot SDK) - Increase timeout for remote MCP query in github_copilot_with_mcp sample - Soften session isolation claim in github_copilot_with_session sample - Fix inline_yaml sample: pass project_endpoint via client_kwargs instead of relying on YAML connection block (AzureAIClient expects project_endpoint, not endpoint) - Handle raw JSON schemas in Responses client _convert_response_format so declarative outputSchema works with the Responses API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Improve raw JSON schema detection heuristic and add tests - Broaden raw schema detection to handle anyOf, oneOf, allOf, $ref, $defs keywords and JSON Schema primitive types, not just 'properties' - Apply same raw schema handling to azure-ai _shared.py for consistency - Add unit tests for both openai and azure-ai response_format conversion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_azure_ai/_shared.py | 21 ++++++ python/packages/azure-ai/tests/test_shared.py | 26 ++++++++ .../agent_framework_openai/_chat_client.py | 21 ++++++ .../tests/openai/test_openai_chat_client.py | 65 +++++++++++++++++++ .../02-agents/declarative/inline_yaml.py | 14 ++-- .../github_copilot/github_copilot_basic.py | 18 +++++ .../github_copilot/github_copilot_with_mcp.py | 3 +- .../github_copilot_with_session.py | 21 +++++- 8 files changed, 181 insertions(+), 8 deletions(-) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py index 7f5f770e36..220640d270 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py @@ -571,4 +571,25 @@ def _convert_response_format(response_format: Mapping[str, Any]) -> dict[str, An if format_type in {"json_object", "text"}: return {"type": format_type} + # Handle raw JSON schemas (e.g. {"type": "object", "properties": {...}}) + # by wrapping them in the expected json_schema envelope. + # Detect by checking for JSON Schema primitive types or known schema keywords. + json_schema_keywords = {"properties", "anyOf", "oneOf", "allOf", "$ref", "$defs"} + json_schema_primitive_types = {"object", "array", "string", "number", "integer", "boolean", "null"} + if format_type in json_schema_primitive_types or ( + format_type is None and any(k in response_format for k in json_schema_keywords) + ): + schema = dict(response_format) + if schema.get("type") == "object" and "additionalProperties" not in schema: + schema["additionalProperties"] = False + # Pop title from schema since OpenAI strict mode rejects unknown keys; + # use it as the schema name in the envelope instead. + name = str(schema.pop("title", None) or "response") + return { + "type": "json_schema", + "name": name, + "schema": schema, + "strict": True, + } + raise IntegrationInvalidRequestException("Unsupported response_format provided for Azure AI client.") diff --git a/python/packages/azure-ai/tests/test_shared.py b/python/packages/azure-ai/tests/test_shared.py index 5672561194..845638ceee 100644 --- a/python/packages/azure-ai/tests/test_shared.py +++ b/python/packages/azure-ai/tests/test_shared.py @@ -404,6 +404,32 @@ def test_convert_response_format_json_schema_missing_schema_raises() -> None: _convert_response_format({"type": "json_schema", "json_schema": {}}) +def test_convert_response_format_raw_json_schema_with_properties() -> None: + """Test raw JSON schema with properties is wrapped in json_schema envelope.""" + result = _convert_response_format({"type": "object", "properties": {"x": {"type": "string"}}, "title": "MyOutput"}) + + assert result["type"] == "json_schema" + assert result["name"] == "MyOutput" + assert result["strict"] is True + assert result["schema"]["additionalProperties"] is False + assert "title" not in result["schema"] + + +def test_convert_response_format_raw_json_schema_no_title() -> None: + """Test raw JSON schema without title defaults name to 'response'.""" + result = _convert_response_format({"type": "object", "properties": {"x": {"type": "string"}}}) + + assert result["name"] == "response" + + +def test_convert_response_format_raw_json_schema_with_anyof() -> None: + """Test raw JSON schema with anyOf keyword is detected.""" + result = _convert_response_format({"anyOf": [{"type": "string"}, {"type": "number"}]}) + + assert result["type"] == "json_schema" + assert result["strict"] is True + + def test_from_azure_ai_tools_mcp_approval_mode_always() -> None: """Test from_azure_ai_tools converts MCP require_approval='always' to dict.""" tools = [ diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index b0d56ee26f..54322cb754 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -636,6 +636,27 @@ class RawOpenAIChatClient( # type: ignore[misc] if format_type in {"json_object", "text"}: return {"type": format_type} + # Handle raw JSON schemas (e.g. {"type": "object", "properties": {...}}) + # by wrapping them in the expected json_schema envelope. + # Detect by checking for JSON Schema primitive types or known schema keywords. + json_schema_keywords = {"properties", "anyOf", "oneOf", "allOf", "$ref", "$defs"} + json_schema_primitive_types = {"object", "array", "string", "number", "integer", "boolean", "null"} + if format_type in json_schema_primitive_types or ( + format_type is None and any(k in response_format for k in json_schema_keywords) + ): + schema = dict(response_format) + if schema.get("type") == "object" and "additionalProperties" not in schema: + schema["additionalProperties"] = False + # Pop title from schema since OpenAI strict mode rejects unknown keys; + # use it as the schema name in the envelope instead. + name = str(schema.pop("title", None) or "response") + return { + "type": "json_schema", + "name": name, + "schema": schema, + "strict": True, + } + raise ChatClientInvalidRequestException("Unsupported response_format provided for Responses client.") def _get_conversation_id( diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 3c09839594..d4e7b6c4a1 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -1713,6 +1713,71 @@ def test_response_format_json_schema_missing_schema() -> None: client._prepare_response_and_text_format(response_format=response_format, text_config=None) +def test_response_format_raw_json_schema_with_properties() -> None: + """Test raw JSON schema with properties is wrapped in json_schema envelope.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + response_format = {"type": "object", "properties": {"x": {"type": "string"}}, "title": "MyOutput"} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + fmt = text_config["format"] + assert fmt["type"] == "json_schema" + assert fmt["name"] == "MyOutput" + assert fmt["strict"] is True + assert fmt["schema"]["additionalProperties"] is False + assert "title" not in fmt["schema"] + + +def test_response_format_raw_json_schema_no_title() -> None: + """Test raw JSON schema without title defaults name to 'response'.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + response_format = {"type": "object", "properties": {"x": {"type": "string"}}} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert text_config["format"]["name"] == "response" + + +def test_response_format_raw_json_schema_preserves_additional_properties() -> None: + """Test raw JSON schema preserves existing additionalProperties.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + response_format = {"type": "object", "properties": {"x": {"type": "string"}}, "additionalProperties": True} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert text_config["format"]["schema"]["additionalProperties"] is True + + +def test_response_format_raw_json_schema_non_object_type() -> None: + """Test raw JSON schema with non-object type does not inject additionalProperties.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + response_format = {"type": "array", "items": {"type": "string"}} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert "additionalProperties" not in text_config["format"]["schema"] + + +def test_response_format_raw_json_schema_with_anyof() -> None: + """Test raw JSON schema with anyOf keyword is detected.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + response_format = {"anyOf": [{"type": "string"}, {"type": "number"}]} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert text_config["format"]["type"] == "json_schema" + + def test_response_format_unsupported_type() -> None: """Test unsupported response_format type raises error.""" client = OpenAIChatClient(model="test-model", api_key="test-key") diff --git a/python/samples/02-agents/declarative/inline_yaml.py b/python/samples/02-agents/declarative/inline_yaml.py index b6d4c9ede4..016f75947f 100644 --- a/python/samples/02-agents/declarative/inline_yaml.py +++ b/python/samples/02-agents/declarative/inline_yaml.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from agent_framework.declarative import AgentFactory from azure.identity.aio import AzureCliCredential @@ -31,16 +32,17 @@ description: A agent that performs diagnostics on systems and can escalate issue model: id: =Env.AZURE_OPENAI_MODEL - connection: - kind: remote - endpoint: =Env.FOUNDRY_PROJECT_ENDPOINT """ # create the agent from the yaml async with ( AzureCliCredential() as credential, - AgentFactory(client_kwargs={"credential": credential}, safe_mode=False).create_agent_from_yaml( - yaml_definition - ) as agent, + AgentFactory( + client_kwargs={ + "credential": credential, + "project_endpoint": os.environ["FOUNDRY_PROJECT_ENDPOINT"], + }, + safe_mode=False, + ).create_agent_from_yaml(yaml_definition) as agent, ): response = await agent.run("What can you do for me?") print("Agent response:", response.text) diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py b/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py index 3a2c4c8eec..af59ae9b3b 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py @@ -19,6 +19,8 @@ from typing import Annotated from agent_framework import tool from agent_framework.github import GitHubCopilotAgent +from copilot.generated.session_events import PermissionRequest +from copilot.types import PermissionRequestResult from dotenv import load_dotenv from pydantic import Field @@ -26,6 +28,19 @@ from pydantic import Field load_dotenv() +def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + """Permission handler that prompts the user for approval.""" + print(f"\n[Permission Request: {request.kind}]") + + if request.full_command_text is not None: + print(f" Command: {request.full_command_text}") + + response = input("Approve? (y/n): ").strip().lower() + if response in ("y", "yes"): + return PermissionRequestResult(kind="approved") + return PermissionRequestResult(kind="denied-interactively-by-user") + + # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; # see samples/02-agents/tools/function_tool_with_approval.py # and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @@ -45,6 +60,7 @@ async def non_streaming_example() -> None: agent = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent: @@ -61,6 +77,7 @@ async def streaming_example() -> None: agent = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent: @@ -80,6 +97,7 @@ async def runtime_options_example() -> None: agent = GitHubCopilotAgent( instructions="Always respond in exactly 3 words.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent: diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py index fde1e8b72e..081dd1b21f 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py @@ -69,9 +69,10 @@ async def main() -> None: print(f"Agent: {result1}\n") # Query that exercises the remote Microsoft Learn MCP server + # Remote MCP calls may take longer, so increase the timeout query2 = "Search Microsoft Learn for 'Azure Functions Python' and summarize the top result" print(f"User: {query2}") - result2 = await agent.run(query2) + result2 = await agent.run(query2, options={"timeout": 120}) print(f"Agent: {result2}\n") diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py index 644104dc35..758eccb015 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py @@ -14,9 +14,24 @@ from typing import Annotated from agent_framework import tool from agent_framework.github import GitHubCopilotAgent +from copilot.generated.session_events import PermissionRequest +from copilot.types import PermissionRequestResult from pydantic import Field +def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + """Permission handler that prompts the user for approval.""" + print(f"\n[Permission Request: {request.kind}]") + + if request.full_command_text is not None: + print(f" Command: {request.full_command_text}") + + response = input("Approve? (y/n): ").strip().lower() + if response in ("y", "yes"): + return PermissionRequestResult(kind="approved") + return PermissionRequestResult(kind="denied-interactively-by-user") + + # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; # see samples/02-agents/tools/function_tool_with_approval.py # and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @@ -36,6 +51,7 @@ async def example_with_automatic_session_creation() -> None: agent = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent: @@ -50,7 +66,7 @@ async def example_with_automatic_session_creation() -> None: print(f"\nUser: {query2}") result2 = await agent.run(query2) print(f"Agent: {result2}") - print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") + print("Note: Each call creates a separate session, so the agent may not remember previous context.\n") async def example_with_session_persistence() -> None: @@ -60,6 +76,7 @@ async def example_with_session_persistence() -> None: agent = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent: @@ -96,6 +113,7 @@ async def example_with_existing_session_id() -> None: agent1 = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent1: @@ -117,6 +135,7 @@ async def example_with_existing_session_id() -> None: agent2 = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent2: From ca6cdd142e7f1c80edd581b2dddf26801253683c Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:38:23 +0000 Subject: [PATCH 26/31] .NET: Fixes for durable agents integration tests (#4952) * Fixing for durable agents integration tests * Add further fixes --- .../06_LongRunningTools/FunctionTriggers.cs | 11 ++++++----- .../ConsoleApps/06_LongRunningTools/Program.cs | 11 ++++++----- .../ConsoleApps/07_ReliableStreaming/Program.cs | 1 + .../SamplesValidation.cs | 2 +- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/FunctionTriggers.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/FunctionTriggers.cs index ed66be8bdd..b707240ba8 100644 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/FunctionTriggers.cs +++ b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/FunctionTriggers.cs @@ -35,13 +35,15 @@ public static class FunctionTriggers int iterationCount = 0; while (iterationCount++ < input.MaxReviewAttempts) { + // NOTE: CustomStatus has a 16 KB UTF-16 limit in Durable Functions. + // Only include short metadata here - the full content is passed via activity inputs/outputs. context.SetCustomStatus( new { message = "Requesting human feedback.", approvalTimeoutHours = input.ApprovalTimeoutHours, iterationCount, - content + contentTitle = content.Title, }); // Step 2: Notify user to review the content @@ -63,7 +65,6 @@ public static class FunctionTriggers { message = $"Human approval timed out after {input.ApprovalTimeoutHours} hour(s). Treating as rejection.", iterationCount, - content }); throw new TimeoutException($"Human approval timed out after {input.ApprovalTimeoutHours} hour(s)."); } @@ -73,7 +74,7 @@ public static class FunctionTriggers context.SetCustomStatus(new { message = "Content approved by human reviewer. Publishing content...", - content + contentTitle = content.Title, }); // Step 4: Publish the approved content @@ -83,7 +84,7 @@ public static class FunctionTriggers { message = $"Content published successfully at {context.CurrentUtcDateTime:s}", humanFeedback = humanResponse, - content + contentTitle = content.Title, }); return new { content = content.Content }; } @@ -92,7 +93,7 @@ public static class FunctionTriggers { message = "Content rejected by human reviewer. Incorporating feedback and regenerating...", humanFeedback = humanResponse, - content + contentTitle = content.Title, }); // Incorporate human feedback and regenerate diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/Program.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/Program.cs index 203edca308..7bb593ab29 100644 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/Program.cs +++ b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/Program.cs @@ -77,13 +77,15 @@ static async Task RunOrchestratorAsync(TaskOrchestrationContext context, int iterationCount = 0; while (iterationCount++ < input.MaxReviewAttempts) { + // NOTE: CustomStatus has a 16 KB UTF-16 limit in Durable Functions. + // Only include short metadata here - the full content is passed via activity inputs/outputs. context.SetCustomStatus( new { message = "Requesting human feedback.", approvalTimeoutHours = input.ApprovalTimeoutHours, iterationCount, - content + contentTitle = content.Title, }); // Step 2: Notify user to review the content @@ -105,7 +107,6 @@ static async Task RunOrchestratorAsync(TaskOrchestrationContext context, { message = $"Human approval timed out after {input.ApprovalTimeoutHours} hour(s). Treating as rejection.", iterationCount, - content }); throw new TimeoutException($"Human approval timed out after {input.ApprovalTimeoutHours} hour(s)."); } @@ -115,7 +116,7 @@ static async Task RunOrchestratorAsync(TaskOrchestrationContext context, context.SetCustomStatus(new { message = "Content approved by human reviewer. Publishing content...", - content + contentTitle = content.Title, }); // Step 4: Publish the approved content @@ -125,7 +126,7 @@ static async Task RunOrchestratorAsync(TaskOrchestrationContext context, { message = $"Content published successfully at {context.CurrentUtcDateTime:s}", humanFeedback = humanResponse, - content + contentTitle = content.Title, }); return new { content = content.Content }; } @@ -134,7 +135,7 @@ static async Task RunOrchestratorAsync(TaskOrchestrationContext context, { message = "Content rejected by human reviewer. Incorporating feedback and regenerating...", humanFeedback = humanResponse, - content + contentTitle = content.Title, }); // Incorporate human feedback and regenerate diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/Program.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/Program.cs index 3abc5c8701..9be3f4f659 100644 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/Program.cs +++ b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/Program.cs @@ -285,6 +285,7 @@ async Task ReadStreamTask(string conversationId, string? cursor, CancellationTok if (chunk.Text != null) { Console.Write(chunk.Text); + Console.Out.Flush(); } // Always update lastCursor to track the latest entry ID, even if text is null diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs index b15f6e8f42..ffd2e39ae2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs @@ -35,7 +35,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi .Build(); private static bool s_infrastructureStarted; - private static readonly TimeSpan s_orchestrationTimeout = TimeSpan.FromMinutes(1); + private static readonly TimeSpan s_orchestrationTimeout = TimeSpan.FromMinutes(2); // In CI, `dotnet run` builds the Functions project from scratch before the host starts, so 60s is not enough. private static readonly TimeSpan s_functionsReadyTimeout = TimeSpan.FromSeconds(180); From b1b528e4a8a1da53f61ad231197ae8b85ed7bb3a Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 27 Mar 2026 22:00:12 +0100 Subject: [PATCH 27/31] Python: [BREAKING] Remove deprecated kwargs compatibility paths (#4858) * [BREAKING] Remove deprecated kwargs compatibility paths Remove the deprecated kwargs compatibility shims across core agents, clients, tools, middleware, and telemetry. Keep workflow kwargs behavior intact in this branch and follow up separately in #4850. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix PR CI fallout for kwargs removal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * updates * Fix Azure AI CI fallout Remove the stale _get_current_conversation_id override from the Azure AI client after the OpenAI base helper was deleted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fixed new classes * Fix Assistants deprecated import gating Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix integration replay regressions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Switch multi-agent hosting samples to Azure chat completions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Simplify Azure multi-agent sample config Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/python-integration-tests.yml | 1 + .github/workflows/python-merge-tests.yml | 1 + python/packages/a2a/tests/test_a2a_agent.py | 16 +- .../_agent_provider.py | 5 +- .../agent_framework_azure_ai/_client.py | 5 - .../_deprecated_azure_openai.py | 8 +- .../_project_provider.py | 7 +- .../test_azure_responses_client.py | 8 +- .../claude/agent_framework_claude/_agent.py | 74 +++- .../packages/core/agent_framework/_agents.py | 107 +++--- .../packages/core/agent_framework/_clients.py | 49 +-- python/packages/core/agent_framework/_mcp.py | 3 +- .../core/agent_framework/_middleware.py | 51 ++- .../packages/core/agent_framework/_tools.py | 77 ++-- .../_workflows/_agent_executor.py | 8 +- .../core/agent_framework/observability.py | 123 +++--- .../packages/core/tests/core/test_agents.py | 20 +- .../packages/core/tests/core/test_clients.py | 98 +---- .../core/test_function_invocation_logic.py | 48 ++- .../test_kwargs_propagation_to_ai_function.py | 351 ------------------ python/packages/core/tests/core/test_mcp.py | 24 +- .../tests/core/test_middleware_with_agent.py | 81 ++-- .../tests/core/test_middleware_with_chat.py | 72 ++-- .../core/tests/core/test_observability.py | 16 +- python/packages/core/tests/core/test_tools.py | 35 +- python/packages/devui/tests/devui/conftest.py | 12 +- .../devui/tests/devui/test_execution.py | 2 +- .../agent_framework_durabletask/_entities.py | 19 +- .../tests/test_durable_entities.py | 50 +++ .../foundry/agent_framework_foundry/_agent.py | 140 +++++-- .../agent_framework_foundry/_chat_client.py | 33 +- .../tests/foundry/test_foundry_agent.py | 54 +++ .../tests/foundry/test_foundry_chat_client.py | 21 ++ .../_foundry_local_client.py | 96 ++++- .../tests/test_foundry_local_client.py | 10 + .../tau2/agent_framework_lab_tau2/runner.py | 4 +- .../openai/agent_framework_openai/__init__.py | 2 +- .../_assistant_provider.py | 4 +- .../_assistants_client.py | 12 +- .../agent_framework_openai/_chat_client.py | 97 +++-- .../_chat_completion_client.py | 67 +++- .../_embedding_client.py | 10 +- .../openai/test_openai_assistants_client.py | 27 ++ .../tests/openai/test_openai_chat_client.py | 56 ++- .../openai/test_openai_chat_client_azure.py | 6 +- .../test_openai_chat_completion_client.py | 43 ++- ...test_openai_chat_completion_client_base.py | 4 +- .../openai/test_openai_embedding_client.py | 9 + .../02_multi_agent/function_app.py | 13 +- .../durabletask/02_multi_agent/client.py | 2 +- .../durabletask/02_multi_agent/sample.py | 2 +- .../durabletask/02_multi_agent/worker.py | 24 +- 52 files changed, 1136 insertions(+), 971 deletions(-) delete mode 100644 python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index 2d12425312..df2fda5cb2 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -210,6 +210,7 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FUNCTIONS_WORKER_RUNTIME: "python" diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index f32fceccb5..453e4335a6 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -341,6 +341,7 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FUNCTIONS_WORKER_RUNTIME: "python" diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index 0d81179cd1..58a82f6d8c 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -366,7 +366,7 @@ def test_get_uri_data_invalid_uri() -> None: def test_parse_contents_from_a2a_conversion(a2a_agent: A2AAgent) -> None: """Test A2A parts to contents conversion.""" - agent = A2AAgent(name="Test Agent", client=MockA2AClient(), _http_client=None) + agent = A2AAgent(name="Test Agent", client=MockA2AClient(), http_client=None) # Create A2A parts parts = [Part(root=TextPart(text="First part")), Part(root=TextPart(text="Second part"))] @@ -485,7 +485,7 @@ async def test_context_manager_no_cleanup_when_no_http_client() -> None: mock_a2a_client = MagicMock() - agent = A2AAgent(client=mock_a2a_client, _http_client=None) + agent = A2AAgent(client=mock_a2a_client, http_client=None) # This should not raise any errors async with agent: @@ -495,7 +495,7 @@ async def test_context_manager_no_cleanup_when_no_http_client() -> None: def test_prepare_message_for_a2a_with_multiple_contents() -> None: """Test conversion of Message with multiple contents.""" - agent = A2AAgent(client=MagicMock(), _http_client=None) + agent = A2AAgent(client=MagicMock(), http_client=None) # Create message with multiple content types message = Message( @@ -523,7 +523,7 @@ def test_prepare_message_for_a2a_with_multiple_contents() -> None: def test_prepare_message_for_a2a_forwards_context_id() -> None: """Test conversion of Message preserves context_id without duplicating it in metadata.""" - agent = A2AAgent(client=MagicMock(), _http_client=None) + agent = A2AAgent(client=MagicMock(), http_client=None) message = Message( role="user", @@ -540,7 +540,7 @@ def test_prepare_message_for_a2a_forwards_context_id() -> None: def test_parse_contents_from_a2a_with_data_part() -> None: """Test conversion of A2A DataPart.""" - agent = A2AAgent(client=MagicMock(), _http_client=None) + agent = A2AAgent(client=MagicMock(), http_client=None) # Create DataPart data_part = Part(root=DataPart(data={"key": "value", "number": 42}, metadata={"source": "test"})) @@ -556,7 +556,7 @@ def test_parse_contents_from_a2a_with_data_part() -> None: def test_parse_contents_from_a2a_unknown_part_kind() -> None: """Test error handling for unknown A2A part kind.""" - agent = A2AAgent(client=MagicMock(), _http_client=None) + agent = A2AAgent(client=MagicMock(), http_client=None) # Create a mock part with unknown kind mock_part = MagicMock() @@ -569,7 +569,7 @@ def test_parse_contents_from_a2a_unknown_part_kind() -> None: def test_prepare_message_for_a2a_with_hosted_file() -> None: """Test conversion of Message with HostedFileContent to A2A message.""" - agent = A2AAgent(client=MagicMock(), _http_client=None) + agent = A2AAgent(client=MagicMock(), http_client=None) # Create message with hosted file content message = Message( @@ -595,7 +595,7 @@ def test_prepare_message_for_a2a_with_hosted_file() -> None: def test_parse_contents_from_a2a_with_hosted_file_uri() -> None: """Test conversion of A2A FilePart with hosted file URI back to UriContent.""" - agent = A2AAgent(client=MagicMock(), _http_client=None) + agent = A2AAgent(client=MagicMock(), http_client=None) # Create FilePart with hosted file URI (simulating what A2A would send back) file_part = Part( diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py index 51589a0d84..a43702d8b3 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py @@ -445,6 +445,8 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]): # Merge tools: convert agent's hosted tools + user-provided function tools merged_tools = self._merge_tools(agent.tools, provided_tools) + merged_default_options: dict[str, Any] = dict(default_options) if default_options is not None else {} + merged_default_options.setdefault("model_id", agent.model) return Agent( # type: ignore[return-value] client=client, @@ -452,9 +454,8 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]): name=agent.name, description=agent.description, instructions=agent.instructions, - model_id=agent.model, tools=merged_tools, - default_options=default_options, # type: ignore[arg-type] + default_options=cast(Any, merged_default_options), middleware=middleware, context_providers=context_providers, ) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py index 9f96cf0c3a..f4ceb92b5b 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -603,11 +603,6 @@ class RawAzureAIClient(RawOpenAIChatClient[AzureAIClientOptionsT], Generic[Azure return transformed - @override - def _get_current_conversation_id(self, options: Mapping[str, Any], **kwargs: Any) -> str | None: - """Get the current conversation ID from chat options or kwargs.""" - return options.get("conversation_id") or kwargs.get("conversation_id") or self.conversation_id - @override def _parse_response_from_openai( self, diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py b/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py index 21f50e930a..8a3a9833ac 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py @@ -24,7 +24,10 @@ from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT, APP_INFO, pre from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer from agent_framework._types import Annotation, Content from agent_framework.observability import ChatTelemetryLayer, EmbeddingTelemetryLayer -from agent_framework_openai._assistants_client import OpenAIAssistantsClient, OpenAIAssistantsOptions +from agent_framework_openai._assistants_client import ( + OpenAIAssistantsClient, # type: ignore[reportDeprecated] + OpenAIAssistantsOptions, +) from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from agent_framework_openai._chat_completion_client import OpenAIChatCompletionOptions, RawOpenAIChatCompletionClient from agent_framework_openai._embedding_client import OpenAIEmbeddingOptions, RawOpenAIEmbeddingClient @@ -673,7 +676,8 @@ AzureOpenAIAssistantsOptions = OpenAIAssistantsOptions "Use OpenAIAssistantsClient (also deprecated) or migrate to OpenAIChatClient." ) class AzureOpenAIAssistantsClient( - OpenAIAssistantsClient[AzureOpenAIAssistantsOptionsT], Generic[AzureOpenAIAssistantsOptionsT] + OpenAIAssistantsClient[AzureOpenAIAssistantsOptionsT], # type: ignore[reportDeprecated] + Generic[AzureOpenAIAssistantsOptionsT], ): """Deprecated Azure OpenAI Assistants client. Use OpenAIAssistantsClient or migrate to OpenAIChatClient.""" diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py index b4c948efa3..c0430fd47c 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging import sys from collections.abc import Callable, Mapping, MutableMapping, Sequence -from typing import Any, Generic +from typing import Any, Generic, cast from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, @@ -398,6 +398,8 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]): # from_azure_ai_tools converts hosted tools (MCP, code interpreter, file search, web search) # but function tools need the actual implementations from provided_tools merged_tools = self._merge_tools(details.definition.tools, provided_tools) + merged_default_options: dict[str, Any] = dict(default_options) if default_options is not None else {} + merged_default_options.setdefault("model_id", details.definition.model) return Agent( # type: ignore[return-value] client=client, @@ -405,9 +407,8 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]): name=details.name, description=details.description, instructions=details.definition.instructions, - model_id=details.definition.model, tools=merged_tools, - default_options=default_options, # type: ignore[arg-type] + default_options=cast(Any, merged_default_options), middleware=middleware, context_providers=context_providers, ) diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py index 99bd2061b7..da2c346d49 100644 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py @@ -477,7 +477,9 @@ async def test_integration_client_agent_existing_session(): ) as first_agent: # Start a conversation and capture the session session = first_agent.create_session() - first_response = await first_agent.run("My hobby is photography. Remember this.", session=session, store=True) + first_response = await first_agent.run( + "My hobby is photography. Remember this.", session=session, options={"store": True} + ) assert isinstance(first_response, AgentResponse) assert first_response.text is not None @@ -492,7 +494,9 @@ async def test_integration_client_agent_existing_session(): instructions="You are a helpful assistant with good memory.", ) as second_agent: # Reuse the preserved session - second_response = await second_agent.run("What is my hobby?", session=preserved_session) + second_response = await second_agent.run( + "What is my hobby?", session=preserved_session, options={"store": True} + ) assert isinstance(second_response, AgentResponse) assert second_response.text is not None diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index 23703b2c53..dd30a3b2d2 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -7,7 +7,7 @@ import logging import sys from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, overload +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast, overload from agent_framework import ( AgentMiddlewareTypes, @@ -584,7 +584,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]): return AgentResponse.from_updates(updates, value=structured_output) @overload - def run( + def run( # type: ignore[override] self, messages: AgentRunInputs | None = None, *, @@ -595,7 +595,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]): ) -> Awaitable[AgentResponse[Any]]: ... @overload - def run( + def run( # type: ignore[override] self, messages: AgentRunInputs | None = None, *, @@ -747,3 +747,71 @@ class ClaudeAgent(AgentTelemetryLayer, RawClaudeAgent[OptionsT], Generic[Options response = await agent.run("Hello!") print(response.text) """ + + @overload # type: ignore[override] + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[False] = ..., + session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + options: OptionsT | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + compaction_strategy: Any = None, + tokenizer: Any = None, + function_invocation_kwargs: dict[str, Any] | None = None, + client_kwargs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]]: ... + + @overload # type: ignore[override] + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[True], + session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + options: OptionsT | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + compaction_strategy: Any = None, + tokenizer: Any = None, + function_invocation_kwargs: dict[str, Any] | None = None, + client_kwargs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + + def run( # pyright: ignore[reportIncompatibleMethodOverride] # type: ignore[override] + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + options: OptionsT | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + compaction_strategy: Any = None, + tokenizer: Any = None, + function_invocation_kwargs: dict[str, Any] | None = None, + client_kwargs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + """Run the Claude agent with telemetry enabled.""" + super_run = cast( + "Callable[..., Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]]", + super().run, + ) + return super_run( + messages=messages, + stream=stream, + session=session, + middleware=middleware, + options=options, + tools=tools, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, + **kwargs, + ) diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 27a6a45747..1868742111 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -5,7 +5,6 @@ from __future__ import annotations import logging import re import sys -import warnings from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence from contextlib import AbstractAsyncContextManager, AsyncExitStack from copy import deepcopy @@ -248,7 +247,6 @@ class SupportsAgentRun(Protocol): session: AgentSession | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: """Get a response from the agent (non-streaming).""" ... @@ -262,7 +260,6 @@ class SupportsAgentRun(Protocol): session: AgentSession | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Get a streaming response from the agent.""" ... @@ -275,7 +272,6 @@ class SupportsAgentRun(Protocol): session: AgentSession | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Get a response from the agent. @@ -291,7 +287,6 @@ class SupportsAgentRun(Protocol): session: The conversation session associated with the message(s). function_invocation_kwargs: Keyword arguments forwarded to tool invocation. client_kwargs: Additional client-specific keyword arguments. - kwargs: Additional keyword arguments. Returns: When stream=False: An AgentResponse with the final result. @@ -334,7 +329,15 @@ class BaseAgent(SerializationMixin): # Create a concrete subclass that implements the protocol class SimpleAgent(BaseAgent): - async def run(self, messages=None, *, stream=False, session=None, **kwargs): + async def run( + self, + messages=None, + *, + stream=False, + session=None, + function_invocation_kwargs=None, + client_kwargs=None, + ): if stream: async def _stream(): @@ -373,7 +376,6 @@ class BaseAgent(SerializationMixin): context_providers: Sequence[BaseContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, additional_properties: MutableMapping[str, Any] | None = None, - **kwargs: Any, ) -> None: """Initialize a BaseAgent instance. @@ -385,15 +387,7 @@ class BaseAgent(SerializationMixin): context_providers: Context providers to include during agent invocation. middleware: List of middleware. additional_properties: Additional properties set on the agent. - kwargs: Additional keyword arguments (merged into additional_properties). """ - if kwargs: - warnings.warn( - "Passing additional properties as direct keyword arguments to BaseAgent is deprecated; " - "pass them via additional_properties instead.", - DeprecationWarning, - stacklevel=3, - ) if id is None: id = str(uuid4()) self.id = id @@ -403,10 +397,7 @@ class BaseAgent(SerializationMixin): self.middleware: list[MiddlewareTypes] | None = ( cast(list[MiddlewareTypes], middleware) if middleware is not None else None ) - - # Merge kwargs into additional_properties self.additional_properties: dict[str, Any] = cast(dict[str, Any], additional_properties or {}) - self.additional_properties.update(kwargs) def create_session(self, *, session_id: str | None = None) -> AgentSession: """Create a new lightweight session. @@ -666,9 +657,10 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, default_options: OptionsCoT | None = None, context_providers: Sequence[BaseContextProvider] | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + additional_properties: MutableMapping[str, Any] | None = None, ) -> None: """Initialize a Agent instance. @@ -695,7 +687,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] If both this and a compaction_strategy on the underlying client are set, this one is used. tokenizer: Optional agent-level tokenizer. If both this and a tokenizer on the underlying client are set, this one is used. - kwargs: Any additional keyword arguments. Will be stored as ``additional_properties``. + additional_properties: Additional properties stored on the agent. """ opts = dict(default_options) if default_options else {} @@ -709,7 +701,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] name=name, description=description, context_providers=context_providers, - **kwargs, + middleware=middleware, + additional_properties=additional_properties, ) self.client = client self.compaction_strategy = compaction_strategy @@ -812,7 +805,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[ResponseModelBoundT]]: ... @overload @@ -828,7 +820,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @overload @@ -844,7 +835,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... def run( @@ -859,7 +849,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Run the agent with the given messages and options. @@ -890,21 +879,12 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] is used, falling back to the client default. function_invocation_kwargs: Keyword arguments forwarded to tool invocation. client_kwargs: Additional client-specific keyword arguments for the chat client. - kwargs: Deprecated additional keyword arguments for the agent. - They are forwarded to both tool invocation and the chat client for compatibility. Returns: When stream=False: An Awaitable[AgentResponse] containing the agent's response. When stream=True: A ResponseStream of AgentResponseUpdate items with ``get_final_response()`` for the final AgentResponse. """ - if kwargs: - warnings.warn( - "Passing runtime keyword arguments directly to run() is deprecated; pass tool values via " - "function_invocation_kwargs and client-specific values via client_kwargs instead.", - DeprecationWarning, - stacklevel=2, - ) if not stream: async def _run_non_streaming() -> AgentResponse[Any]: @@ -915,7 +895,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options=options, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - legacy_kwargs=kwargs, function_invocation_kwargs=function_invocation_kwargs, client_kwargs=client_kwargs, ) @@ -1003,7 +982,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options=options, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - legacy_kwargs=kwargs, function_invocation_kwargs=function_invocation_kwargs, client_kwargs=client_kwargs, ) @@ -1103,7 +1081,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options: Mapping[str, Any] | None, compaction_strategy: CompactionStrategy | None, tokenizer: TokenizerProtocol | None, - legacy_kwargs: Mapping[str, Any], function_invocation_kwargs: Mapping[str, Any] | None, client_kwargs: Mapping[str, Any] | None, ) -> _RunContext: @@ -1176,12 +1153,9 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] duplicate_error_message=mcp_duplicate_message, ) - # TODO(Copilot): Delete once direct ``run(**kwargs)`` compatibility is removed. - # Legacy compatibility still fans out direct run kwargs into tool runtime kwargs. - effective_function_invocation_kwargs = { - **dict(legacy_kwargs), - **(dict(function_invocation_kwargs) if function_invocation_kwargs is not None else {}), - } + effective_function_invocation_kwargs = ( + dict(function_invocation_kwargs) if function_invocation_kwargs is not None else {} + ) additional_function_arguments = {**effective_function_invocation_kwargs, **existing_additional_args} # Build options dict from run() options merged with provided options @@ -1214,12 +1188,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] # Build session_messages from session context: context messages + input messages session_messages: list[Message] = session_context.get_messages(include_input=True) - # TODO(Copilot): Delete once direct ``run(**kwargs)`` compatibility is removed. - # Legacy compatibility still fans out direct run kwargs into client kwargs. - effective_client_kwargs = { - **dict(legacy_kwargs), - **(dict(client_kwargs) if client_kwargs is not None else {}), - } + effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} if active_session is not None: effective_client_kwargs["session"] = active_session @@ -1499,9 +1468,29 @@ class Agent( *, stream: Literal[False] = ..., session: AgentSession | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + options: ChatOptions[ResponseModelBoundT], + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + ) -> Awaitable[AgentResponse[ResponseModelBoundT]]: ... + + @overload + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[False] = ..., + session: AgentSession | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + options: OptionsCoT | ChatOptions[None] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @overload @@ -1511,9 +1500,13 @@ class Agent( *, stream: Literal[True], session: AgentSession | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... def run( @@ -1523,10 +1516,12 @@ class Agent( stream: bool = False, session: AgentSession | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, options: OptionsCoT | ChatOptions[Any] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Run the agent.""" super_run = cast( @@ -1538,10 +1533,12 @@ class Agent( stream=stream, session=session, middleware=middleware, + tools=tools, options=options, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, function_invocation_kwargs=function_invocation_kwargs, client_kwargs=client_kwargs, - **kwargs, ) def __init__( @@ -1558,7 +1555,7 @@ class Agent( middleware: Sequence[MiddlewareTypes] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + additional_properties: MutableMapping[str, Any] | None = None, ) -> None: """Initialize a Agent instance.""" super().__init__( @@ -1573,7 +1570,7 @@ class Agent( middleware=middleware, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - **kwargs, + additional_properties=additional_properties, ) diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 66740f5bf8..1865da7928 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging import sys -import warnings from abc import ABC, abstractmethod from collections.abc import ( AsyncIterable, @@ -139,7 +138,8 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]): options: ChatOptions[ResponseModelBoundT], compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload @@ -153,7 +153,6 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @overload @@ -167,7 +166,6 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... def get_response( @@ -180,7 +178,6 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Send input and return the response. @@ -192,7 +189,6 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]): tokenizer: Optional per-call tokenizer override. function_invocation_kwargs: Keyword arguments forwarded only to tool invocation layers. client_kwargs: Additional client-specific keyword arguments. - **kwargs: Deprecated additional client-specific keyword arguments. Returns: When stream=False: An awaitable ChatResponse from the client. @@ -296,7 +292,6 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, additional_properties: dict[str, Any] | None = None, - **kwargs: Any, ) -> None: """Initialize a BaseChatClient instance. @@ -304,19 +299,10 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): compaction_strategy: Optional compaction strategy to apply before model calls. tokenizer: Optional tokenizer used by token-aware compaction strategies. additional_properties: Additional properties for the client. - kwargs: Additional keyword arguments (merged into additional_properties for now). """ self.additional_properties = additional_properties or {} self.compaction_strategy = compaction_strategy self.tokenizer = tokenizer - if kwargs: - warnings.warn( - "Passing additional properties as direct keyword arguments to BaseChatClient is deprecated; " - "pass them via additional_properties instead.", - DeprecationWarning, - stacklevel=3, - ) - self.additional_properties.update(kwargs) super().__init__() def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]: @@ -457,7 +443,8 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): options: ChatOptions[ResponseModelBoundT], compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload @@ -469,7 +456,8 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[None] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[Any]]: ... @overload @@ -481,7 +469,8 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... def get_response( @@ -492,7 +481,8 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Get a response from a chat client. @@ -504,13 +494,9 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): When omitted, the client-level default is used. tokenizer: Optional per-call tokenizer override. When omitted, the client-level default is used. - **kwargs: Additional compatibility keyword arguments. Lower chat-client layers do not - consume ``function_invocation_kwargs`` directly; if present, it is ignored here - because function invocation has already been handled by upper layers. If a - ``client_kwargs`` mapping is present, it is flattened into standard keyword - arguments before forwarding to ``_inner_get_response()`` so client implementations - can leverage those values, while implementations that ignore - extra kwargs remain compatible. + function_invocation_kwargs: Keyword arguments forwarded only to tool invocation layers. + client_kwargs: Additional client-specific keyword arguments forwarded to + ``_inner_get_response()``. Returns: When streaming a response stream of ChatResponseUpdates, otherwise an Awaitable ChatResponse. @@ -519,14 +505,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): compaction_strategy=compaction_strategy, tokenizer=tokenizer, ) - compatibility_client_kwargs = kwargs.pop("client_kwargs", None) - kwargs.pop("function_invocation_kwargs", None) - merged_client_kwargs = ( - dict(cast(Mapping[str, Any], compatibility_client_kwargs)) - if isinstance(compatibility_client_kwargs, Mapping) - else {} - ) - merged_client_kwargs.update(kwargs) + merged_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} if not compaction_overrides: return self._inner_get_response( diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 267e176ee8..0dab38c820 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -768,7 +768,8 @@ class MCPTool: options["stop"] = params.stopSequences try: - response = await self.client.get_response( + chat_client: Any = self.client + response: Any = await chat_client.get_response( messages, options=options or None, ) diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index 381482b91a..31950e0d7b 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -39,7 +39,7 @@ if TYPE_CHECKING: from ._clients import SupportsChatGetResponse from ._compaction import CompactionStrategy, TokenizerProtocol from ._sessions import AgentSession - from ._tools import FunctionTool + from ._tools import FunctionTool, ToolTypes from ._types import ChatOptions, ChatResponse, ChatResponseUpdate ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) @@ -100,6 +100,7 @@ class AgentContext: agent: The agent being invoked. messages: The messages being sent to the agent. session: The agent session for this invocation, if any. + tools: Run-level tool overrides for this invocation, if any. options: The options for the agent invocation as a dict. stream: Whether this is a streaming invocation. compaction_strategy: Optional per-run compaction override. @@ -142,6 +143,7 @@ class AgentContext: agent: SupportsAgentRun, messages: list[Message], session: AgentSession | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, options: Mapping[str, Any] | None = None, stream: bool = False, compaction_strategy: CompactionStrategy | None = None, @@ -165,6 +167,7 @@ class AgentContext: agent: The agent being invoked. messages: The messages being sent to the agent. session: The agent session for this invocation, if any. + tools: Run-level tool overrides for this invocation, if any. options: The options for the agent invocation as a dict. stream: Whether this is a streaming invocation. compaction_strategy: Optional per-run compaction override. @@ -181,6 +184,7 @@ class AgentContext: self.agent = agent self.messages = messages self.session = session + self.tools = tools self.options = options self.stream = stream self.compaction_strategy = compaction_strategy @@ -1025,7 +1029,7 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload @@ -1039,7 +1043,6 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @overload @@ -1053,7 +1056,6 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... def get_response( @@ -1066,27 +1068,26 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Execute the chat pipeline if middleware is configured.""" super_get_response = super().get_response # type: ignore[misc] - - if compaction_strategy is not None: - kwargs["compaction_strategy"] = compaction_strategy - if tokenizer is not None: - kwargs["tokenizer"] = tokenizer - effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} call_middleware = effective_client_kwargs.pop("middleware", []) + context_kwargs = dict(effective_client_kwargs) + if compaction_strategy is not None: + context_kwargs["compaction_strategy"] = compaction_strategy + if tokenizer is not None: + context_kwargs["tokenizer"] = tokenizer pipeline = self._get_chat_middleware_pipeline(call_middleware) # type: ignore[reportUnknownArgumentType] if not pipeline.has_middlewares: return super_get_response( # type: ignore[no-any-return] messages=messages, stream=stream, options=options, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, function_invocation_kwargs=function_invocation_kwargs, client_kwargs=effective_client_kwargs, - **kwargs, ) context = ChatContext( @@ -1094,7 +1095,7 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): messages=list(messages), options=options, stream=stream, - kwargs={**effective_client_kwargs, **kwargs}, + kwargs=context_kwargs, function_invocation_kwargs=function_invocation_kwargs, ) @@ -1180,12 +1181,12 @@ class AgentMiddlewareLayer: stream: Literal[False] = ..., session: AgentSession | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, options: ChatOptions[ResponseModelBoundT], compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[ResponseModelBoundT]]: ... @overload @@ -1196,12 +1197,12 @@ class AgentMiddlewareLayer: stream: Literal[False] = ..., session: AgentSession | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, options: ChatOptions[None] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @overload @@ -1212,12 +1213,12 @@ class AgentMiddlewareLayer: stream: Literal[True], session: AgentSession | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, options: ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... def run( @@ -1227,12 +1228,12 @@ class AgentMiddlewareLayer: stream: bool = False, session: AgentSession | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, options: ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """MiddlewareTypes-enabled unified run method.""" # Re-categorize self.middleware at runtime to support dynamic changes @@ -1263,23 +1264,23 @@ class AgentMiddlewareLayer: messages, stream=stream, session=session, + tools=tools, options=options, compaction_strategy=compaction_strategy, tokenizer=tokenizer, function_invocation_kwargs=effective_function_invocation_kwargs, client_kwargs=effective_client_kwargs, - **kwargs, ) context = AgentContext( agent=self, # type: ignore[arg-type] messages=normalize_messages(messages), session=session, + tools=tools, options=options, stream=stream, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - kwargs=kwargs, client_kwargs=effective_client_kwargs, function_invocation_kwargs=effective_function_invocation_kwargs, ) @@ -1313,22 +1314,16 @@ class AgentMiddlewareLayer: def _middleware_handler( self, context: AgentContext ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: - # TODO(Copilot): Delete once direct ``run(**kwargs)`` compatibility is removed. - client_kwargs = {**context.client_kwargs, **context.kwargs} - # TODO(Copilot): Delete once direct ``run(**kwargs)`` compatibility is removed. - function_invocation_kwargs = { - **context.function_invocation_kwargs, - **{k: v for k, v in context.kwargs.items() if k != "middleware"}, - } return super().run( # type: ignore[misc, no-any-return] context.messages, stream=context.stream, session=context.session, + tools=context.tools, options=context.options, compaction_strategy=context.compaction_strategy, tokenizer=context.tokenizer, - function_invocation_kwargs=function_invocation_kwargs, - client_kwargs=client_kwargs, + function_invocation_kwargs=context.function_invocation_kwargs, + client_kwargs=context.client_kwargs, ) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index f7bc3f0e15..521a0c4d96 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -8,7 +8,6 @@ import json import logging import sys import typing -import warnings from collections.abc import ( AsyncIterable, Awaitable, @@ -344,8 +343,6 @@ class FunctionTool(SerializationMixin): self._instance = None # Store the instance for bound methods self._context_parameter_name: str | None = None self._input_model_explicitly_provided = input_model is not None - # TODO(Copilot): Delete once legacy ``**kwargs`` runtime injection is removed. - self._forward_runtime_kwargs: bool = False if self.func: self._discover_injected_parameters() @@ -390,10 +387,6 @@ class FunctionTool(SerializationMixin): for name, param in signature.parameters.items(): if name in {"self", "cls"}: continue - if param.kind == inspect.Parameter.VAR_KEYWORD: - self._forward_runtime_kwargs = True - continue - annotation = type_hints.get(name, param.annotation) if self._is_context_parameter(name, annotation): if self._context_parameter_name is not None: @@ -518,6 +511,7 @@ class FunctionTool(SerializationMixin): *, arguments: BaseModel | Mapping[str, Any] | None = None, context: FunctionInvocationContext | None = None, + tool_call_id: str | None = None, **kwargs: Any, ) -> list[Content]: """Run the AI function with the provided arguments as a Pydantic model. @@ -530,7 +524,10 @@ class FunctionTool(SerializationMixin): Keyword Args: arguments: A mapping or model instance containing the arguments for the function. context: Explicit function invocation context carrying runtime kwargs. - kwargs: Deprecated keyword arguments to pass to the function. Use ``context`` instead. + tool_call_id: Optional tool call identifier used for telemetry and tracing. + kwargs: Direct function argument values. When provided, every keyword + must match a declared tool parameter. Runtime data must be passed + via ``context``. Returns: A list of Content items representing the tool output. @@ -552,18 +549,13 @@ class FunctionTool(SerializationMixin): {key: value for key, value in kwargs.items() if key in parameter_names} if arguments is None else {} ) runtime_kwargs = dict(context.kwargs) if context is not None else {} - deprecated_runtime_kwargs = { - key: value for key, value in kwargs.items() if key not in direct_argument_kwargs and key != "tool_call_id" - } - if deprecated_runtime_kwargs: - warnings.warn( - "Passing runtime keyword arguments directly to FunctionTool.invoke() is deprecated; " - "pass them via FunctionInvocationContext instead.", - DeprecationWarning, - stacklevel=2, + unexpected_kwargs = {key: value for key, value in kwargs.items() if key not in direct_argument_kwargs} + if unexpected_kwargs: + unexpected_names = ", ".join(sorted(unexpected_kwargs)) + raise TypeError( + f"Unexpected keyword argument(s) for tool '{self.name}': {unexpected_names}. " + "Pass runtime data via FunctionInvocationContext instead." ) - runtime_kwargs.update(deprecated_runtime_kwargs) - tool_call_id = kwargs.get("tool_call_id", runtime_kwargs.pop("tool_call_id", None)) if arguments is None and direct_argument_kwargs: arguments = direct_argument_kwargs if arguments is None and context is not None: @@ -614,17 +606,6 @@ class FunctionTool(SerializationMixin): call_kwargs = dict(validated_arguments) observable_kwargs = dict(validated_arguments) - - # Legacy runtime kwargs injection path retained for backwards compatibility with tools - # that still declare ``**kwargs``. New tools should consume runtime data via ``ctx``. - legacy_runtime_kwargs = dict(runtime_kwargs) - if self._forward_runtime_kwargs and legacy_runtime_kwargs: - for key, value in legacy_runtime_kwargs.items(): - if key not in call_kwargs: - call_kwargs[key] = value - if key not in observable_kwargs: - observable_kwargs[key] = value - if self._context_parameter_name is not None and effective_context is not None: call_kwargs[self._context_parameter_name] = effective_context @@ -1420,7 +1401,7 @@ async def _auto_invoke_function( # No middleware - execute directly try: direct_context = None - if getattr(tool, "_forward_runtime_kwargs", False) or getattr(tool, "_context_parameter_name", None): + if getattr(tool, "_context_parameter_name", None): direct_context = FunctionInvocationContext( function=tool, arguments=args, @@ -2078,7 +2059,6 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload @@ -2093,7 +2073,6 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @overload @@ -2108,7 +2087,6 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... def get_response( @@ -2122,7 +2100,6 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: from ._middleware import categorize_middleware from ._types import ( @@ -2133,14 +2110,6 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): ) super_get_response = super().get_response # type: ignore[misc] - if kwargs: - warnings.warn( - "Passing client-specific keyword arguments directly to get_response() is deprecated; " - "pass them via client_kwargs instead.", - DeprecationWarning, - stacklevel=2, - ) - effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} if middleware is not None: existing = effective_client_kwargs.get("middleware", []) @@ -2176,19 +2145,23 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): invocation_session=invocation_session, middleware_pipeline=function_middleware_pipeline, ) - filtered_kwargs = {k: v for k, v in {**effective_client_kwargs, **kwargs}.items() if k != "session"} + filtered_kwargs = {k: v for k, v in effective_client_kwargs.items() if k != "session"} # Make options mutable so we can update conversation_id during function invocation loop mutable_options: dict[str, Any] = dict(options) if options else {} # Remove additional_function_arguments from options passed to underlying chat client # It's for tool invocation only and not recognized by chat service APIs mutable_options.pop("additional_function_arguments", None) - # Support tools passed via kwargs in direct client.get_response(...) calls. - if "tools" in filtered_kwargs: - if mutable_options.get("tools") is None: - mutable_options["tools"] = filtered_kwargs["tools"] - filtered_kwargs.pop("tools", None) - + if not self.function_invocation_configuration.get("enabled", True): + return super_get_response( # type: ignore[no-any-return] + messages=messages, + stream=stream, + options=mutable_options, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=filtered_kwargs, + ) if not stream: async def _get_response() -> ChatResponse[Any]: @@ -2235,7 +2208,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): aggregated_usage = add_usage_details(aggregated_usage, response.usage_details) if response.conversation_id is not None: - _update_conversation_id(kwargs, response.conversation_id, mutable_options) + _update_conversation_id(filtered_kwargs, response.conversation_id, mutable_options) prepped_messages = [] result = await _process_function_requests( @@ -2379,7 +2352,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): return if response.conversation_id is not None: - _update_conversation_id(kwargs, response.conversation_id, mutable_options) + _update_conversation_id(filtered_kwargs, response.conversation_id, mutable_options) prepped_messages = [] result = await _process_function_requests( diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 462c3f8c64..02b4da943c 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -12,7 +12,7 @@ from agent_framework import Content from .._agents import SupportsAgentRun from .._sessions import AgentSession -from .._types import AgentResponse, AgentResponseUpdate, Message +from .._types import AgentResponse, AgentResponseUpdate, Message, ResponseStream from ._agent_utils import resolve_agent_id from ._const import WORKFLOW_RUN_KWARGS_KEY from ._executor import Executor, handler @@ -352,7 +352,8 @@ class AgentExecutor(Executor): """ run_kwargs, options = self._prepare_agent_run_args(ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})) - response = await self._agent.run( + run_agent = cast(Callable[..., Awaitable[AgentResponse[Any]]], self._agent.run) + response = await run_agent( self._cache, stream=False, session=self._session, @@ -383,7 +384,8 @@ class AgentExecutor(Executor): updates: list[AgentResponseUpdate] = [] streamed_user_input_requests: list[Content] = [] - stream = self._agent.run( + run_agent_stream = cast(Callable[..., ResponseStream[AgentResponseUpdate, AgentResponse[Any]]], self._agent.run) + stream = run_agent_stream( self._cache, stream=True, session=self._session, diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index f673f5cc61..236daa29a0 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -49,8 +49,9 @@ if TYPE_CHECKING: # pragma: no cover from ._agents import SupportsAgentRun from ._clients import SupportsChatGetResponse from ._compaction import CompactionStrategy, TokenizerProtocol + from ._middleware import MiddlewareTypes from ._sessions import AgentSession - from ._tools import FunctionTool + from ._tools import FunctionTool, ToolTypes from ._types import ( AgentResponse, AgentResponseUpdate, @@ -1191,7 +1192,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options: ChatOptions[ResponseModelBoundT], compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload @@ -1203,7 +1205,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[None] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[Any]]: ... @overload @@ -1215,7 +1218,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... def get_response( @@ -1226,7 +1230,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Trace chat responses with OpenTelemetry spans and metrics. @@ -1238,25 +1243,14 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): tokenizer: Optional tokenizer used by token-aware compaction strategies. Keyword Args: - kwargs: Compatibility keyword arguments from higher client layers. This layer does - not consume ``function_invocation_kwargs`` directly; if present, it is ignored - because function invocation has already been processed above. If a ``client_kwargs`` - mapping is present, it is flattened into ordinary keyword arguments for tracing and - forwarding so clients that use those values continue to work while clients that - ignore extra kwargs remain compatible. + function_invocation_kwargs: Keyword arguments forwarded only to tool invocation layers. + client_kwargs: Additional client-specific keyword arguments for downstream chat clients. """ from ._types import ChatResponse, ChatResponseUpdate, ResponseStream # type: ignore[reportUnusedImport] global OBSERVABILITY_SETTINGS super_get_response = super().get_response # type: ignore[misc] - compatibility_client_kwargs = kwargs.pop("client_kwargs", None) - kwargs.pop("function_invocation_kwargs", None) - merged_client_kwargs = ( - dict(cast(Mapping[str, Any], compatibility_client_kwargs)) - if isinstance(compatibility_client_kwargs, Mapping) - else {} - ) - merged_client_kwargs.update(kwargs) + merged_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} if not OBSERVABILITY_SETTINGS.ENABLED: return super_get_response( # type: ignore[no-any-return] @@ -1265,7 +1259,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options=options, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - **merged_client_kwargs, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=merged_client_kwargs, ) opts: dict[str, Any] = options or {} # type: ignore[assignment] @@ -1292,7 +1287,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options=opts, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - **merged_client_kwargs, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=merged_client_kwargs, ), ) @@ -1384,7 +1380,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options=opts, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - **merged_client_kwargs, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=merged_client_kwargs, ), ) except Exception as exception: @@ -1512,11 +1509,29 @@ class AgentTelemetryLayer: *, stream: Literal[False] = ..., session: AgentSession | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + options: ChatOptions[ResponseModelBoundT], + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + ) -> Awaitable[AgentResponse[ResponseModelBoundT]]: ... + + @overload + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[False] = ..., + session: AgentSession | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + options: ChatOptions[None] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @overload @@ -1526,11 +1541,13 @@ class AgentTelemetryLayer: *, stream: Literal[True], session: AgentSession | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + options: ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... def run( @@ -1539,11 +1556,13 @@ class AgentTelemetryLayer: *, stream: bool = False, session: AgentSession | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + options: ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Trace agent runs with OpenTelemetry spans and metrics.""" global OBSERVABILITY_SETTINGS @@ -1554,23 +1573,27 @@ class AgentTelemetryLayer: super().run, # type: ignore[misc] ) provider_name = str(self.otel_provider_name) + super_run_kwargs: dict[str, Any] = { + "messages": messages, + "stream": stream, + "session": session, + "tools": tools, + "options": options, + "compaction_strategy": compaction_strategy, + "tokenizer": tokenizer, + "function_invocation_kwargs": function_invocation_kwargs, + "client_kwargs": client_kwargs, + } + if middleware is not None: + super_run_kwargs["middleware"] = middleware if not OBSERVABILITY_SETTINGS.ENABLED: - return super_run( # type: ignore[no-any-return] - messages=messages, - stream=stream, - session=session, - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - function_invocation_kwargs=function_invocation_kwargs, - client_kwargs=client_kwargs, - **kwargs, - ) + return super_run(**super_run_kwargs) # type: ignore[no-any-return] - default_options = getattr(self, "default_options", {}) - options = kwargs.get("options") + default_options = dict(getattr(self, "default_options", {})) merged_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} - merged_client_kwargs.update(kwargs) - merged_options: dict[str, Any] = merge_chat_options(default_options, options or {}) + merged_options: dict[str, Any] = merge_chat_options( + default_options, dict(options) if options is not None else {} + ) attributes = _get_span_attributes( operation_name=OtelAttr.AGENT_INVOKE_OPERATION, provider_name=provider_name, @@ -1590,16 +1613,7 @@ class AgentTelemetryLayer: if stream: try: - run_result: object = super_run( - messages=messages, - stream=True, - session=session, - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - function_invocation_kwargs=function_invocation_kwargs, - client_kwargs=client_kwargs, - **kwargs, - ) + run_result: object = super_run(**super_run_kwargs) if isinstance(run_result, ResponseStream): result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType] elif isinstance(run_result, Awaitable): @@ -1693,16 +1707,7 @@ class AgentTelemetryLayer: ) start_time_stamp = perf_counter() try: - response: AgentResponse[Any] = await super_run( - messages=messages, - stream=False, - session=session, - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - function_invocation_kwargs=function_invocation_kwargs, - client_kwargs=client_kwargs, - **kwargs, - ) + response: AgentResponse[Any] = await super_run(**super_run_kwargs) except Exception as exception: capture_exception(span=span, exception=exception, timestamp=time_ns()) raise diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index c751265fbe..94253b3c34 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -148,11 +148,9 @@ async def test_chat_client_agent_init_with_name( assert agent.description == "Test" -def test_agent_init_warns_for_direct_additional_properties(client: SupportsChatGetResponse) -> None: - with pytest.warns(DeprecationWarning, match="additional_properties"): - agent = Agent(client=client, legacy_key="legacy-value") - - assert agent.additional_properties["legacy_key"] == "legacy-value" +def test_agent_init_rejects_direct_additional_properties(client: SupportsChatGetResponse) -> None: + with pytest.raises(TypeError): + Agent(client=client, legacy_key="legacy-value") async def test_chat_client_agent_run(client: SupportsChatGetResponse) -> None: @@ -303,7 +301,6 @@ async def test_prepare_run_context_handles_function_kwargs( }, compaction_strategy=None, tokenizer=None, - legacy_kwargs={"legacy_key": "legacy-value"}, function_invocation_kwargs={"runtime_key": "runtime-value"}, client_kwargs={"client_key": "client-value"}, ) @@ -311,7 +308,6 @@ async def test_prepare_run_context_handles_function_kwargs( assert ctx["chat_options"]["temperature"] == 0.4 assert "additional_function_arguments" not in ctx["chat_options"] assert ctx["function_invocation_kwargs"]["from_options"] == "options-value" - assert ctx["function_invocation_kwargs"]["legacy_key"] == "legacy-value" assert ctx["function_invocation_kwargs"]["runtime_key"] == "runtime-value" assert "session" not in ctx["function_invocation_kwargs"] assert ctx["client_kwargs"]["client_key"] == "client-value" @@ -1181,8 +1177,8 @@ async def test_agent_run_accepts_prefixed_mcp_tools(chat_client_base: Any) -> No assert tool_names == ["search", "docs_search"] -async def test_agent_tool_receives_session_in_kwargs(chat_client_base: Any) -> None: - """Verify legacy **kwargs tools receive the session when agent.run() is called with one.""" +async def test_agent_tool_without_context_does_not_receive_session(chat_client_base: Any) -> None: + """Verify tools without FunctionInvocationContext no longer receive injected session kwargs.""" captured: dict[str, Any] = {} @@ -1215,8 +1211,8 @@ async def test_agent_tool_receives_session_in_kwargs(chat_client_base: Any) -> N result = await agent.run("hello", session=session) assert result.text == "done" - assert captured.get("has_session") is True - assert captured.get("has_state") is True + assert captured.get("has_session") is False + assert captured.get("has_state") is False async def test_agent_tool_receives_explicit_session_via_function_invocation_context_kwargs( @@ -1278,7 +1274,7 @@ async def test_chat_agent_tool_choice_run_level_overrides_agent_level(chat_clien agent = Agent( client=chat_client_base, tools=[tool_tool], - options={"tool_choice": "auto"}, + default_options={"tool_choice": "auto"}, ) # Run with run-level tool_choice="required" diff --git a/python/packages/core/tests/core/test_clients.py b/python/packages/core/tests/core/test_clients.py index 73526298df..9a7e90f5ee 100644 --- a/python/packages/core/tests/core/test_clients.py +++ b/python/packages/core/tests/core/test_clients.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. -import inspect from typing import Any from unittest.mock import patch @@ -15,11 +14,6 @@ from agent_framework import ( Message, SlidingWindowStrategy, SupportsChatGetResponse, - SupportsCodeInterpreterTool, - SupportsFileSearchTool, - SupportsImageGenerationTool, - SupportsMCPTool, - SupportsWebSearchTool, TruncationStrategy, ) @@ -53,11 +47,9 @@ def test_base_client(chat_client_base: SupportsChatGetResponse): assert isinstance(chat_client_base, SupportsChatGetResponse) -def test_base_client_warns_for_direct_additional_properties(chat_client_base: SupportsChatGetResponse) -> None: - with pytest.warns(DeprecationWarning, match="additional_properties"): - client = type(chat_client_base)(legacy_key="legacy-value") - - assert client.additional_properties["legacy_key"] == "legacy-value" +def test_base_client_rejects_direct_additional_properties(chat_client_base: SupportsChatGetResponse) -> None: + with pytest.raises(TypeError): + type(chat_client_base)(legacy_key="legacy-value") def test_base_client_as_agent_uses_explicit_additional_properties(chat_client_base: SupportsChatGetResponse) -> None: @@ -66,27 +58,6 @@ def test_base_client_as_agent_uses_explicit_additional_properties(chat_client_ba assert agent.additional_properties == {"team": "core"} -def test_openai_chat_completion_client_get_response_docstring_surfaces_layered_runtime_docs() -> None: - from agent_framework.openai import OpenAIChatCompletionClient - - docstring = inspect.getdoc(OpenAIChatCompletionClient.get_response) - - assert docstring is not None - assert "Get a response from a chat client." in docstring - assert "function_invocation_kwargs" in docstring - assert "middleware: Optional per-call chat and function middleware." in docstring - assert "function_middleware: Optional per-call function middleware." not in docstring - - -def test_openai_chat_completion_client_get_response_is_defined_on_openai_class() -> None: - from agent_framework.openai import OpenAIChatCompletionClient - - signature = inspect.signature(OpenAIChatCompletionClient.get_response) - - assert OpenAIChatCompletionClient.get_response.__qualname__ == "OpenAIChatCompletionClient.get_response" - assert "middleware" in signature.parameters - - async def test_base_client_get_response_uses_explicit_client_kwargs(chat_client_base: SupportsChatGetResponse) -> None: async def fake_inner_get_response(**kwargs): assert kwargs["trace_id"] == "trace-123" @@ -333,66 +304,3 @@ async def test_chat_client_instructions_handling(chat_client_base: SupportsChatG assert appended_messages[0].text == "You are a helpful assistant." assert appended_messages[1].role == "user" assert appended_messages[1].text == "hello" - - -# region Tool Support Protocol Tests - - -def test_openai_responses_client_supports_all_tool_protocols(): - """Test that OpenAIResponsesClient supports all hosted tool protocols.""" - from agent_framework.openai import OpenAIResponsesClient - - assert isinstance(OpenAIResponsesClient, SupportsCodeInterpreterTool) - assert isinstance(OpenAIResponsesClient, SupportsWebSearchTool) - assert isinstance(OpenAIResponsesClient, SupportsImageGenerationTool) - assert isinstance(OpenAIResponsesClient, SupportsMCPTool) - assert isinstance(OpenAIResponsesClient, SupportsFileSearchTool) - - -def test_openai_chat_completion_client_supports_web_search_only(): - """Test that OpenAIChatClient only supports web search tool.""" - from agent_framework.openai import OpenAIChatCompletionClient - - assert not isinstance(OpenAIChatCompletionClient, SupportsCodeInterpreterTool) - assert isinstance(OpenAIChatCompletionClient, SupportsWebSearchTool) - assert not isinstance(OpenAIChatCompletionClient, SupportsImageGenerationTool) - assert not isinstance(OpenAIChatCompletionClient, SupportsMCPTool) - assert not isinstance(OpenAIChatCompletionClient, SupportsFileSearchTool) - - -def test_openai_assistants_client_supports_code_interpreter_and_file_search(): - """Test that OpenAIAssistantsClient supports code interpreter and file search.""" - from agent_framework.openai import OpenAIAssistantsClient - - assert isinstance(OpenAIAssistantsClient, SupportsCodeInterpreterTool) - assert not isinstance(OpenAIAssistantsClient, SupportsWebSearchTool) - assert not isinstance(OpenAIAssistantsClient, SupportsImageGenerationTool) - assert not isinstance(OpenAIAssistantsClient, SupportsMCPTool) - assert isinstance(OpenAIAssistantsClient, SupportsFileSearchTool) - - -def test_protocol_isinstance_with_client_instance(): - """Test that protocol isinstance works with client instances.""" - from agent_framework.openai import OpenAIResponsesClient - - # Create mock client instance (won't connect to API) - client = OpenAIResponsesClient.__new__(OpenAIResponsesClient) - - assert isinstance(client, SupportsCodeInterpreterTool) - assert isinstance(client, SupportsWebSearchTool) - - -def test_protocol_tool_methods_return_dict(): - """Test that static tool methods return dict[str, Any].""" - from agent_framework.openai import OpenAIResponsesClient - - code_tool = OpenAIResponsesClient.get_code_interpreter_tool() - assert isinstance(code_tool, dict) - assert code_tool.get("type") == "code_interpreter" - - web_tool = OpenAIResponsesClient.get_web_search_tool() - assert isinstance(web_tool, dict) - assert web_tool.get("type") == "web_search" - - -# endregion diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index d9659837a8..96bec7d547 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -13,6 +13,7 @@ from agent_framework import ( Content, Message, SupportsChatGetResponse, + chat_middleware, tool, ) from agent_framework._compaction import ( @@ -74,7 +75,7 @@ async def test_base_client_with_function_calling(chat_client_base: SupportsChatG assert response.messages[2].text == "done" -async def test_base_client_with_function_calling_tools_in_kwargs(chat_client_base: SupportsChatGetResponse): +async def test_base_client_with_function_calling_string_input(chat_client_base: SupportsChatGetResponse): exec_counter = 0 @tool(name="test_function", approval_mode="never_require") @@ -95,7 +96,7 @@ async def test_base_client_with_function_calling_tools_in_kwargs(chat_client_bas ChatResponse(messages=Message(role="assistant", text="done")), ] - response = await chat_client_base.get_response("hello", tools=[ai_func]) + response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [ai_func]}) assert exec_counter == 1 assert len(response.messages) == 3 @@ -1429,6 +1430,36 @@ async def test_function_invocation_config_enabled_false(chat_client_base: Suppor assert len(response.messages) > 0 +async def test_function_invocation_config_enabled_false_preserves_invocation_kwargs( + chat_client_base: SupportsChatGetResponse, +): + """Test disabled function invocation still forwards invocation kwargs downstream.""" + captured_kwargs: dict[str, Any] = {} + + @tool(name="test_function") + def ai_func(arg1: str) -> str: + return f"Processed {arg1}" + + @chat_middleware + async def capture_middleware(context, call_next): + captured_kwargs.update(context.function_invocation_kwargs or {}) + await call_next() + + chat_client_base.chat_middleware = [capture_middleware] + chat_client_base.run_responses = [ + ChatResponse(messages=Message(role="assistant", text="response without function calling")), + ] + chat_client_base.function_invocation_configuration["enabled"] = False + + await chat_client_base.get_response( + [Message(role="user", text="hello")], + options={"tool_choice": "auto", "tools": [ai_func]}, + function_invocation_kwargs={"tool_request_id": "tool-123"}, + ) + + assert captured_kwargs == {"tool_request_id": "tool-123"} + + @pytest.mark.skip(reason="Error handling and failsafe behavior needs investigation in unified API") async def test_function_invocation_config_max_consecutive_errors(chat_client_base: SupportsChatGetResponse): """Test that max_consecutive_errors_per_request limits error retries.""" @@ -1523,7 +1554,7 @@ async def test_function_invocation_stop_clears_conversation_id_non_stream(chat_c response = await chat_client_base.get_response( [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [error_func]}, - session=session_stub, + client_kwargs={"session": session_stub}, ) assert response.conversation_id is None @@ -1881,8 +1912,7 @@ async def test_hosted_tool_approval_response(chat_client_base: SupportsChatGetRe # Send the approval response response = await chat_client_base.get_response( [Message(role="user", contents=[approval_response])], - tool_choice="auto", - tools=[local_func], + options={"tool_choice": "auto", "tools": [local_func]}, ) # The hosted tool approval should be returned as-is (not executed) @@ -1930,8 +1960,7 @@ async def test_hosted_mcp_approval_response_passthrough(chat_client_base: Suppor response = await chat_client_base.get_response( messages, - tool_choice="auto", - tools=[local_func], + options={"tool_choice": "auto", "tools": [local_func]}, ) # The response should succeed without errors @@ -2024,8 +2053,7 @@ async def test_mixed_local_and_hosted_approval_flow(chat_client_base: SupportsCh response = await chat_client_base.get_response( messages, - tool_choice="auto", - tools=[local_func], + options={"tool_choice": "auto", "tools": [local_func]}, ) assert response is not None @@ -2799,7 +2827,7 @@ async def test_streaming_function_invocation_stop_clears_conversation_id(chat_cl "hello", options={"tool_choice": "auto", "tools": [error_func]}, stream=True, - session=session_stub, + client_kwargs={"session": session_stub}, ) async for _ in stream: pass diff --git a/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py b/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py deleted file mode 100644 index 11a738a0b9..0000000000 --- a/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py +++ /dev/null @@ -1,351 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Tests for kwargs propagation from get_response() to @tool functions.""" - -from collections.abc import AsyncIterable, Awaitable, MutableSequence, Sequence -from typing import Any - -from agent_framework import ( - Agent, - BaseChatClient, - ChatMiddlewareLayer, - ChatResponse, - ChatResponseUpdate, - Content, - FunctionInvocationContext, - FunctionInvocationLayer, - Message, - ResponseStream, - tool, -) -from agent_framework.observability import ChatTelemetryLayer - - -class _MockBaseChatClient(BaseChatClient[Any]): - """Mock chat client for testing function invocation.""" - - def __init__(self) -> None: - super().__init__() - self.run_responses: list[ChatResponse] = [] - self.streaming_responses: list[list[ChatResponseUpdate]] = [] - self.call_count: int = 0 - - def _inner_get_response( - self, - *, - messages: MutableSequence[Message], - stream: bool, - options: dict[str, Any], - **kwargs: Any, - ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: - if stream: - return self._get_streaming_response(messages=messages, options=options, **kwargs) - - async def _get() -> ChatResponse: - return await self._get_non_streaming_response(messages=messages, options=options, **kwargs) - - return _get() - - async def _get_non_streaming_response( - self, - *, - messages: MutableSequence[Message], - options: dict[str, Any], - **kwargs: Any, - ) -> ChatResponse: - self.call_count += 1 - if self.run_responses: - return self.run_responses.pop(0) - return ChatResponse(messages=Message(role="assistant", text="default response")) - - def _get_streaming_response( - self, - *, - messages: MutableSequence[Message], - options: dict[str, Any], - **kwargs: Any, - ) -> ResponseStream[ChatResponseUpdate, ChatResponse]: - async def _stream() -> AsyncIterable[ChatResponseUpdate]: - self.call_count += 1 - if self.streaming_responses: - for update in self.streaming_responses.pop(0): - yield update - else: - yield ChatResponseUpdate( - contents=[Content.from_text("default streaming response")], role="assistant", finish_reason="stop" - ) - - def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: - response_format = options.get("response_format") - output_format_type = response_format if isinstance(response_format, type) else None - return ChatResponse.from_updates(updates, output_format_type=output_format_type) - - return ResponseStream(_stream(), finalizer=_finalize) - - -class FunctionInvokingMockClient( - FunctionInvocationLayer[Any], - ChatMiddlewareLayer[Any], - ChatTelemetryLayer[Any], - _MockBaseChatClient, -): - """Mock client with function invocation support.""" - - pass - - -class TestKwargsPropagationToFunctionTool: - """Test cases for kwargs flowing from get_response() to @tool functions.""" - - async def test_kwargs_propagate_to_tool_with_kwargs(self) -> None: - """Test that kwargs passed to get_response() are available in @tool **kwargs.""" - # TODO(Copilot): Remove this legacy coverage once runtime ``**kwargs`` tool injection is removed. - captured_kwargs: dict[str, Any] = {} - - @tool(approval_mode="never_require") - def capture_kwargs_tool(x: int, **kwargs: Any) -> str: - """A tool that captures kwargs for testing.""" - captured_kwargs.update(kwargs) - return f"result: x={x}" - - client = FunctionInvokingMockClient() - client.run_responses = [ - # First response: function call - ChatResponse( - messages=[ - Message( - role="assistant", - contents=[ - Content.from_function_call( - call_id="call_1", name="capture_kwargs_tool", arguments='{"x": 42}' - ) - ], - ) - ] - ), - # Second response: final answer - ChatResponse(messages=[Message(role="assistant", text="Done!")]), - ] - - result = await client.get_response( - messages=[Message(role="user", text="Test")], - stream=False, - options={ - "tools": [capture_kwargs_tool], - "additional_function_arguments": { - "user_id": "user-123", - "session_token": "secret-token", - "custom_data": {"key": "value"}, - }, - }, - ) - - # Verify the tool was called and received the kwargs - assert "user_id" in captured_kwargs, f"Expected 'user_id' in captured kwargs: {captured_kwargs}" - assert captured_kwargs["user_id"] == "user-123" - assert "session_token" in captured_kwargs - assert captured_kwargs["session_token"] == "secret-token" - assert "custom_data" in captured_kwargs - assert captured_kwargs["custom_data"] == {"key": "value"} - # Verify result - assert result.messages[-1].text == "Done!" - - async def test_kwargs_not_forwarded_to_tool_without_kwargs(self) -> None: - """Test that kwargs are NOT forwarded to @tool that doesn't accept **kwargs.""" - # TODO(Copilot): Remove this legacy coverage once runtime ``**kwargs`` tool injection is removed. - - @tool(approval_mode="never_require") - def simple_tool(x: int) -> str: - """A simple tool without **kwargs.""" - return f"result: x={x}" - - client = FunctionInvokingMockClient() - client.run_responses = [ - ChatResponse( - messages=[ - Message( - role="assistant", - contents=[ - Content.from_function_call(call_id="call_1", name="simple_tool", arguments='{"x": 99}') - ], - ) - ] - ), - ChatResponse(messages=[Message(role="assistant", text="Completed!")]), - ] - - # Call with additional_function_arguments - the tool should work but not receive them - result = await client.get_response( - messages=[Message(role="user", text="Test")], - stream=False, - options={ - "tools": [simple_tool], - "additional_function_arguments": {"user_id": "user-123"}, - }, - ) - - # Verify the tool was called successfully (no error from extra kwargs) - assert result.messages[-1].text == "Completed!" - - async def test_kwargs_isolated_between_function_calls(self) -> None: - """Test that kwargs are consistent across multiple function call invocations.""" - # TODO(Copilot): Remove this legacy coverage once runtime ``**kwargs`` tool injection is removed. - invocation_kwargs: list[dict[str, Any]] = [] - - @tool(approval_mode="never_require") - def tracking_tool(name: str, **kwargs: Any) -> str: - """A tool that tracks kwargs from each invocation.""" - invocation_kwargs.append(dict(kwargs)) - return f"called with {name}" - - client = FunctionInvokingMockClient() - client.run_responses = [ - # Two function calls in one response - ChatResponse( - messages=[ - Message( - role="assistant", - contents=[ - Content.from_function_call( - call_id="call_1", name="tracking_tool", arguments='{"name": "first"}' - ), - Content.from_function_call( - call_id="call_2", name="tracking_tool", arguments='{"name": "second"}' - ), - ], - ) - ] - ), - ChatResponse(messages=[Message(role="assistant", text="All done!")]), - ] - - result = await client.get_response( - messages=[Message(role="user", text="Test")], - stream=False, - options={ - "tools": [tracking_tool], - "additional_function_arguments": { - "request_id": "req-001", - "trace_context": {"trace_id": "abc"}, - }, - }, - ) - - # Both invocations should have received the same kwargs - assert len(invocation_kwargs) == 2 - for kwargs in invocation_kwargs: - assert kwargs.get("request_id") == "req-001" - assert kwargs.get("trace_context") == {"trace_id": "abc"} - assert result.messages[-1].text == "All done!" - - async def test_streaming_response_kwargs_propagation(self) -> None: - """Test that kwargs propagate to @tool in streaming mode.""" - # TODO(Copilot): Remove this legacy coverage once runtime ``**kwargs`` tool injection is removed. - captured_kwargs: dict[str, Any] = {} - - @tool(approval_mode="never_require") - def streaming_capture_tool(value: str, **kwargs: Any) -> str: - """A tool that captures kwargs during streaming.""" - captured_kwargs.update(kwargs) - return f"processed: {value}" - - client = FunctionInvokingMockClient() - client.streaming_responses = [ - # First stream: function call - [ - ChatResponseUpdate( - role="assistant", - contents=[ - Content.from_function_call( - call_id="stream_call_1", - name="streaming_capture_tool", - arguments='{"value": "streaming-test"}', - ) - ], - finish_reason="stop", - ) - ], - # Second stream: final response - [ - ChatResponseUpdate( - contents=[Content.from_text("Stream complete!")], role="assistant", finish_reason="stop" - ) - ], - ] - - # Collect streaming updates - updates: list[ChatResponseUpdate] = [] - stream = client.get_response( - messages=[Message(role="user", text="Test")], - stream=True, - options={ - "tools": [streaming_capture_tool], - "additional_function_arguments": { - "streaming_session": "session-xyz", - "correlation_id": "corr-123", - }, - }, - ) - async for update in stream: - updates.append(update) - - # Verify kwargs were captured by the tool - assert "streaming_session" in captured_kwargs, f"Expected 'streaming_session' in {captured_kwargs}" - assert captured_kwargs["streaming_session"] == "session-xyz" - assert captured_kwargs["correlation_id"] == "corr-123" - - async def test_agent_run_injects_function_invocation_context(self) -> None: - """Test that Agent.run injects FunctionInvocationContext for ctx-based tools.""" - captured_context_kwargs: dict[str, Any] = {} - captured_client_kwargs: dict[str, Any] = {} - captured_options: dict[str, Any] = {} - - @tool(approval_mode="never_require") - def capture_context_tool(x: int, ctx: FunctionInvocationContext) -> str: - captured_context_kwargs.update(ctx.kwargs) - return f"result: x={x}" - - class CapturingFunctionInvokingMockClient(FunctionInvokingMockClient): - async def _get_non_streaming_response( - self, - *, - messages: MutableSequence[Message], - options: dict[str, Any], - **kwargs: Any, - ) -> ChatResponse: - captured_options.update(options) - captured_client_kwargs.update(kwargs) - return await super()._get_non_streaming_response(messages=messages, options=options, **kwargs) - - client = CapturingFunctionInvokingMockClient() - client.run_responses = [ - ChatResponse( - messages=[ - Message( - role="assistant", - contents=[ - Content.from_function_call( - call_id="call_1", - name="capture_context_tool", - arguments='{"x": 42}', - ) - ], - ) - ] - ), - ChatResponse(messages=[Message(role="assistant", text="Done!")]), - ] - - agent = Agent(client=client, tools=[capture_context_tool]) - result = await agent.run( - [Message(role="user", text="Test")], - function_invocation_kwargs={"tool_request_id": "tool-123"}, - client_kwargs={"client_request_id": "client-456"}, - ) - - assert captured_context_kwargs["tool_request_id"] == "tool-123" - assert "client_request_id" not in captured_context_kwargs - assert captured_client_kwargs["client_request_id"] == "client-456" - assert "tool_request_id" not in captured_client_kwargs - assert "additional_function_arguments" not in captured_options - assert result.messages[-1].text == "Done!" diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index eb233eea99..09c036c704 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -1751,6 +1751,9 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): assert isinstance(result, types.ErrorData) assert result.code == types.INTERNAL_ERROR assert "Failed to get right content types from the response." in result.message + mock_chat_client.get_response.assert_awaited_once() + _, kwargs = mock_chat_client.get_response.await_args + assert kwargs["options"] == {"max_tokens": None} async def test_mcp_tool_sampling_callback_no_response_and_successful_message_creation(): @@ -3704,14 +3707,19 @@ async def test_mcp_tool_filters_framework_kwargs(): # Invoke the tool with framework kwargs that should be filtered out await func.invoke( - param="test_value", - response_format=MockResponseFormat, # Should be filtered - chat_options={"some": "option"}, # Should be filtered - tools=[Mock()], # Should be filtered - tool_choice="auto", # Should be filtered - session=Mock(), # Should be filtered - conversation_id="conv-123", # Should be filtered - options={"metadata": "value"}, # Should be filtered + context=FunctionInvocationContext( + function=func, + arguments={"param": "test_value"}, + kwargs={ + "response_format": MockResponseFormat, # Should be filtered + "chat_options": {"some": "option"}, # Should be filtered + "tools": [Mock()], # Should be filtered + "tool_choice": "auto", # Should be filtered + "session": Mock(), # Should be filtered + "conversation_id": "conv-123", # Should be filtered + "options": {"metadata": "value"}, # Should be filtered + }, + ), ) # Verify call_tool was called with only the valid argument diff --git a/python/packages/core/tests/core/test_middleware_with_agent.py b/python/packages/core/tests/core/test_middleware_with_agent.py index 6470a8202e..69d08482d3 100644 --- a/python/packages/core/tests/core/test_middleware_with_agent.py +++ b/python/packages/core/tests/core/test_middleware_with_agent.py @@ -789,9 +789,10 @@ class TestChatAgentFunctionMiddlewareWithTools: assert modified_kwargs["new_param"] == "added_by_middleware" assert modified_kwargs["custom_param"] == "test_value" - async def test_run_kwargs_available_in_function_middleware(self, chat_client_base: "MockBaseChatClient") -> None: - """Test that kwargs passed directly to agent.run() appear in FunctionInvocationContext.kwargs, - including complex nested values like dicts.""" + async def test_function_invocation_kwargs_available_in_function_middleware( + self, chat_client_base: "MockBaseChatClient" + ) -> None: + """Test that function_invocation_kwargs appear in FunctionInvocationContext.kwargs.""" captured_kwargs: dict[str, Any] = {} @function_middleware @@ -822,18 +823,20 @@ class TestChatAgentFunctionMiddlewareWithTools: session_metadata = {"tenant": "acme-corp", "region": "us-west"} await agent.run( [Message(role="user", text="Get weather")], - user_id="user-456", - session_metadata=session_metadata, + function_invocation_kwargs={ + "user_id": "user-456", + "session_metadata": session_metadata, + }, ) assert "user_id" in captured_kwargs, f"Expected 'user_id' in kwargs: {captured_kwargs}" assert captured_kwargs["user_id"] == "user-456" assert captured_kwargs["session_metadata"] == {"tenant": "acme-corp", "region": "us-west"} - async def test_run_kwargs_merged_with_additional_function_arguments( + async def test_function_invocation_kwargs_merged_with_additional_function_arguments( self, chat_client_base: "MockBaseChatClient" ) -> None: - """Test that explicit additional_function_arguments in options take precedence over run kwargs.""" + """Test that explicit additional_function_arguments in options take precedence.""" captured_kwargs: dict[str, Any] = {} @function_middleware @@ -863,9 +866,10 @@ class TestChatAgentFunctionMiddlewareWithTools: await agent.run( [Message(role="user", text="Get weather")], - # This kwarg should be overridden by additional_function_arguments - user_id="from-kwargs", - tenant_id="from-kwargs", + function_invocation_kwargs={ + "user_id": "from-kwargs", + "tenant_id": "from-kwargs", + }, options={ "additional_function_arguments": { "user_id": "from-options", @@ -876,15 +880,15 @@ class TestChatAgentFunctionMiddlewareWithTools: # additional_function_arguments takes precedence for overlapping keys assert captured_kwargs["user_id"] == "from-options" - # Non-overlapping kwargs from run() still come through + # Non-overlapping function_invocation_kwargs still come through assert captured_kwargs["tenant_id"] == "from-kwargs" # Keys only in additional_function_arguments are present assert captured_kwargs["extra_key"] == "only-in-options" - async def test_run_kwargs_consistent_across_multiple_tool_calls( + async def test_function_invocation_kwargs_consistent_across_multiple_tool_calls( self, chat_client_base: "MockBaseChatClient" ) -> None: - """Test that kwargs are consistent across multiple tool invocations in a single run.""" + """Test that function_invocation_kwargs are consistent across tool invocations.""" invocation_kwargs: list[dict[str, Any]] = [] @function_middleware @@ -917,8 +921,10 @@ class TestChatAgentFunctionMiddlewareWithTools: await agent.run( [Message(role="user", text="Get weather for both cities")], - user_id="user-456", - request_id="req-001", + function_invocation_kwargs={ + "user_id": "user-456", + "request_id": "req-001", + }, ) assert len(invocation_kwargs) == 2 @@ -2060,23 +2066,21 @@ class TestChatAgentChatMiddleware: "agent_middleware_after", ] - async def test_agent_middleware_can_access_and_override_custom_kwargs(self) -> None: - """Test that agent middleware can access and override custom parameters like temperature.""" - captured_kwargs: dict[str, Any] = {} - modified_kwargs: dict[str, Any] = {} + async def test_agent_middleware_can_access_and_override_options(self) -> None: + """Test that agent middleware can access and override runtime options.""" + captured_options: dict[str, Any] = {} + modified_options: dict[str, Any] = {} @agent_middleware async def kwargs_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: - # Capture the original kwargs - captured_kwargs.update(context.kwargs) + assert isinstance(context.options, dict) + captured_options.update(context.options) - # Modify some kwargs - context.kwargs["temperature"] = 0.9 - context.kwargs["max_tokens"] = 500 - context.kwargs["new_param"] = "added_by_middleware" + context.options["temperature"] = 0.9 + context.options["max_tokens"] = 500 + context.options["new_param"] = "added_by_middleware" - # Store modified kwargs for verification - modified_kwargs.update(context.kwargs) + modified_options.update(context.options) await call_next() @@ -2084,24 +2088,25 @@ class TestChatAgentChatMiddleware: client = MockBaseChatClient() agent = Agent(client=client, middleware=[kwargs_middleware]) - # Execute the agent with custom parameters + # Execute the agent with runtime options messages = [Message(role="user", text="test message")] - response = await agent.run(messages, temperature=0.7, max_tokens=100, custom_param="test_value") + response = await agent.run( + messages, + options={"temperature": 0.7, "max_tokens": 100, "custom_param": "test_value"}, + ) # Verify response assert response is not None assert len(response.messages) > 0 - # Verify middleware captured the original kwargs - assert captured_kwargs["temperature"] == 0.7 - assert captured_kwargs["max_tokens"] == 100 - assert captured_kwargs["custom_param"] == "test_value" + assert captured_options["temperature"] == 0.7 + assert captured_options["max_tokens"] == 100 + assert captured_options["custom_param"] == "test_value" - # Verify middleware could modify the kwargs - assert modified_kwargs["temperature"] == 0.9 - assert modified_kwargs["max_tokens"] == 500 - assert modified_kwargs["new_param"] == "added_by_middleware" - assert modified_kwargs["custom_param"] == "test_value" # Should still be there + assert modified_options["temperature"] == 0.9 + assert modified_options["max_tokens"] == 500 + assert modified_options["new_param"] == "added_by_middleware" + assert modified_options["custom_param"] == "test_value" # class TestMiddlewareWithProtocolOnlyAgent: diff --git a/python/packages/core/tests/core/test_middleware_with_chat.py b/python/packages/core/tests/core/test_middleware_with_chat.py index 5fa9d64031..b3393c2248 100644 --- a/python/packages/core/tests/core/test_middleware_with_chat.py +++ b/python/packages/core/tests/core/test_middleware_with_chat.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from typing import Any +from unittest.mock import patch from agent_framework import ( Agent, @@ -296,50 +297,77 @@ class TestChatMiddleware: assert response3 is not None assert execution_count["count"] == 2 # Should be 2 now - async def test_chat_client_middleware_can_access_and_override_custom_kwargs( + async def test_run_level_middleware_is_not_forwarded_to_inner_client( self, chat_client_base: "MockBaseChatClient" ) -> None: - """Test that chat client middleware can access and override custom parameters like temperature.""" - captured_kwargs: dict[str, Any] = {} - modified_kwargs: dict[str, Any] = {} + """Test that run-level middleware stays in the middleware pipeline only.""" + observed_context_kwargs: dict[str, Any] = {} + + @chat_middleware + async def inspecting_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + observed_context_kwargs.update(context.kwargs) + await call_next() + + async def fake_inner_get_response(**kwargs: Any) -> ChatResponse: + assert "middleware" not in kwargs + return ChatResponse(messages=[Message(role="assistant", text="ok")]) + + with patch.object( + chat_client_base, + "_inner_get_response", + side_effect=fake_inner_get_response, + ) as mock_inner_get_response: + response = await chat_client_base.get_response( + [Message(role="user", text="hello")], + client_kwargs={"middleware": [inspecting_middleware], "trace_id": "trace-123"}, + ) + + assert response.messages[0].text == "ok" + assert observed_context_kwargs == {"trace_id": "trace-123"} + mock_inner_get_response.assert_called_once() + + async def test_chat_client_middleware_can_access_and_override_options( + self, chat_client_base: "MockBaseChatClient" + ) -> None: + """Test that chat client middleware can access and override runtime options.""" + captured_options: dict[str, Any] = {} + modified_options: dict[str, Any] = {} @chat_middleware async def kwargs_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: - # Capture the original kwargs - captured_kwargs.update(context.kwargs) + assert isinstance(context.options, dict) + captured_options.update(context.options) - # Modify some kwargs - context.kwargs["temperature"] = 0.9 - context.kwargs["max_tokens"] = 500 - context.kwargs["new_param"] = "added_by_middleware" + context.options["temperature"] = 0.9 + context.options["max_tokens"] = 500 + context.options["new_param"] = "added_by_middleware" - # Store modified kwargs for verification - modified_kwargs.update(context.kwargs) + modified_options.update(context.options) await call_next() # Add middleware to chat client chat_client_base.chat_middleware = [kwargs_middleware] - # Execute chat client with custom parameters + # Execute chat client with runtime options messages = [Message(role="user", text="test message")] response = await chat_client_base.get_response( - messages, temperature=0.7, max_tokens=100, custom_param="test_value" + messages, + options={"temperature": 0.7, "max_tokens": 100, "custom_param": "test_value"}, ) # Verify response assert response is not None assert len(response.messages) > 0 - assert captured_kwargs["temperature"] == 0.7 - assert captured_kwargs["max_tokens"] == 100 - assert captured_kwargs["custom_param"] == "test_value" + assert captured_options["temperature"] == 0.7 + assert captured_options["max_tokens"] == 100 + assert captured_options["custom_param"] == "test_value" - # Verify middleware could modify the kwargs - assert modified_kwargs["temperature"] == 0.9 - assert modified_kwargs["max_tokens"] == 500 - assert modified_kwargs["new_param"] == "added_by_middleware" - assert modified_kwargs["custom_param"] == "test_value" # Should still be there + assert modified_options["temperature"] == 0.9 + assert modified_options["max_tokens"] == 500 + assert modified_options["new_param"] == "added_by_middleware" + assert modified_options["custom_param"] == "test_value" def test_chat_middleware_pipeline_cache_reuses_matching_middleware( self, diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 7642ffe73a..332ee2b6e6 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -207,7 +207,7 @@ async def test_chat_client_observability(mock_chat_client, span_exporter: InMemo messages = [Message(role="user", text="Test message")] span_exporter.clear() - response = await client.get_response(messages=messages, model_id="Test") + response = await client.get_response(messages=messages, options={"model_id": "Test"}) assert response is not None spans = span_exporter.get_finished_spans() assert len(spans) == 1 @@ -232,7 +232,7 @@ async def test_chat_client_streaming_observability( span_exporter.clear() # Collect all yielded updates updates = [] - stream = client.get_response(stream=True, messages=messages, model_id="Test") + stream = client.get_response(stream=True, messages=messages, options={"model_id": "Test"}) async for update in stream: updates.append(update) await stream.get_final_response() @@ -1540,7 +1540,7 @@ async def test_chat_client_observability_exception(mock_chat_client, span_export span_exporter.clear() with pytest.raises(ValueError, match="Test error"): - await client.get_response(messages=messages, model_id="Test") + await client.get_response(messages=messages, options={"model_id": "Test"}) spans = span_exporter.get_finished_spans() assert len(spans) == 1 @@ -1570,7 +1570,7 @@ async def test_chat_client_streaming_observability_exception(mock_chat_client, s span_exporter.clear() with pytest.raises(ValueError, match="Streaming error"): - async for _ in client.get_response(messages=messages, stream=True, model_id="Test"): + async for _ in client.get_response(messages=messages, stream=True, options={"model_id": "Test"}): pass spans = span_exporter.get_finished_spans() @@ -2075,7 +2075,7 @@ async def test_capture_messages_with_finish_reason(mock_chat_client, span_export messages = [Message(role="user", text="Test")] span_exporter.clear() - response = await client.get_response(messages=messages, model_id="Test") + response = await client.get_response(messages=messages, options={"model_id": "Test"}) assert response is not None assert response.finish_reason == "stop" @@ -2165,7 +2165,7 @@ async def test_chat_client_when_disabled(mock_chat_client, span_exporter: InMemo messages = [Message(role="user", text="Test")] span_exporter.clear() - response = await client.get_response(messages=messages, model_id="Test") + response = await client.get_response(messages=messages, options={"model_id": "Test"}) assert response is not None spans = span_exporter.get_finished_spans() @@ -2181,7 +2181,7 @@ async def test_chat_client_streaming_when_disabled(mock_chat_client, span_export span_exporter.clear() updates = [] - async for update in client.get_response(messages=messages, stream=True, model_id="Test"): + async for update in client.get_response(messages=messages, stream=True, options={"model_id": "Test"}): updates.append(update) assert len(updates) == 2 # Still works functionally @@ -2661,7 +2661,7 @@ async def test_capture_messages_preserves_non_ascii_characters(mock_chat_client, messages = [Message(role="user", text=japanese_text)] span_exporter.clear() - response = await client.get_response(messages=messages, model_id="Test") + response = await client.get_response(messages=messages, options={"model_id": "Test"}) assert response is not None spans = span_exporter.get_finished_spans() diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 859a012e1d..0f87219690 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -594,8 +594,8 @@ async def test_tool_invoke_telemetry_sensitive_disabled(span_exporter: InMemoryS assert attributes[OtelAttr.TOOL_CALL_ID] == "test_call_id" -async def test_tool_invoke_ignores_additional_kwargs() -> None: - """Ensure tools drop unknown kwargs when invoked with validated arguments.""" +async def test_tool_invoke_rejects_unexpected_runtime_kwargs() -> None: + """Ensure invoke() requires runtime data to flow through FunctionInvocationContext.""" @tool async def simple_tool(message: str) -> str: @@ -604,15 +604,12 @@ async def test_tool_invoke_ignores_additional_kwargs() -> None: args = simple_tool.input_model(message="hello world") - # These kwargs simulate runtime context passed through function invocation. - result = await simple_tool.invoke( - arguments=args, - api_token="secret-token", - options={"model_id": "dummy"}, - ) - - assert isinstance(result, list) - assert result[0].text == "HELLO WORLD" + with pytest.raises(TypeError, match="Unexpected keyword argument"): + await simple_tool.invoke( + arguments=args, + api_token="secret-token", + options={"model_id": "dummy"}, + ) async def test_tool_invoke_telemetry_with_pydantic_args(span_exporter: InMemorySpanExporter): @@ -917,8 +914,8 @@ def test_parse_inputs_unsupported_type(): # endregion -async def test_ai_function_with_kwargs_injection(): - """Test that ai_function correctly handles kwargs injection and hides them from schema.""" +async def test_ai_function_with_kwargs_rejects_runtime_invoke_kwargs(): + """Test that runtime kwargs must be passed through FunctionInvocationContext.""" @tool def tool_with_kwargs(x: int, **kwargs: Any) -> str: @@ -937,13 +934,11 @@ async def test_ai_function_with_kwargs_injection(): # Verify direct invocation works assert tool_with_kwargs(1, user_id="user1") == "x=1, user=user1" - # Verify invoke works with injected args - result = await tool_with_kwargs.invoke( - arguments=tool_with_kwargs.input_model(x=5), - user_id="user2", - ) - assert isinstance(result, list) - assert result[0].text == "x=5, user=user2" + with pytest.raises(TypeError, match="Unexpected keyword argument"): + await tool_with_kwargs.invoke( + arguments=tool_with_kwargs.input_model(x=5), + user_id="user2", + ) # Verify invoke works without injected args (uses default) result_default = await tool_with_kwargs.invoke( diff --git a/python/packages/devui/tests/devui/conftest.py b/python/packages/devui/tests/devui/conftest.py index 3ff5f499a7..114a7a7d6d 100644 --- a/python/packages/devui/tests/devui/conftest.py +++ b/python/packages/devui/tests/devui/conftest.py @@ -446,7 +446,7 @@ async def executor_with_real_agent() -> tuple[AgentFrameworkExecutor, str, MockB name="Test Chat Agent", description="A real Agent for testing execution flow", client=mock_client, - system_message="You are a helpful test assistant.", + instructions="You are a helpful test assistant.", ) # Register the real agent @@ -478,14 +478,14 @@ async def sequential_workflow() -> tuple[AgentFrameworkExecutor, str, MockBaseCh name="Writer", description="Content writer agent", client=mock_client, - system_message="You are a content writer. Create clear, engaging content.", + instructions="You are a content writer. Create clear, engaging content.", ) reviewer = Agent( id="reviewer", name="Reviewer", description="Content reviewer agent", client=mock_client, - system_message="You are a reviewer. Provide constructive feedback.", + instructions="You are a reviewer. Provide constructive feedback.", ) workflow = SequentialBuilder(participants=[writer, reviewer]).build() @@ -523,21 +523,21 @@ async def concurrent_workflow() -> tuple[AgentFrameworkExecutor, str, MockBaseCh name="Researcher", description="Research agent", client=mock_client, - system_message="You are a researcher. Find key data and insights.", + instructions="You are a researcher. Find key data and insights.", ) analyst = Agent( id="analyst", name="Analyst", description="Analysis agent", client=mock_client, - system_message="You are an analyst. Identify trends and patterns.", + instructions="You are an analyst. Identify trends and patterns.", ) summarizer = Agent( id="summarizer", name="Summarizer", description="Summary agent", client=mock_client, - system_message="You are a summarizer. Provide concise summaries.", + instructions="You are a summarizer. Provide concise summaries.", ) workflow = ConcurrentBuilder(participants=[researcher, analyst, summarizer]).build() diff --git a/python/packages/devui/tests/devui/test_execution.py b/python/packages/devui/tests/devui/test_execution.py index 4d0436a314..fc3abee80d 100644 --- a/python/packages/devui/tests/devui/test_execution.py +++ b/python/packages/devui/tests/devui/test_execution.py @@ -309,7 +309,7 @@ async def test_full_pipeline_workflow_events_are_json_serializable(): name="Serialization Test Agent", description="Agent for testing serialization", client=mock_client, - system_message="You are a test assistant.", + instructions="You are a test assistant.", ) agent_executor = AgentExecutor(id="agent_node", agent=agent) diff --git a/python/packages/durabletask/agent_framework_durabletask/_entities.py b/python/packages/durabletask/agent_framework_durabletask/_entities.py index 460b6b0429..15fb77285e 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_entities.py +++ b/python/packages/durabletask/agent_framework_durabletask/_entities.py @@ -23,6 +23,7 @@ from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol from ._durable_agent_state import ( DurableAgentState, DurableAgentStateEntry, + DurableAgentStateMessage, DurableAgentStateRequest, DurableAgentStateResponse, ) @@ -151,10 +152,11 @@ class AgentEntity: try: chat_messages: list[Message] = [ - m.to_chat_message() + replayable_message for entry in self.state.data.conversation_history if not self._is_error_response(entry) for m in entry.messages + if (replayable_message := self._to_replayable_message(m)) is not None ] run_kwargs: dict[str, Any] = {"messages": chat_messages, "options": options} @@ -190,6 +192,21 @@ class AgentEntity: return error_response + @staticmethod + def _to_replayable_message(message: DurableAgentStateMessage) -> Message | None: + """Convert persisted history into a message safe to replay into chat clients.""" + chat_message = message.to_chat_message() + replayable_contents = [content for content in chat_message.contents if content.type != "reasoning"] + if not replayable_contents: + return None + + return Message( + role=chat_message.role, + contents=replayable_contents, + author_name=chat_message.author_name, + additional_properties=chat_message.additional_properties, + ) + async def _invoke_agent( self, run_kwargs: dict[str, Any], diff --git a/python/packages/durabletask/tests/test_durable_entities.py b/python/packages/durabletask/tests/test_durable_entities.py index a11e9718ef..e61eacaf0c 100644 --- a/python/packages/durabletask/tests/test_durable_entities.py +++ b/python/packages/durabletask/tests/test_durable_entities.py @@ -21,7 +21,9 @@ from agent_framework_durabletask import ( DurableAgentStateData, DurableAgentStateMessage, DurableAgentStateRequest, + DurableAgentStateResponse, DurableAgentStateTextContent, + DurableAgentStateTextReasoningContent, RunRequest, ) from agent_framework_durabletask._entities import DurableTaskEntityStateProvider @@ -391,6 +393,54 @@ class TestAgentEntityRunAgent: assert len(history) == 6 assert entity.state.message_count == 6 + async def test_run_filters_reasoning_content_from_replayed_history(self) -> None: + """Replayed durable history should not include reasoning-only content items.""" + captured_messages: list[Message] = [] + + async def mock_run(*args, stream=False, **kwargs): + if stream: + raise TypeError("streaming not supported") + captured_messages.extend(kwargs["messages"]) + return _agent_response("Response") + + mock_agent = Mock() + mock_agent.run = mock_run + + entity = _make_entity(mock_agent) + entity.state.data = DurableAgentStateData( + conversation_history=[ + DurableAgentStateRequest( + correlation_id="corr-entity-prev-request", + created_at=datetime.now(), + messages=[ + DurableAgentStateMessage( + role="user", + contents=[DurableAgentStateTextContent(text="Hi")], + ) + ], + ), + DurableAgentStateResponse( + correlation_id="corr-entity-prev-response", + created_at=datetime.now(), + messages=[ + DurableAgentStateMessage( + role="assistant", + contents=[ + DurableAgentStateTextReasoningContent(text="Let me think."), + DurableAgentStateTextContent(text="Hello there."), + ], + ) + ], + ), + ] + ) + + await entity.run({"message": "What next?", "correlationId": "corr-entity-replay"}) + + assert captured_messages + assert all(content.type != "reasoning" for message in captured_messages for content in message.contents) + assert [message.text for message in captured_messages] == ["Hi", "Hello there.", "What next?"] + class TestAgentEntityReset: """Test suite for the reset operation.""" diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 67c6f6070d..6f548b4012 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -27,6 +27,7 @@ from agent_framework import ( RawAgent, load_settings, ) +from agent_framework._compaction import CompactionStrategy, TokenizerProtocol from agent_framework.observability import AgentTelemetryLayer, ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient @@ -125,9 +126,13 @@ class RawFoundryAgentChatClient( # type: ignore[misc] credential: AzureCredentialTypes | None = None, project_client: AIProjectClient | None = None, allow_preview: bool | None = None, + default_headers: Mapping[str, str] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, + instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, ) -> None: """Initialize a raw Foundry Agent client. @@ -141,9 +146,13 @@ class RawFoundryAgentChatClient( # type: ignore[misc] credential: Azure credential for authentication. project_client: An existing AIProjectClient to use. allow_preview: Enables preview opt-in on internally-created AIProjectClient. + default_headers: Additional HTTP headers for requests made through the OpenAI client. env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. - kwargs: Additional keyword arguments. + instruction_role: The role to use for 'instruction' messages. + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. """ settings = load_settings( FoundryAgentSettings, @@ -189,7 +198,14 @@ class RawFoundryAgentChatClient( # type: ignore[misc] # Get OpenAI client from project async_client = self.project_client.get_openai_client() - super().__init__(async_client=async_client, **kwargs) + super().__init__( + async_client=async_client, + default_headers=default_headers, + instruction_role=instruction_role, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, + ) def _get_agent_reference(self) -> dict[str, str]: """Build the agent reference dict for the Responses API.""" @@ -210,7 +226,10 @@ class RawFoundryAgentChatClient( # type: ignore[misc] default_options: FoundryAgentOptionsT | Mapping[str, Any] | None = None, context_providers: Sequence[BaseContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - **kwargs: Any, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: Mapping[str, Any] | None = None, ) -> Agent[FoundryAgentOptionsT]: """Create a FoundryAgent that reuses this client's Foundry configuration.""" function_tools = cast( @@ -233,7 +252,10 @@ class RawFoundryAgentChatClient( # type: ignore[misc] description=description, instructions=instructions, default_options=default_options, - **kwargs, + function_invocation_configuration=function_invocation_configuration, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, ), ) @@ -365,11 +387,15 @@ class _FoundryAgentChatClient( # type: ignore[misc] credential: AzureCredentialTypes | None = None, project_client: AIProjectClient | None = None, allow_preview: bool | None = None, + default_headers: Mapping[str, str] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, + instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, middleware: (Sequence[ChatAndFunctionMiddlewareTypes] | None) = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, - **kwargs: Any, ) -> None: """Initialize a Foundry Agent client with full middleware support. @@ -380,11 +406,15 @@ class _FoundryAgentChatClient( # type: ignore[misc] credential: Azure credential for authentication. project_client: An existing AIProjectClient to use. allow_preview: Enables preview opt-in on internally-created AIProjectClient. + default_headers: Additional HTTP headers for requests made through the OpenAI client. env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. + instruction_role: The role to use for 'instruction' messages. + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. middleware: Optional sequence of middleware. function_invocation_configuration: Optional function invocation configuration. - kwargs: Additional keyword arguments. """ super().__init__( project_endpoint=project_endpoint, @@ -393,11 +423,15 @@ class _FoundryAgentChatClient( # type: ignore[misc] credential=credential, project_client=project_client, allow_preview=allow_preview, + default_headers=default_headers, env_file_path=env_file_path, env_file_encoding=env_file_encoding, + instruction_role=instruction_role, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, middleware=middleware, function_invocation_configuration=function_invocation_configuration, - **kwargs, ) @@ -435,10 +469,19 @@ class RawFoundryAgent( # type: ignore[misc] allow_preview: bool | None = None, tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, context_providers: Sequence[BaseContextProvider] | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, client_type: type[RawFoundryAgentChatClient] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, + id: str | None = None, + name: str | None = None, + description: str | None = None, + instructions: str | None = None, + default_options: FoundryAgentOptionsT | Mapping[str, Any] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: Mapping[str, Any] | None = None, ) -> None: """Initialize a Foundry Agent. @@ -454,11 +497,20 @@ class RawFoundryAgent( # type: ignore[misc] allow_preview: Enables preview opt-in on internally-created AIProjectClient. tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. context_providers: Optional context providers for injecting dynamic context. + middleware: Optional agent-level middleware. client_type: Custom client class to use (must be a subclass of ``RawFoundryAgentChatClient``). Defaults to ``_FoundryAgentChatClient`` (full client middleware). env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. - kwargs: Additional keyword arguments passed to the Agent base class. + id: Optional local agent identifier. + name: Optional display name for the local agent wrapper. + description: Optional local description for the local agent wrapper. + instructions: Optional instructions for the local agent wrapper. + default_options: Default chat options for the local agent wrapper. + function_invocation_configuration: Optional function invocation configuration override. + compaction_strategy: Optional agent-level in-run compaction override. + tokenizer: Optional agent-level tokenizer override. + additional_properties: Additional properties stored on the local agent wrapper. """ # Create the client actual_client_type = client_type or _FoundryAgentChatClient @@ -467,22 +519,38 @@ class RawFoundryAgent( # type: ignore[misc] f"client_type must be a subclass of RawFoundryAgentChatClient, got {actual_client_type.__name__}" ) - client = actual_client_type( - project_endpoint=project_endpoint, - agent_name=agent_name, - agent_version=agent_version, - credential=credential, - project_client=project_client, - allow_preview=allow_preview, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) + client_kwargs: dict[str, Any] = { + "project_endpoint": project_endpoint, + "agent_name": agent_name, + "agent_version": agent_version, + "credential": credential, + "project_client": project_client, + "allow_preview": allow_preview, + "env_file_path": env_file_path, + "env_file_encoding": env_file_encoding, + } + if function_invocation_configuration is not None: + if not issubclass(actual_client_type, FunctionInvocationLayer): + raise TypeError( + "function_invocation_configuration requires a FunctionInvocationLayer-based client_type." + ) + client_kwargs["function_invocation_configuration"] = function_invocation_configuration + + client = actual_client_type(**client_kwargs) super().__init__( client=client, # type: ignore[arg-type] + instructions=instructions, + id=id, + name=name, + description=description, tools=tools, # type: ignore[arg-type] + default_options=cast(FoundryAgentOptionsT | None, default_options), context_providers=context_providers, - **kwargs, + middleware=middleware, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=dict(additional_properties) if additional_properties is not None else None, ) async def configure_azure_monitor( @@ -598,7 +666,15 @@ class FoundryAgent( # type: ignore[misc] client_type: type[RawFoundryAgentChatClient] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, + id: str | None = None, + name: str | None = None, + description: str | None = None, + instructions: str | None = None, + default_options: FoundryAgentOptionsT | Mapping[str, Any] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: Mapping[str, Any] | None = None, ) -> None: """Initialize a Foundry Agent with full middleware and telemetry. @@ -615,7 +691,15 @@ class FoundryAgent( # type: ignore[misc] client_type: Custom client class (must subclass ``RawFoundryAgentChatClient``). env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. - kwargs: Additional keyword arguments. + id: Optional local agent identifier. + name: Optional display name for the local agent wrapper. + description: Optional local description for the local agent wrapper. + instructions: Optional instructions for the local agent wrapper. + default_options: Default chat options for the local agent wrapper. + function_invocation_configuration: Optional function invocation configuration override. + compaction_strategy: Optional agent-level in-run compaction override. + tokenizer: Optional agent-level tokenizer override. + additional_properties: Additional properties stored on the local agent wrapper. """ super().__init__( project_endpoint=project_endpoint, @@ -630,5 +714,13 @@ class FoundryAgent( # type: ignore[misc] client_type=client_type, env_file_path=env_file_path, env_file_encoding=env_file_encoding, - **kwargs, + id=id, + name=name, + description=description, + instructions=instructions, + default_options=default_options, + function_invocation_configuration=function_invocation_configuration, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, ) diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 51d1b96bb3..4634ec8524 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging import sys -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Awaitable, Callable, Mapping, Sequence from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal from agent_framework import ( @@ -15,6 +15,7 @@ from agent_framework import ( FunctionInvocationLayer, load_settings, ) +from agent_framework._compaction import CompactionStrategy, TokenizerProtocol from agent_framework.observability import ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient @@ -132,10 +133,13 @@ class RawFoundryChatClient( # type: ignore[misc] model: str | None = None, credential: AzureCredentialTypes | AzureTokenProvider | None = None, allow_preview: bool | None = None, + default_headers: Mapping[str, str] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, instruction_role: str | None = None, - **kwargs: Any, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, ) -> None: """Initialize a raw Microsoft Foundry chat client. @@ -149,10 +153,13 @@ class RawFoundryChatClient( # type: ignore[misc] credential: Azure credential or token provider for authentication. Required when using ``project_endpoint`` without a ``project_client``. allow_preview: Enables preview opt-in on internally-created AIProjectClient. + default_headers: Additional HTTP headers for requests made through the OpenAI client. env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. instruction_role: The role to use for 'instruction' messages. - kwargs: Additional keyword arguments. + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. """ foundry_settings = load_settings( FoundrySettings, @@ -195,8 +202,11 @@ class RawFoundryChatClient( # type: ignore[misc] super().__init__( model=resolved_model, async_client=project_client.get_openai_client(), + default_headers=default_headers, instruction_role=instruction_role, - **kwargs, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, ) self.project_client = project_client @@ -516,12 +526,15 @@ class FoundryChatClient( # type: ignore[misc] model: str | None = None, credential: AzureCredentialTypes | AzureTokenProvider | None = None, allow_preview: bool | None = None, + default_headers: Mapping[str, str] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, middleware: (Sequence[ChatAndFunctionMiddlewareTypes] | None) = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, - **kwargs: Any, ) -> None: """Initialize a Foundry chat client. @@ -533,12 +546,15 @@ class FoundryChatClient( # type: ignore[misc] Can also be set via environment variable ``FOUNDRY_MODEL``. credential: Azure credential or token provider for authentication. allow_preview: Enables preview opt-in on internally-created AIProjectClient. + default_headers: Additional HTTP headers for requests made through the OpenAI client. env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. instruction_role: The role to use for 'instruction' messages. + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. middleware: Optional sequence of middleware. function_invocation_configuration: Optional function invocation configuration. - kwargs: Additional keyword arguments. """ super().__init__( project_endpoint=project_endpoint, @@ -546,10 +562,13 @@ class FoundryChatClient( # type: ignore[misc] model=model, credential=credential, allow_preview=allow_preview, + default_headers=default_headers, env_file_path=env_file_path, env_file_encoding=env_file_encoding, instruction_role=instruction_role, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, middleware=middleware, function_invocation_configuration=function_invocation_configuration, - **kwargs, ) diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 2eb992d1a2..09a31f941b 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect import os import sys from typing import Any @@ -68,6 +69,17 @@ def test_raw_foundry_agent_chat_client_init_with_agent_name() -> None: assert client.agent_version == "1.0" +def test_raw_foundry_agent_chat_client_init_uses_explicit_parameters() -> None: + signature = inspect.signature(RawFoundryAgentChatClient.__init__) + + assert "default_headers" in signature.parameters + assert "instruction_role" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert "additional_properties" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + def test_raw_foundry_agent_chat_client_get_agent_reference_with_version() -> None: """Test agent reference includes version when provided.""" @@ -129,6 +141,15 @@ def test_raw_foundry_agent_chat_client_as_agent_preserves_client_type() -> None: assert named_agent.client.agent_name == "test-agent" +def test_raw_foundry_agent_chat_client_as_agent_uses_explicit_parameters() -> None: + signature = inspect.signature(RawFoundryAgentChatClient.as_agent) + + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert "additional_properties" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + async def test_raw_foundry_agent_chat_client_prepare_options_validates_tools() -> None: """Test that _prepare_options rejects non-FunctionTool objects.""" @@ -210,6 +231,17 @@ def test_foundry_agent_chat_client_init() -> None: assert client.agent_name == "test-agent" +def test_foundry_agent_chat_client_init_uses_explicit_parameters() -> None: + signature = inspect.signature(_FoundryAgentChatClient.__init__) + + assert "default_headers" in signature.parameters + assert "instruction_role" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert "additional_properties" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + def test_raw_foundry_agent_init_creates_client() -> None: """Test that RawFoundryAgent creates a client internally.""" @@ -241,6 +273,28 @@ def test_raw_foundry_agent_init_with_custom_client_type() -> None: assert isinstance(agent.client, RawFoundryAgentChatClient) +def test_raw_foundry_agent_init_uses_explicit_parameters() -> None: + signature = inspect.signature(RawFoundryAgent.__init__) + + assert "instructions" in signature.parameters + assert "default_options" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert "additional_properties" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + +def test_foundry_agent_init_uses_explicit_parameters() -> None: + signature = inspect.signature(FoundryAgent.__init__) + + assert "instructions" in signature.parameters + assert "default_options" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert "additional_properties" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + def test_raw_foundry_agent_init_rejects_invalid_client_type() -> None: """Test that invalid client_type raises TypeError.""" diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 7489be1896..5691de70e1 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect import json import os import sys @@ -140,6 +141,26 @@ def test_init() -> None: assert client.project_client is mock_project_client +def test_raw_foundry_chat_client_init_uses_explicit_parameters() -> None: + signature = inspect.signature(RawFoundryChatClient.__init__) + + assert "default_headers" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert "additional_properties" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + +def test_foundry_chat_client_init_uses_explicit_parameters() -> None: + signature = inspect.signature(FoundryChatClient.__init__) + + assert "default_headers" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert "additional_properties" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + def test_init_with_default_header() -> None: default_headers = {"X-Unit-Test": "test-guid"} mock_openai_client = _make_mock_openai_client() diff --git a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py index 1cb16fc40c..5b0e15f2a2 100644 --- a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py +++ b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py @@ -3,15 +3,21 @@ from __future__ import annotations import sys -from collections.abc import Sequence -from typing import Any, Generic +from collections.abc import Awaitable, Callable, Mapping, Sequence +from typing import Any, Generic, Literal, cast, overload from agent_framework import ( ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, ChatOptions, + ChatResponse, + ChatResponseUpdate, + CompactionStrategy, FunctionInvocationConfiguration, FunctionInvocationLayer, + Message, + ResponseStream, + TokenizerProtocol, ) from agent_framework._settings import load_settings from agent_framework.observability import ChatTelemetryLayer @@ -122,8 +128,8 @@ class FoundryLocalSettings(TypedDict, total=False): 'FOUNDRY_LOCAL_'. Keys: - model_id: The name of the model deployment to use. - (Env var FOUNDRY_LOCAL_MODEL_ID) + model: The name of the model deployment to use. + (Env var FOUNDRY_LOCAL_MODEL) """ model: str | None @@ -138,6 +144,78 @@ class FoundryLocalClient( ): """Foundry Local Chat completion class with middleware, telemetry, and function invocation support.""" + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[False] = ..., + options: ChatOptions[ResponseModelT], + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + ) -> Awaitable[ChatResponse[ResponseModelT]]: ... + + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[False] = ..., + options: FoundryLocalChatOptionsT | ChatOptions[None] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + ) -> Awaitable[ChatResponse[Any]]: ... + + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[True], + options: FoundryLocalChatOptionsT | ChatOptions[Any] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... + + def get_response( + self, + messages: Sequence[Message], + *, + stream: bool = False, + options: FoundryLocalChatOptionsT | ChatOptions[Any] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: + """Get a response from the Foundry Local chat client with all standard layers enabled.""" + super_get_response = cast( + "Callable[..., Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]]", + super().get_response, + ) + effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} + if middleware is not None: + effective_client_kwargs["middleware"] = middleware + return super_get_response( + messages=messages, + stream=stream, + options=options, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=effective_client_kwargs, + ) + def __init__( self, model: str | None = None, @@ -182,7 +260,7 @@ class FoundryLocalClient( # Create a FoundryLocalClient with a specific model ID: from agent_framework.foundry import FoundryLocalClient - client = FoundryLocalClient(model_id="phi-4-mini") + client = FoundryLocalClient(model="phi-4-mini") agent = client.as_agent( name="LocalAgent", @@ -192,7 +270,7 @@ class FoundryLocalClient( response = await agent.run("What's the weather like in Seattle?") # Or you can set the model id in the environment: - os.environ["FOUNDRY_LOCAL_MODEL_ID"] = "phi-4-mini" + os.environ["FOUNDRY_LOCAL_MODEL"] = "phi-4-mini" client = FoundryLocalClient() # A FoundryLocalManager is created and if set, the service is started. @@ -205,12 +283,12 @@ class FoundryLocalClient( from foundry_local.models import DeviceType client = FoundryLocalClient( - model_id="phi-4-mini", + model="phi-4-mini", device=DeviceType.GPU, ) # and choosing if the model should be prepared on initialization: client = FoundryLocalClient( - model_id="phi-4-mini", + model="phi-4-mini", prepare_model=False, ) # Beware, in this case the first request to generate a completion @@ -230,7 +308,7 @@ class FoundryLocalClient( class MyOptions(FoundryLocalChatOptions, total=False): my_custom_option: str - client: FoundryLocalClient[MyOptions] = FoundryLocalClient(model_id="phi-4-mini") + client: FoundryLocalClient[MyOptions] = FoundryLocalClient(model="phi-4-mini") response = await client.get_response("Hello", options={"my_custom_option": "value"}) Raises: diff --git a/python/packages/foundry_local/tests/test_foundry_local_client.py b/python/packages/foundry_local/tests/test_foundry_local_client.py index c5b4447b28..02b42f22a6 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import inspect from unittest.mock import MagicMock, patch import pytest @@ -66,6 +67,15 @@ def test_foundry_local_client_init(mock_foundry_local_manager: MagicMock) -> Non assert isinstance(client, SupportsChatGetResponse) +def test_foundry_local_client_get_response_uses_explicit_runtime_buckets() -> None: + """Foundry Local should expose explicit runtime buckets instead of raw kwargs.""" + signature = inspect.signature(FoundryLocalClient.get_response) + + assert "client_kwargs" in signature.parameters + assert "function_invocation_kwargs" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + def test_foundry_local_client_init_with_bootstrap_false(mock_foundry_local_manager: MagicMock) -> None: """Test FoundryLocalClient initialization with bootstrap=False.""" with patch( diff --git a/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py b/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py index 8d4aee310f..6f3faf17c6 100644 --- a/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py +++ b/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py @@ -211,7 +211,7 @@ class TaskRunner: client=assistant_chat_client, instructions=assistant_system_prompt, tools=tools, - temperature=self.assistant_sampling_temperature, + default_options={"temperature": self.assistant_sampling_temperature}, context_providers=[ SlidingWindowHistoryProvider( system_message=assistant_system_prompt, @@ -246,7 +246,7 @@ class TaskRunner: return Agent( client=user_simuator_chat_client, instructions=user_sim_system_prompt, - temperature=0.0, + default_options={"temperature": 0.0}, # No sliding window for user simulator to maintain full conversation context # TODO(yuge): Consider adding user tools in future for more realistic scenarios ) diff --git a/python/packages/openai/agent_framework_openai/__init__.py b/python/packages/openai/agent_framework_openai/__init__.py index 855dfb5f7a..5744c16b43 100644 --- a/python/packages/openai/agent_framework_openai/__init__.py +++ b/python/packages/openai/agent_framework_openai/__init__.py @@ -17,7 +17,7 @@ else: from ._assistant_provider import OpenAIAssistantProvider from ._assistants_client import ( AssistantToolResources, - OpenAIAssistantsClient, + OpenAIAssistantsClient, # type: ignore[reportDeprecated] OpenAIAssistantsOptions, ) from ._chat_client import ( diff --git a/python/packages/openai/agent_framework_openai/_assistant_provider.py b/python/packages/openai/agent_framework_openai/_assistant_provider.py index f0b88e1761..f899607039 100644 --- a/python/packages/openai/agent_framework_openai/_assistant_provider.py +++ b/python/packages/openai/agent_framework_openai/_assistant_provider.py @@ -15,7 +15,7 @@ from openai import AsyncOpenAI from openai.types.beta.assistant import Assistant from pydantic import BaseModel -from ._assistants_client import OpenAIAssistantsClient +from ._assistants_client import OpenAIAssistantsClient # type: ignore[reportDeprecated] from ._shared import OpenAISettings, from_assistant_tools, to_assistant_tools if TYPE_CHECKING: @@ -538,7 +538,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]): A configured Agent instance. """ # Create the chat client with the assistant - client = OpenAIAssistantsClient( + client = OpenAIAssistantsClient( # type: ignore[reportDeprecated] model=assistant.model, assistant_id=assistant.id, assistant_name=assistant.name, diff --git a/python/packages/openai/agent_framework_openai/_assistants_client.py b/python/packages/openai/agent_framework_openai/_assistants_client.py index f5755d8640..14aa764492 100644 --- a/python/packages/openai/agent_framework_openai/_assistants_client.py +++ b/python/packages/openai/agent_framework_openai/_assistants_client.py @@ -70,6 +70,11 @@ if sys.version_info >= (3, 12): else: from typing_extensions import override # type: ignore # pragma: no cover +if sys.version_info >= (3, 13): + from warnings import deprecated # type: ignore # pragma: no cover +else: + from typing_extensions import deprecated # type: ignore # pragma: no cover + if sys.version_info >= (3, 11): from typing import Self, TypedDict # type: ignore # pragma: no cover else: @@ -208,6 +213,7 @@ OpenAIAssistantsOptionsT = TypeVar( # endregion +@deprecated("OpenAIAssistantsClient is deprecated. Use OpenAIChatClient instead.") class OpenAIAssistantsClient( # type: ignore[misc] OpenAIConfigMixin, FunctionInvocationLayer[OpenAIAssistantsOptionsT], @@ -216,7 +222,11 @@ class OpenAIAssistantsClient( # type: ignore[misc] BaseChatClient[OpenAIAssistantsOptionsT], Generic[OpenAIAssistantsOptionsT], ): - """OpenAI Assistants client with middleware, telemetry, and function invocation support.""" + """OpenAI Assistants client with middleware, telemetry, and function invocation support. + + .. deprecated:: + OpenAIAssistantsClient is deprecated. Use :class:`OpenAIChatClient` instead. + """ # region Hosted Tool Factory Methods diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 54322cb754..bc7f2f3ba8 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -29,6 +29,7 @@ from typing import ( ) from agent_framework._clients import BaseChatClient +from agent_framework._compaction import CompactionStrategy, TokenizerProtocol from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer from agent_framework._settings import SecretString from agent_framework._telemetry import USER_AGENT_KEY @@ -278,6 +279,9 @@ class RawOpenAIChatClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -295,6 +299,9 @@ class RawOpenAIChatClient( # type: ignore[misc] default_headers: Additional HTTP headers. async_client: Pre-configured OpenAI client. instruction_role: Role for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before the process environment for ``OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. @@ -314,6 +321,9 @@ class RawOpenAIChatClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -338,6 +348,9 @@ class RawOpenAIChatClient( # type: ignore[misc] async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. instruction_role: Role for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before process environment variables for ``AZURE_OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. @@ -358,9 +371,11 @@ class RawOpenAIChatClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize a raw OpenAI Chat client. @@ -391,11 +406,13 @@ class RawOpenAIChatClient( # type: ignore[misc] async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. instruction_role: Role for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before process environment variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` lookups. env_file_encoding: Encoding for the ``.env`` file. - kwargs: Additional keyword arguments forwarded to ``BaseChatClient``. Notes: Environment resolution and routing precedence are: @@ -452,7 +469,11 @@ class RawOpenAIChatClient( # type: ignore[misc] if use_azure_client: self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] - super().__init__(**kwargs) + super().__init__( + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, + ) # region Inner Methods @@ -460,7 +481,6 @@ class RawOpenAIChatClient( # type: ignore[misc] self, messages: Sequence[Message], options: Mapping[str, Any], - **kwargs: Any, ) -> tuple[AsyncOpenAI, dict[str, Any], dict[str, Any]]: """Validate options and prepare the request. @@ -469,7 +489,7 @@ class RawOpenAIChatClient( # type: ignore[misc] """ client = self.client validated_options = await self._validate_options(options) - run_options = await self._prepare_options(messages, validated_options, **kwargs) + run_options = await self._prepare_options(messages, validated_options) return client, run_options, validated_options def _handle_request_error(self, ex: Exception) -> NoReturn: @@ -526,7 +546,7 @@ class RawOpenAIChatClient( # type: ignore[misc] client, run_options, validated_options, - ) = await self._prepare_request(messages, options, **kwargs) + ) = await self._prepare_request(messages, options) try: if "text_format" in run_options: async with client.responses.stream(**run_options) as response: @@ -560,7 +580,7 @@ class RawOpenAIChatClient( # type: ignore[misc] except Exception as ex: self._handle_request_error(ex) return self._parse_response_from_openai(response, options=validated_options) - client, run_options, validated_options = await self._prepare_request(messages, options, **kwargs) + client, run_options, validated_options = await self._prepare_request(messages, options) try: if "text_format" in run_options: response = await client.responses.parse(stream=False, **run_options) @@ -1121,7 +1141,6 @@ class RawOpenAIChatClient( # type: ignore[misc] self, messages: Sequence[Message], options: Mapping[str, Any], - **kwargs: Any, ) -> dict[str, Any]: """Take options dict and create the specific options for Responses API.""" # Exclude keys that are not supported or handled separately @@ -1143,7 +1162,7 @@ class RawOpenAIChatClient( # type: ignore[misc] # messages # Handle instructions by prepending to messages as system message # Only prepend instructions for the first turn (when no conversation/response ID exists) - conversation_id = self._get_current_conversation_id(options, **kwargs) + conversation_id = options.get("conversation_id") if (instructions := options.get("instructions")) and not conversation_id: # First turn: prepend instructions as system message messages = prepend_instructions_to_messages(list(messages), instructions, role="system") @@ -1151,7 +1170,7 @@ class RawOpenAIChatClient( # type: ignore[misc] request_input = self._prepare_messages_for_openai(messages) if not request_input: raise ChatClientInvalidRequestException("Messages are required for chat completions") - conversation_id = self._get_current_conversation_id(options, **kwargs) + conversation_id = options.get("conversation_id") run_options["input"] = request_input # model id @@ -1169,7 +1188,7 @@ class RawOpenAIChatClient( # type: ignore[misc] run_options[new_key] = run_options.pop(old_key) # Handle different conversation ID formats - if conversation_id := self._get_current_conversation_id(options, **kwargs): + if conversation_id := options.get("conversation_id"): if conversation_id.startswith("resp_"): # For response IDs, set previous_response_id and remove conversation property run_options["previous_response_id"] = conversation_id @@ -1223,14 +1242,6 @@ class RawOpenAIChatClient( # type: ignore[misc] raise ValueError("model must be a non-empty string") options["model"] = self.model - def _get_current_conversation_id(self, options: Mapping[str, Any], **kwargs: Any) -> str | None: - """Get the current conversation ID, preferring kwargs over options. - - This ensures runtime-updated conversation IDs (for example, from tool execution - loops) take precedence over the initial configuration provided in options. - """ - return kwargs.get("conversation_id") or options.get("conversation_id") - def _prepare_messages_for_openai(self, chat_messages: Sequence[Message]) -> list[dict[str, Any]]: """Prepare the chat messages for a request. @@ -2490,10 +2501,13 @@ class OpenAIChatClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, instruction_role: str | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, + additional_properties: dict[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, ) -> None: """Initialize an OpenAI Responses client. @@ -2509,11 +2523,14 @@ class OpenAIChatClient( # type: ignore[misc] default_headers: Additional HTTP headers. async_client: Pre-configured OpenAI client. instruction_role: Role for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + additional_properties: Optional additional properties to include on all requests. env_file_path: Optional ``.env`` file that is checked before the process environment for ``OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. - middleware: Optional middleware to apply to the client. - function_invocation_configuration: Optional function invocation configuration override. """ ... @@ -2530,10 +2547,13 @@ class OpenAIChatClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, instruction_role: str | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, + additional_properties: dict[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, ) -> None: """Initialize an OpenAI Responses client. @@ -2556,11 +2576,14 @@ class OpenAIChatClient( # type: ignore[misc] async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. instruction_role: Role for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + additional_properties: Optional additional properties to include on all requests. env_file_path: Optional ``.env`` file that is checked before process environment variables for ``AZURE_OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. - middleware: Optional middleware to apply to the client. - function_invocation_configuration: Optional function invocation configuration override. """ ... @@ -2577,11 +2600,13 @@ class OpenAIChatClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, instruction_role: str | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, - **kwargs: Any, + additional_properties: dict[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, ) -> None: """Initialize an OpenAI Responses client. @@ -2611,13 +2636,15 @@ class OpenAIChatClient( # type: ignore[misc] async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. instruction_role: Role to use for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before process environment variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` lookups. env_file_encoding: Encoding for the ``.env`` file. - middleware: Optional middleware to apply to the client. - function_invocation_configuration: Optional function invocation configuration override. - kwargs: Other keyword parameters. Notes: Environment resolution and routing precedence are: @@ -2675,7 +2702,9 @@ class OpenAIChatClient( # type: ignore[misc] env_file_encoding=env_file_encoding, middleware=middleware, function_invocation_configuration=function_invocation_configuration, - **kwargs, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, ) diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index 514d0a2991..4828014e5b 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -18,6 +18,7 @@ from itertools import chain from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast, overload from agent_framework._clients import BaseChatClient +from agent_framework._compaction import CompactionStrategy, TokenizerProtocol from agent_framework._docstrings import apply_layered_docstring from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer from agent_framework._settings import SecretString @@ -193,6 +194,9 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -210,6 +214,9 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] default_headers: Additional HTTP headers. async_client: Pre-configured OpenAI client. instruction_role: Role for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before the process environment for ``OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. @@ -229,6 +236,9 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -253,6 +263,9 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. instruction_role: Role for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before process environment variables for ``AZURE_OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. @@ -273,9 +286,11 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize a raw OpenAI Chat completion client. @@ -306,11 +321,13 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. instruction_role: Role for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before process environment variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` lookups. env_file_encoding: Encoding for the ``.env`` file. - kwargs: Additional keyword arguments forwarded to ``BaseChatClient``. Notes: Environment resolution and routing precedence are: @@ -366,7 +383,11 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] if use_azure_client: self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] - super().__init__(**kwargs) + super().__init__( + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, + ) # region Hosted Tool Factory Methods @@ -427,7 +448,10 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] *, stream: Literal[False] = ..., options: ChatOptions[ResponseModelBoundT], - **kwargs: Any, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload @@ -437,7 +461,10 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] *, stream: Literal[False] = ..., options: OpenAIChatCompletionOptionsT | ChatOptions[None] | None = None, - **kwargs: Any, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[Any]]: ... @overload @@ -447,7 +474,10 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] *, stream: Literal[True], options: OpenAIChatCompletionOptionsT | ChatOptions[Any] | None = None, - **kwargs: Any, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... @override @@ -457,7 +487,10 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] *, stream: bool = False, options: OpenAIChatCompletionOptionsT | ChatOptions[Any] | None = None, - **kwargs: Any, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Get a response from the raw OpenAI chat client.""" super_get_response = cast( @@ -468,7 +501,10 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] messages=messages, stream=stream, options=options, - **kwargs, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, ) @override @@ -1205,10 +1241,11 @@ class OpenAIChatCompletionClient( # type: ignore[misc] *, stream: Literal[False] = ..., options: ChatOptions[ResponseModelBoundT], + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload @@ -1218,10 +1255,11 @@ class OpenAIChatCompletionClient( # type: ignore[misc] *, stream: Literal[False] = ..., options: OpenAIChatCompletionOptionsT | ChatOptions[None] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @overload @@ -1231,10 +1269,11 @@ class OpenAIChatCompletionClient( # type: ignore[misc] *, stream: Literal[True], options: OpenAIChatCompletionOptionsT | ChatOptions[Any] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... @override @@ -1244,10 +1283,11 @@ class OpenAIChatCompletionClient( # type: ignore[misc] *, stream: bool = False, options: OpenAIChatCompletionOptionsT | ChatOptions[Any] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Get a response from the OpenAI chat client with all standard layers enabled.""" super_get_response = cast( @@ -1261,9 +1301,10 @@ class OpenAIChatCompletionClient( # type: ignore[misc] messages=messages, stream=stream, options=options, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, function_invocation_kwargs=function_invocation_kwargs, client_kwargs=effective_client_kwargs, - **kwargs, ) diff --git a/python/packages/openai/agent_framework_openai/_embedding_client.py b/python/packages/openai/agent_framework_openai/_embedding_client.py index 9cb37ad4df..6a637e29da 100644 --- a/python/packages/openai/agent_framework_openai/_embedding_client.py +++ b/python/packages/openai/agent_framework_openai/_embedding_client.py @@ -79,6 +79,7 @@ class RawOpenAIEmbeddingClient( base_url: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -95,6 +96,7 @@ class RawOpenAIEmbeddingClient( ``OPENAI_BASE_URL``. default_headers: Additional HTTP headers. async_client: Pre-configured OpenAI client. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before the process environment for ``OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. @@ -113,6 +115,7 @@ class RawOpenAIEmbeddingClient( base_url: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -136,6 +139,7 @@ class RawOpenAIEmbeddingClient( default_headers: Additional HTTP headers. async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before process environment variables for ``AZURE_OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. @@ -155,9 +159,9 @@ class RawOpenAIEmbeddingClient( api_version: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize a raw OpenAI embedding client. @@ -187,11 +191,11 @@ class RawOpenAIEmbeddingClient( default_headers: Additional HTTP headers. async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before process environment variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` lookups. env_file_encoding: Encoding for the ``.env`` file. - kwargs: Additional keyword arguments forwarded to ``BaseEmbeddingClient``. Notes: Environment resolution precedence is: @@ -247,7 +251,7 @@ class RawOpenAIEmbeddingClient( if use_azure_client: self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] - super().__init__(**kwargs) + super().__init__(additional_properties=additional_properties) def service_url(self) -> str: """Get the URL of the service.""" diff --git a/python/packages/openai/tests/openai/test_openai_assistants_client.py b/python/packages/openai/tests/openai/test_openai_assistants_client.py index 54171ca7ca..ecb211001d 100644 --- a/python/packages/openai/tests/openai/test_openai_assistants_client.py +++ b/python/packages/openai/tests/openai/test_openai_assistants_client.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import inspect import json import logging from typing import Annotated, Any @@ -11,6 +12,11 @@ from agent_framework import ( Content, Message, SupportsChatGetResponse, + SupportsCodeInterpreterTool, + SupportsFileSearchTool, + SupportsImageGenerationTool, + SupportsMCPTool, + SupportsWebSearchTool, tool, ) from openai.types.beta.threads import ( @@ -30,6 +36,8 @@ from pydantic import Field from agent_framework_openai import OpenAIAssistantsClient +pytestmark = pytest.mark.filterwarnings("ignore:OpenAIAssistantsClient is deprecated\\..*:DeprecationWarning") + def create_test_openai_assistants_client( mock_async_openai: MagicMock, @@ -104,6 +112,25 @@ def mock_async_openai() -> MagicMock: return mock_client +def test_openai_assistants_client_is_deprecated(mock_async_openai: MagicMock) -> None: + with pytest.warns(DeprecationWarning, match="OpenAIAssistantsClient is deprecated. Use OpenAIChatClient instead."): + OpenAIAssistantsClient(model="gpt-4", api_key="test-api-key", async_client=mock_async_openai) + + +def test_openai_assistants_client_init_keeps_var_keyword() -> None: + signature = inspect.signature(OpenAIAssistantsClient.__init__) + + assert any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + +def test_openai_assistants_client_supports_code_interpreter_and_file_search() -> None: + assert isinstance(OpenAIAssistantsClient, SupportsCodeInterpreterTool) + assert not isinstance(OpenAIAssistantsClient, SupportsWebSearchTool) + assert not isinstance(OpenAIAssistantsClient, SupportsImageGenerationTool) + assert not isinstance(OpenAIAssistantsClient, SupportsMCPTool) + assert isinstance(OpenAIAssistantsClient, SupportsFileSearchTool) + + def test_init_with_client(mock_async_openai: MagicMock) -> None: """Test OpenAIAssistantsClient initialization with existing client.""" client = create_test_openai_assistants_client( diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index d4e7b6c4a1..87025cffe4 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import base64 +import inspect import json import os from datetime import datetime, timezone @@ -18,6 +19,11 @@ from agent_framework import ( FunctionTool, Message, SupportsChatGetResponse, + SupportsCodeInterpreterTool, + SupportsFileSearchTool, + SupportsImageGenerationTool, + SupportsMCPTool, + SupportsWebSearchTool, tool, ) from agent_framework._sessions import ( @@ -48,7 +54,7 @@ from openai.types.responses.response_text_delta_event import ResponseTextDeltaEv from pydantic import BaseModel from pytest import param -from agent_framework_openai import OpenAIChatClient +from agent_framework_openai import OpenAIChatClient, OpenAIResponsesClient from agent_framework_openai._chat_client import OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY from agent_framework_openai._exceptions import OpenAIContentFilterException @@ -110,6 +116,40 @@ def test_init(openai_unit_test_env: dict[str, str]) -> None: assert isinstance(openai_responses_client, SupportsChatGetResponse) +def test_init_uses_explicit_parameters() -> None: + signature = inspect.signature(OpenAIChatClient.__init__) + + assert "additional_properties" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + +def test_deprecated_responses_client_supports_all_tool_protocols() -> None: + assert isinstance(OpenAIResponsesClient, SupportsCodeInterpreterTool) + assert isinstance(OpenAIResponsesClient, SupportsWebSearchTool) + assert isinstance(OpenAIResponsesClient, SupportsImageGenerationTool) + assert isinstance(OpenAIResponsesClient, SupportsMCPTool) + assert isinstance(OpenAIResponsesClient, SupportsFileSearchTool) + + +def test_protocol_isinstance_with_responses_client_instance() -> None: + client = object.__new__(OpenAIResponsesClient) + + assert isinstance(client, SupportsCodeInterpreterTool) + assert isinstance(client, SupportsWebSearchTool) + + +def test_deprecated_responses_client_tool_methods_return_dict() -> None: + code_tool = OpenAIResponsesClient.get_code_interpreter_tool() + assert isinstance(code_tool, dict) + assert code_tool.get("type") == "code_interpreter" + + web_tool = OpenAIResponsesClient.get_web_search_tool() + assert isinstance(web_tool, dict) + assert web_tool.get("type") == "web_search" + + def test_init_prefers_openai_responses_model(monkeypatch, openai_unit_test_env: dict[str, str]) -> None: monkeypatch.setenv("OPENAI_RESPONSES_MODEL", "test_responses_model_id") @@ -3033,20 +3073,6 @@ async def test_prepare_options_store_parameter_handling() -> None: assert "previous_response_id" not in options -async def test_conversation_id_precedence_kwargs_over_options() -> None: - """When both kwargs and options contain conversation_id, kwargs wins.""" - client = OpenAIChatClient(model="test-model", api_key="test-key") - messages = [Message(role="user", text="Hello")] - - # options has a stale response id, kwargs carries the freshest one - opts = {"conversation_id": "resp_old_123"} - run_opts = await client._prepare_options(messages, opts, conversation_id="resp_new_456") # type: ignore - - # Verify kwargs takes precedence and maps to previous_response_id for resp_* IDs - assert run_opts.get("previous_response_id") == "resp_new_456" - assert "conversation" not in run_opts - - def _create_mock_responses_text_response(*, response_id: str) -> MagicMock: mock_response = MagicMock() mock_response.id = response_id diff --git a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py index 918fe98767..bda022e94a 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py @@ -465,7 +465,7 @@ async def test_integration_client_agent_existing_session() -> None: first_response = await first_agent.run( "My hobby is photography. Remember this.", session=session, - store=True, + options={"store": True}, ) assert isinstance(first_response, AgentResponse) @@ -476,7 +476,9 @@ async def test_integration_client_agent_existing_session() -> None: client=OpenAIChatClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as second_agent: - second_response = await second_agent.run("What is my hobby?", session=preserved_session) + second_response = await second_agent.run( + "What is my hobby?", session=preserved_session, options={"store": True} + ) assert isinstance(second_response, AgentResponse) assert second_response.text is not None diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index deee60ac7a..18eff3a54f 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import inspect import json import os from typing import Any @@ -11,6 +12,11 @@ from agent_framework import ( Content, Message, SupportsChatGetResponse, + SupportsCodeInterpreterTool, + SupportsFileSearchTool, + SupportsImageGenerationTool, + SupportsMCPTool, + SupportsWebSearchTool, tool, ) from agent_framework.exceptions import ChatClientException, SettingNotFoundError @@ -20,7 +26,7 @@ from openai.types.chat.chat_completion_message import ChatCompletionMessage from pydantic import BaseModel from pytest import param -from agent_framework_openai import OpenAIChatCompletionClient +from agent_framework_openai import OpenAIChatCompletionClient, RawOpenAIChatCompletionClient from agent_framework_openai._exceptions import OpenAIContentFilterException skip_if_openai_integration_tests_disabled = pytest.mark.skipif( @@ -37,6 +43,41 @@ def test_init(openai_unit_test_env: dict[str, str]) -> None: assert isinstance(open_ai_chat_completion, SupportsChatGetResponse) +def test_get_response_docstring_surfaces_layered_runtime_docs() -> None: + docstring = inspect.getdoc(OpenAIChatCompletionClient.get_response) + + assert docstring is not None + assert "Get a response from a chat client." in docstring + assert "function_invocation_kwargs" in docstring + assert "middleware: Optional per-call chat and function middleware." in docstring + assert "function_middleware: Optional per-call function middleware." not in docstring + + +def test_get_response_is_defined_on_openai_class() -> None: + signature = inspect.signature(OpenAIChatCompletionClient.get_response) + + assert OpenAIChatCompletionClient.get_response.__qualname__ == "OpenAIChatCompletionClient.get_response" + assert "middleware" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + +def test_init_uses_explicit_parameters() -> None: + signature = inspect.signature(RawOpenAIChatCompletionClient.__init__) + + assert "additional_properties" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + +def test_supports_web_search_only() -> None: + assert not isinstance(OpenAIChatCompletionClient, SupportsCodeInterpreterTool) + assert isinstance(OpenAIChatCompletionClient, SupportsWebSearchTool) + assert not isinstance(OpenAIChatCompletionClient, SupportsImageGenerationTool) + assert not isinstance(OpenAIChatCompletionClient, SupportsMCPTool) + assert not isinstance(OpenAIChatCompletionClient, SupportsFileSearchTool) + + def test_init_prefers_openai_chat_model(monkeypatch, openai_unit_test_env: dict[str, str]) -> None: monkeypatch.setenv("OPENAI_CHAT_MODEL", "test_chat_model_id") diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client_base.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client_base.py index 3f5cbcddfb..76d11d1dbd 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client_base.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client_base.py @@ -138,7 +138,7 @@ async def test_cmc_structured_output_no_fcc( openai_chat_completion = OpenAIChatCompletionClient() await openai_chat_completion.get_response( messages=chat_history, - response_format=Test, + options={"response_format": Test}, ) mock_create.assert_awaited_once() @@ -322,7 +322,7 @@ async def test_get_streaming_structured_output_no_fcc( async for msg in openai_chat_completion.get_response( stream=True, messages=chat_history, - response_format=Test, + options={"response_format": Test}, ): assert isinstance(msg, ChatResponseUpdate) mock_create.assert_awaited_once() diff --git a/python/packages/openai/tests/openai/test_openai_embedding_client.py b/python/packages/openai/tests/openai/test_openai_embedding_client.py index 4ef39697d6..cf2ff4f60f 100644 --- a/python/packages/openai/tests/openai/test_openai_embedding_client.py +++ b/python/packages/openai/tests/openai/test_openai_embedding_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect import os from unittest.mock import AsyncMock, MagicMock @@ -15,6 +16,7 @@ from agent_framework_openai import ( OpenAIEmbeddingClient, OpenAIEmbeddingOptions, ) +from agent_framework_openai._embedding_client import RawOpenAIEmbeddingClient def _make_openai_response( @@ -44,6 +46,13 @@ def test_openai_construction_with_explicit_params() -> None: assert client.model == "text-embedding-3-small" +def test_raw_openai_embedding_client_init_uses_explicit_parameters() -> None: + signature = inspect.signature(RawOpenAIEmbeddingClient.__init__) + + assert "additional_properties" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + def test_openai_construction_from_env(openai_unit_test_env: dict[str, str]) -> None: client = OpenAIEmbeddingClient() assert client.model == openai_unit_test_env["OPENAI_EMBEDDING_MODEL"] diff --git a/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py b/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py index eb978d3993..9771a27f70 100644 --- a/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py +++ b/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py @@ -1,21 +1,20 @@ # Copyright (c) Microsoft. All rights reserved. -"""Host multiple Foundry-powered agents inside a single Azure Functions app. +"""Host multiple Azure OpenAI-powered agents inside a single Azure Functions app. Components used in this sample: -- FoundryChatClient to create agents bound to a shared Foundry deployment. +- OpenAIChatCompletionClient configured for Azure OpenAI. - AgentFunctionApp to register multiple agents and expose dedicated HTTP endpoints. - Custom tool functions to demonstrate tool invocation from different agents. -Prerequisites: set `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in with Azure CLI before starting the Functions host.""" +Prerequisites: set `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT_NAME`, and sign in with Azure CLI before starting the Functions host.""" import logging -import os from typing import Any from agent_framework import Agent, tool from agent_framework.azure import AgentFunctionApp -from agent_framework.foundry import FoundryChatClient +from agent_framework.openai import OpenAIChatCompletionClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -60,9 +59,7 @@ def calculate_tip(bill_amount: float, tip_percentage: float = 15.0) -> dict[str, # 1. Create multiple agents, each with its own instruction set and tools. -client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], +client = OpenAIChatCompletionClient( credential=AzureCliCredential(), ) diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/client.py b/python/samples/04-hosting/durabletask/02_multi_agent/client.py index 5f69e875ed..81933de8ee 100644 --- a/python/samples/04-hosting/durabletask/02_multi_agent/client.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/client.py @@ -8,7 +8,7 @@ each with their own specialized capabilities and tools. Prerequisites: - The worker must be running with both agents registered -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME when running the worker - Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running """ diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/sample.py b/python/samples/04-hosting/durabletask/02_multi_agent/sample.py index 47be55a6d9..4ef01fe400 100644 --- a/python/samples/04-hosting/durabletask/02_multi_agent/sample.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/sample.py @@ -5,7 +5,7 @@ This sample demonstrates running both the worker and client in a single process for multiple agents with different tools. The worker registers two agents (WeatherAgent and MathAgent), each with their own specialized capabilities. Prerequisites: -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME - Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running (e.g., using Docker) To run this sample: diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/worker.py b/python/samples/04-hosting/durabletask/02_multi_agent/worker.py index ab27cb4edb..9183e9ee61 100644 --- a/python/samples/04-hosting/durabletask/02_multi_agent/worker.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/worker.py @@ -1,13 +1,13 @@ # Copyright (c) Microsoft. All rights reserved. -"""Worker process for hosting multiple agents with different tools using Durable Task. +"""Worker process for hosting multiple Azure OpenAI agents with different tools using Durable Task. This worker registers two agents - a weather assistant and a math assistant - each with their own specialized tools. This demonstrates how to host multiple agents with different capabilities in a single worker process. Prerequisites: -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME - Sign in with Azure CLI for AzureCliCredential authentication - Start a Durable Task Scheduler (e.g., using Docker) """ @@ -19,7 +19,7 @@ from typing import Any from agent_framework import Agent, tool from agent_framework.azure import DurableAIAgentWorker -from agent_framework.foundry import FoundryChatClient +from agent_framework.openai import OpenAIChatCompletionClient from azure.identity import AzureCliCredential from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential from dotenv import load_dotenv @@ -73,13 +73,10 @@ def create_weather_agent(): Returns: Agent: The configured Weather agent with weather tool """ - _client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AsyncAzureCliCredential(), - ) return Agent( - client=_client, + client=OpenAIChatCompletionClient( + credential=AsyncAzureCliCredential(), + ), name=WEATHER_AGENT_NAME, instructions="You are a helpful weather assistant. Provide current weather information.", tools=[get_weather], @@ -92,13 +89,10 @@ def create_math_agent(): Returns: Agent: The configured Math agent with calculation tools """ - _client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AsyncAzureCliCredential(), - ) return Agent( - client=_client, + client=OpenAIChatCompletionClient( + credential=AsyncAzureCliCredential(), + ), name=MATH_AGENT_NAME, instructions="You are a helpful math assistant. Help users with calculations like tip calculations.", tools=[calculate_tip], From 3627b9b9114c592b705b1d30a9d7161fb631ea5d Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:51:21 +0100 Subject: [PATCH 28/31] .NET: Skip flaky integration tests blocking merge queue (#4972) * Skip flaky integration tests blocking merge queue Skip 7 timing-dependent integration tests that are causing a 73% failure rate in the merge queue over the past 3 days. All failures are LLM-latency/timeout related and occur across unrelated PRs. Skipped tests: - ConsoleAppSamplesValidation.ReliableStreamingSampleValidationAsync - ConsoleAppSamplesValidation.SingleAgentOrchestrationHITLSampleValidationAsync - ConsoleAppSamplesValidation.LongRunningToolsSampleValidationAsync - SamplesValidation.LongRunningToolsSampleValidationAsync (AzureFunctions) - SamplesValidation.ReliableStreamingSampleValidationAsync (AzureFunctions) - ExternalClientTests.CallFunctionToolsAsync - ExternalClientTests.CallLongRunningFunctionToolsAsync Tracking issue: https://github.com/microsoft/agent-framework/issues/4971 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Extract skip reason to const per review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ConsoleAppSamplesValidation.cs | 8 +++++--- .../ExternalClientTests.cs | 6 ++++-- .../SamplesValidation.cs | 6 ++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ConsoleAppSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ConsoleAppSamplesValidation.cs index c15405db63..7b8fa3a8f9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ConsoleAppSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ConsoleAppSamplesValidation.cs @@ -13,6 +13,8 @@ namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; [Trait("Category", "SampleValidation")] public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) : SamplesValidationBase(outputHelper) { + private const string SkipFlakyTimingTest = "Flaky: timing-dependent LLM test, see https://github.com/microsoft/agent-framework/issues/4971"; + private static readonly string s_samplesPath = Path.GetFullPath( Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "04-hosting", "DurableAgents", "ConsoleApps")); @@ -235,7 +237,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) Assert.True(foundSuccess, "Orchestration did not complete successfully."); } - [Fact] + [Fact(Skip = SkipFlakyTimingTest)] public async Task SingleAgentOrchestrationHITLSampleValidationAsync() { string samplePath = Path.Combine(s_samplesPath, "05_AgentOrchestration_HITL"); @@ -309,7 +311,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) }); } - [Fact] + [Fact(Skip = SkipFlakyTimingTest)] public async Task LongRunningToolsSampleValidationAsync() { string samplePath = Path.Combine(s_samplesPath, "06_LongRunningTools"); @@ -394,7 +396,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) }); } - [Fact] + [Fact(Skip = SkipFlakyTimingTest)] public async Task ReliableStreamingSampleValidationAsync() { string samplePath = Path.Combine(s_samplesPath, "07_ReliableStreaming"); diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs index 6c200e9876..134a12e688 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs @@ -19,6 +19,8 @@ namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; [Trait("Category", "Integration")] public sealed class ExternalClientTests(ITestOutputHelper outputHelper) : IDisposable { + private const string SkipFlakyTimingTest = "Flaky: timing-dependent LLM test, see https://github.com/microsoft/agent-framework/issues/4971"; + private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : TimeSpan.FromSeconds(60); @@ -75,7 +77,7 @@ public sealed class ExternalClientTests(ITestOutputHelper outputHelper) : IDispo Assert.Contains(agentLogs, log => log.EventId.Name == "LogAgentResponse"); } - [Fact] + [Fact(Skip = SkipFlakyTimingTest)] public async Task CallFunctionToolsAsync() { int weatherToolInvocationCount = 0; @@ -127,7 +129,7 @@ public sealed class ExternalClientTests(ITestOutputHelper outputHelper) : IDispo Assert.Equal(1, packingListToolInvocationCount); } - [Fact] + [Fact(Skip = SkipFlakyTimingTest)] public async Task CallLongRunningFunctionToolsAsync() { [Description("Starts a greeting workflow and returns the workflow instance ID")] diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs index ffd2e39ae2..078b6af790 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs @@ -15,6 +15,8 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests; [Trait("Category", "SampleValidation")] public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLifetime { + private const string SkipFlakyTimingTest = "Flaky: timing-dependent LLM test, see https://github.com/microsoft/agent-framework/issues/4971"; + private const string AzureFunctionsPort = "7071"; private const string AzuritePort = "10000"; private const string DtsPort = "8080"; @@ -272,7 +274,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi }); } - [Fact] + [Fact(Skip = SkipFlakyTimingTest)] public async Task LongRunningToolsSampleValidationAsync() { string samplePath = Path.Combine(s_samplesPath, "06_LongRunningTools"); @@ -402,7 +404,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi }); } - [Fact] + [Fact(Skip = SkipFlakyTimingTest)] public async Task ReliableStreamingSampleValidationAsync() { string samplePath = Path.Combine(s_samplesPath, "08_ReliableStreaming"); From ca02146ee4b454b5a3ae7ef0c35280ea31f501d6 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:55:30 +0100 Subject: [PATCH 29/31] .NET: BugFix #3433 ChatClientAgent streaming responses missing messageid (#4615) * Changes * Fix ChatClientAgent streaming responses missing MessageId Generate fallback MessageId in ChatClientAgent.RunCoreStreamingAsync when the underlying LLM provider does not set ChatResponseUpdate.MessageId. Without a MessageId the AGUI converter's null==null check silently drops all text content, causing CopilotKit Zod validation errors. Changes: - ChatClientAgent: generate msg_{Guid} fallback via ??= in streaming loop - AgentResponseExtensions: sync wrapper MessageId back to RawRepresentation in AsChatResponseUpdate() so downstream consumers see the value - Add unit tests for both fixes and AGUI streaming MessageId scenarios Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #4615 review comments - Fix MessageId seeding: use first-seen provider MessageId (or generate fallback) and apply consistently to all chunks in the stream, preventing message splitting when providers set MessageId only on the first chunk - Add test for mixed MessageId scenario (first chunk only) - Fix skipped TextStreaming test: assert Empty (not NotEmpty) to match actual null==null behavior - Fix skipped ToolCalls test: assert empty ParentMessageId to match actual empty-string passthrough behavior * Handle empty MessageId in AsChatResponseUpdate sync Treat empty/whitespace MessageId the same as null when syncing from the AgentResponseUpdate wrapper back to RawRepresentation. Providers that return empty string MessageId (e.g. tool call responses) now get the wrapper value recovered correctly. Add test for empty string MessageId recovery scenario. * Move MessageId fallback generation to AGUI layer Move fallback MessageId generation from ChatClientAgent to AsAGUIEventStreamAsync, addressing the architectural concern that MessageId is nullable in the AIAgent abstraction and the requirement for non-null values is specific to the AGUI protocol. The AGUI layer now generates a fallback MessageId for null or empty/whitespace values, covering all agent types (not just ChatClientAgent) including external implementations. Changes: - Revert MessageId generation from ChatClientAgent.RunCoreStreamingAsync - Add fallback MessageId generation in AsAGUIEventStreamAsync for null/empty MessageId values (handles both null and whitespace) - Unskip and update AGUI tests to verify fallback generation - Update ChatClientAgent tests to reflect passthrough behavior * Revert AsChatResponseUpdate MessageId sync-back Remove the MessageId sync-back logic from AsChatResponseUpdate() as it is no longer needed. With fallback generation moved to the AGUI layer, the abstraction layer should not mutate the RawRepresentation object. Revert to the original passthrough behavior for AsChatResponseUpdate() and update tests accordingly. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ChatResponseUpdateAGUIExtensions.cs | 9 + .../AGUIStreamingMessageIdTests.cs | 228 ++++++++++++++++++ .../AgentResponseUpdateExtensionsTests.cs | 39 +++ .../ChatClient/ChatClientAgentTests.cs | 69 ++++++ 4 files changed, 345 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs index f5fb103bd4..b11475ddfc 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs @@ -341,8 +341,17 @@ internal static class ChatResponseUpdateAGUIExtensions }; string? currentMessageId = null; + string? streamingMessageId = null; await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) { + // Generate a fallback MessageId when the provider doesn't supply one. + // This ensures all AGUI events have a valid messageId regardless of agent type. + if (string.IsNullOrWhiteSpace(chatResponse.MessageId)) + { + streamingMessageId ??= Guid.NewGuid().ToString("N"); + chatResponse.MessageId = streamingMessageId; + } + if (chatResponse is { Contents.Count: > 0 } && chatResponse.Contents[0] is TextContent && !string.Equals(currentMessageId, chatResponse.MessageId, StringComparison.Ordinal)) diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs new file mode 100644 index 0000000000..5c55408ff8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.AGUI.Shared; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.AGUI.UnitTests; + +/// +/// Tests for AGUI streaming behavior when MessageId is null or missing from +/// ChatResponseUpdate objects (e.g., providers like Google GenAI/Vertex AI +/// that don't supply MessageId on streaming chunks). +/// +public sealed class AGUIStreamingMessageIdTests +{ + /// + /// When ChatResponseUpdate objects with null MessageId are fed directly to + /// AsAGUIEventStreamAsync, the AGUI layer generates a fallback MessageId so + /// that events are valid regardless of agent type or provider. + /// + [Fact] + public async Task TextStreaming_NullMessageId_GeneratesFallbackInAGUILayerAsync() + { + // Arrange - Simulate a provider that does NOT set MessageId + List providerUpdates = + [ + new ChatResponseUpdate(ChatRole.Assistant, "Hello"), + new ChatResponseUpdate(ChatRole.Assistant, " world"), + new ChatResponseUpdate(ChatRole.Assistant, "!") + ]; + + // Act + List aguiEvents = []; + await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() + .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) + { + aguiEvents.Add(evt); + } + + // Assert - AGUI layer should generate a fallback MessageId + List startEvents = aguiEvents.OfType().ToList(); + List contentEvents = aguiEvents.OfType().ToList(); + + Assert.Single(startEvents); + Assert.False(string.IsNullOrEmpty(startEvents[0].MessageId)); + + Assert.Equal(3, contentEvents.Count); + Assert.All(contentEvents, e => Assert.False(string.IsNullOrEmpty(e.MessageId))); + + // All events should share the same generated MessageId + string?[] distinctIds = contentEvents.Select(e => e.MessageId).Distinct().ToArray(); + Assert.Single(distinctIds); + Assert.Equal(startEvents[0].MessageId, distinctIds[0]); + } + + /// + /// Full pipeline: ChatClientAgent → AsChatResponseUpdatesAsync → AsAGUIEventStreamAsync + /// with a provider that returns null MessageId. Verifies that fallback MessageId + /// generation ensures valid AGUI events. + /// + [Fact] + public async Task FullPipeline_NullProviderMessageId_ProducesValidAGUIEventsAsync() + { + // Arrange - ChatClientAgent with a mock client that omits MessageId + IChatClient mockChatClient = new NullMessageIdChatClient(); + ChatClientAgent agent = new(mockChatClient, name: "test-agent"); + + ChatMessage userMessage = new(ChatRole.User, "tell me about agents"); + + // Act - Run the full pipeline exactly as MapAGUI does + List aguiEvents = []; + await foreach (BaseEvent evt in agent + .RunStreamingAsync([userMessage]) + .AsChatResponseUpdatesAsync() + .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) + { + aguiEvents.Add(evt); + } + + // Assert — The pipeline should produce AGUI events with valid messageId + List startEvents = aguiEvents.OfType().ToList(); + List contentEvents = aguiEvents.OfType().ToList(); + + Assert.NotEmpty(startEvents); + Assert.NotEmpty(contentEvents); + + foreach (TextMessageStartEvent startEvent in startEvents) + { + Assert.False( + string.IsNullOrEmpty(startEvent.MessageId), + "TextMessageStartEvent.MessageId should not be null/empty when provider omits it"); + } + + foreach (TextMessageContentEvent contentEvent in contentEvents) + { + Assert.False( + string.IsNullOrEmpty(contentEvent.MessageId), + "TextMessageContentEvent.MessageId should not be null/empty when provider omits it"); + } + + // All content events should share the same messageId + string?[] distinctMessageIds = contentEvents.Select(e => e.MessageId).Distinct().ToArray(); + Assert.Single(distinctMessageIds); + } + + /// + /// When ChatResponseUpdate has empty string MessageId, the AGUI layer generates + /// a fallback so ToolCallStartEvent.ParentMessageId is valid. + /// + [Fact] + public async Task ToolCalls_EmptyMessageId_GeneratesFallbackParentMessageIdAsync() + { + // Arrange - ChatResponseUpdate with a tool call but empty MessageId + FunctionCallContent functionCall = new("call_abc123", "GetWeather") + { + Arguments = new Dictionary { ["location"] = "San Francisco" } + }; + + List providerUpdates = + [ + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + MessageId = "", + Contents = [functionCall] + } + ]; + + // Act + List aguiEvents = []; + await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() + .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) + { + aguiEvents.Add(evt); + } + + // Assert — ParentMessageId should have a generated fallback + ToolCallStartEvent? toolCallStart = aguiEvents.OfType().FirstOrDefault(); + Assert.NotNull(toolCallStart); + Assert.Equal("call_abc123", toolCallStart.ToolCallId); + Assert.Equal("GetWeather", toolCallStart.ToolCallName); + Assert.False( + string.IsNullOrEmpty(toolCallStart.ParentMessageId), + "ParentMessageId should have a generated fallback for empty provider MessageId"); + } + + /// + /// When a provider properly sets MessageId (e.g., OpenAI), the AGUI pipeline + /// produces valid events with correct messageId values. + /// + [Fact] + public async Task TextStreaming_WithProviderMessageId_ProducesValidAGUIEventsAsync() + { + // Arrange — Provider that properly sets MessageId + List providerUpdates = + [ + new ChatResponseUpdate(ChatRole.Assistant, "Hello") + { + MessageId = "chatcmpl-abc123" + }, + new ChatResponseUpdate(ChatRole.Assistant, " world") + { + MessageId = "chatcmpl-abc123" + } + ]; + + // Act + List aguiEvents = []; + await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() + .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) + { + aguiEvents.Add(evt); + } + + // Assert + List startEvents = aguiEvents.OfType().ToList(); + List contentEvents = aguiEvents.OfType().ToList(); + + Assert.Single(startEvents); + Assert.Equal("chatcmpl-abc123", startEvents[0].MessageId); + + Assert.Equal(2, contentEvents.Count); + Assert.All(contentEvents, e => Assert.Equal("chatcmpl-abc123", e.MessageId)); + } +} + +/// +/// Mock IChatClient that simulates a provider not setting MessageId on streaming chunks +/// (e.g., Google GenAI / Vertex AI). +/// +internal sealed class NullMessageIdChatClient : IChatClient +{ + public void Dispose() + { + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "response")])); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (string chunk in (string[])["Agents", " are", " autonomous", " programs."]) + { + yield return new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new TextContent(chunk)] + }; + + await Task.Yield(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseUpdateExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseUpdateExtensionsTests.cs index 89cff04de8..5cf0b75eef 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseUpdateExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseUpdateExtensionsTests.cs @@ -384,6 +384,45 @@ public class AgentResponseUpdateExtensionsTests Assert.Same(originalChatResponseUpdate, result); } + [Fact] + public void AsChatResponseUpdate_WithRawRepresentationNullMessageId_ReturnsRawDirectly() + { + // Arrange - RawRepresentation has null MessageId + ChatResponseUpdate originalChatResponseUpdate = new() + { + ResponseId = "original-update", + Contents = [new TextContent("Hello")] + }; + AgentResponseUpdate agentResponseUpdate = new(originalChatResponseUpdate); + + // Act + ChatResponseUpdate result = agentResponseUpdate.AsChatResponseUpdate(); + + // Assert - Returns the raw representation directly without mutation + Assert.Same(originalChatResponseUpdate, result); + Assert.Null(result.MessageId); + } + + [Fact] + public void AsChatResponseUpdate_WithRawRepresentationExistingMessageId_PreservesOriginal() + { + // Arrange - RawRepresentation already has MessageId set by provider + ChatResponseUpdate originalChatResponseUpdate = new() + { + ResponseId = "original-update", + MessageId = "provider-message-id", + Contents = [new TextContent("Hello")] + }; + AgentResponseUpdate agentResponseUpdate = new(originalChatResponseUpdate); + + // Act + ChatResponseUpdate result = agentResponseUpdate.AsChatResponseUpdate(); + + // Assert - Provider's original MessageId should be preserved + Assert.Same(originalChatResponseUpdate, result); + Assert.Equal("provider-message-id", result.MessageId); + } + [Fact] public void AsChatResponseUpdate_WithoutRawRepresentation_CreatesNewChatResponseUpdate() { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 7241ca763e..42b115c8bb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -1793,6 +1793,75 @@ public partial class ChatClientAgentTests Times.Once); } + /// + /// Verify that RunStreamingAsync passes through null MessageId from provider without modification. + /// MessageId generation is handled by downstream consumers (e.g., AGUI layer), not ChatClientAgent. + /// + [Fact] + public async Task RunStreamingAsync_WithNullMessageId_PassesThroughNullAsync() + { + // Arrange - Provider returns updates WITHOUT MessageId + ChatResponseUpdate[] returnUpdates = + [ + new ChatResponseUpdate(role: ChatRole.Assistant, content: "Hello"), + new ChatResponseUpdate(role: ChatRole.Assistant, content: " world"), + ]; + + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).Returns(ToAsyncEnumerableAsync(returnUpdates)); + + ChatClientAgent agent = new(mockService.Object); + + // Act + List result = []; + await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hi")])) + { + result.Add(update); + } + + // Assert - MessageId should be null (ChatClientAgent does not generate fallback IDs) + Assert.Equal(2, result.Count); + Assert.All(result, u => Assert.Null(u.MessageId)); + } + + /// + /// Verify that RunStreamingAsync preserves provider-supplied MessageId when present. + /// + [Fact] + public async Task RunStreamingAsync_WithProviderMessageId_PreservesItAsync() + { + // Arrange - Provider returns updates WITH MessageId (like OpenAI) + ChatResponseUpdate[] returnUpdates = + [ + new ChatResponseUpdate(role: ChatRole.Assistant, content: "Hello") { MessageId = "chatcmpl-abc123" }, + new ChatResponseUpdate(role: ChatRole.Assistant, content: " world") { MessageId = "chatcmpl-abc123" }, + ]; + + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).Returns(ToAsyncEnumerableAsync(returnUpdates)); + + ChatClientAgent agent = new(mockService.Object); + + // Act + List result = []; + await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hi")])) + { + result.Add(update); + } + + // Assert - Provider's MessageId should be preserved, not overwritten + Assert.Equal(2, result.Count); + Assert.All(result, u => Assert.Equal("chatcmpl-abc123", u.MessageId)); + } + /// /// Verify that RunStreamingAsync uses the ChatHistoryProvider factory when the chat client returns no conversation id. /// From f45fc7d402023421987974d234e11f88ccf157ef Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:09:02 +0100 Subject: [PATCH 30/31] .NET: [Breaking] Update Foundry Agents for Responses API (#4502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stage * Add FoundryAgentClient, model param, chatClientFactory, and RAPI samples - Add model parameter to FoundryAgentClient simple constructor - Add chatClientFactory parameter to both constructors - Switch to OpenAI.GetProjectResponsesClientForModel for direct Responses API usage - Add FoundryAgents-RAPI samples (Step01 Basics, Step02 Multiturn, Step03 FunctionTools) - Add solution folder entry for FoundryAgents-RAPI samples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add auto-discovery constructor and simplify RAPI samples - Add FoundryAgentClient constructor that reads AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME from environment variables with DefaultAzureCredential - Simplify RAPI samples to use auto-discovery (no env var or credential code) - Remove Azure.Identity direct references from sample csproj files - Update READMEs to document environment variable requirements Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add remaining RAPI samples (Step04-Step12) - Step04: Function tools with human-in-the-loop approvals - Step05: Structured output with typed responses - Step06: Persisted conversations with session serialization - Step07: Observability with OpenTelemetry - Step08: Dependency injection with hosted service - Step10: Image multi-modality - Step11: Agent as function tool (agent composition) - Step12: Middleware (PII, guardrails, function logging, HITL approval) - Update solution file and folder README with all new samples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add all RAPI samples (Step09-Step23) and switch to AzureCliCredential - Step09: MCP client as tools (GitHub server via stdio) - Step13: Plugins with dependency injection - Step14: Code Interpreter tool - Step15: Computer Use tool with screenshot simulation - Step16: File Search with vector stores - Step17: OpenAPI tools (REST Countries API) - Step18: Bing Custom Search - Step19: SharePoint grounding - Step20: Microsoft Fabric - Step21: Web Search with citations - Step22: Memory Search with multi-turn conversations - Step23: Local MCP via HTTP (Microsoft Learn) - Switch all samples (Step04-Step12) to use AzureCliCredential with env vars - Update solution file and README with all 23 samples - All 23 samples build successfully, tested Step05/06/11/13/21 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Switch Step01-03 samples to AzureCliCredential for consistency Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clarify connection ID format in SharePoint and Fabric READMEs Document that SHAREPOINT_PROJECT_CONNECTION_ID and FABRIC_PROJECT_CONNECTION_ID should use the connection name (e.g., 'SharepointTestTool'), not the full ARM resource URI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Normalize env vars, fix structured output, update READMEs with connection ID formats - Normalize AZURE_FOUNDRY_PROJECT_* env vars to AZURE_AI_PROJECT_ENDPOINT / AZURE_AI_MODEL_DEPLOYMENT_NAME across all samples (Steps 18-22 READMEs + Steps 19-20 Program.cs) - Fix RAPI Step05 StructuredOutput to use full constructor with ResponseFormat for streaming JSON - Update Deep Research sample to use AzureCliCredential - Enrich Bing Grounding README with full ARM resource URI format - Fix Bing Custom Search README env var mismatch (BING_CUSTOM_SEARCH_* -> AZURE_AI_CUSTOM_SEARCH_*) - Add finding instructions for connection ID and instance name in Bing Custom Search READMEs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refactor memory samples and switch to DefaultAzureCredential - Refactor RAPI Step22 MemorySearch: extract store setup to EnsureMemoryStoreAsync local function - Refactor non-RAPI Step22 MemorySearch: same pattern with explicit memory lifecycle - Set UpdateDelay=0 on MemoryUpdateOptions and MemorySearchPreviewTool for faster ingestion - Use WaitForMemoriesUpdateAsync with 500ms polling interval - Switch Step19 SharePoint, Step20 Fabric, Step22 MemorySearch (both) to DefaultAzureCredential - Remove SearchOptions from MemorySearchPreviewTool (causes unknown parameter error) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Switch all RAPI samples to DefaultAzureCredential and format - Replace AzureCliCredential with DefaultAzureCredential across all 20 RAPI samples - Run dotnet format on all RAPI and non-RAPI Foundry samples - AzureAI unit tests: 341 passed (net10.0 + net472) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename to Microsoft Foundry, add metadata, rename RAPI folder - Replace 'Azure AI Foundry' / 'Azure Foundry' with 'Microsoft Foundry' in all docs, comments, and XML docs - Update FoundryAgentClient metadata provider name to 'microsoft.foundry' - Rename FoundryAgents-RAPI folder to FoundryResponseAgents - Rewrite FoundryResponseAgents README with comparison table vs Foundry Agents - Update slnx and parent README with new folder references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review: simplify sample comments and fix DeepResearch credential - Remove 'no server-side agent' and 'Responses API directly' phrasing from comments - Simplify to 'Create a FoundryAgentClient' per review feedback - Switch Agent_Step15_DeepResearch to DefaultAzureCredential Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Restore full DefaultAzureCredential warning comment in DeepResearch sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add ADR 0020: Foundry agent type naming convention Proposes naming options for a new MAF type wrapping versioned Foundry agents (Prompt, ContainerApp, Hosted, Workflow) to distinguish from the existing FoundryResponsesAgent (RAPI path). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Simplify FoundryResponsesAgent samples with env-var constructors and rename folders - Add env-var constructors to FoundryResponsesAgent (simple + options-based) - Fix Constructor 1 model optionality (no longer throws on missing AZURE_AI_MODEL_DEPLOYMENT_NAME) - Add ApplyModelDeploymentFallback helper for options-based constructor - Update all 23 FoundryResponseAgents samples to remove Environment.GetEnvironmentVariable boilerplate - Condense 6 simple samples to one-liner constructor calls - Add XML doc remarks about auto-resolved parameters on all constructors - Rename FoundryAgents -> FoundryVersionedAgents (server-side, versioned) - Rename FoundryResponseAgents -> FoundryAgents (now the default path forward) - Update .slnx and README cross-references for new folder names Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add FoundryAITool factory, rename RAPI folders, and clean up references - Create FoundryAITool static factory class with 17 methods wrapping AgentTool.Create* and ResponseTool.Create* into AITool returns - Rename 23 FoundryAgentsRAPI_* subfolders to FoundryAgents_* (drop RAPI prefix) - Rename .csproj files and update .slnx references accordingly - Update 12 samples (6 FoundryAgents + 6 FoundryVersionedAgents) to use FoundryAITool - Replace all FoundryResponsesAgent references with FoundryAgent in comments and READMEs - Update sample READMEs to reference FoundryAITool methods Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename FoundryVersionedAgents subfolders from FoundryAgents_* to FoundryVersionedAgents_* Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add FoundryVersionedAgent class and refactor extension method internals - Create FoundryVersionedAgent with private ctor and async static factory methods (CreateAIAgentAsync/GetAIAgentAsync) with env-var and explicit endpoint tiers - Extract shared internal helpers from AzureAIProjectChatClientExtensions: CreateChatClientAgent, CreateAgentVersionFromOptionsAsync, CreateAgentVersionWithProtocolAsync (tools overload), CreateChatClientAgentOptions, GetAgentRecordByNameAsync, ThrowIfInvalidAgentName - Extension methods now delegate to shared internal helpers - All 49 existing samples continue to build successfully Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add CreateConversationSessionAsync, DeleteAIAgentAsync, auto-resolve model, simplify samples - Add CreateConversationSessionAsync to FoundryAgent and FoundryVersionedAgent (returns ChatClientAgentSession, creates server-side conversation + session in one call) - Add DeleteAIAgentAsync static method to FoundryVersionedAgent - Make model parameter optional in env-var factory overloads (auto-resolves from AZURE_AI_MODEL_DEPLOYMENT_NAME) - Update all FoundryVersionedAgents samples to use DeleteAIAgentAsync - Remove deploymentName env var from samples where only used for model parameter - Use CreateConversationSessionAsync in Step02_MultiturnConversation - Use explicit types instead of var for agent/session variables - All 49 samples build successfully Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove manual AIProjectClient construction from FoundryVersionedAgents samples - Replace manual AIProjectClient construction with GetService() from the FoundryVersionedAgent in all dual-option and tool-specific samples - Remove AZURE_AI_PROJECT_ENDPOINT env var reads from updated samples - Remove Azure.Identity usings where no longer needed - Only Step01.1, Step01.2, Eval_Step01 retain manual construction (pedagogical samples) - All 49 samples build successfully Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Replace aiProjectClient extension calls with FoundryVersionedAgent factories in all samples - Replace aiProjectClient.CreateAIAgentAsync with FoundryVersionedAgent.CreateAIAgentAsync in Option 2 (Native SDK) paths across Steps 14-21 - Replace aiProjectClient.Agents.DeleteAgentAsync with FoundryVersionedAgent.DeleteAIAgentAsync - Remove unused AIProjectClient variables and using directives - Only Step01.1, Step01.2, Eval_Step01 retain direct AIProjectClient usage (pedagogical) - Step16, Step22 use GetService() for file/memory operations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove unused using directives from Step01.2, Step09, Eval_Step02 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update ADR 0020 with accepted decision: Option 6 - Add Option 6 detailing FoundryAgent, FoundryVersionedAgent, FoundryAITool, env-var auto-discovery, and self-contained factory patterns - Mark decision as accepted with rationale - Update current state and metadata sections Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Step01 basics samples to use FoundryVersionedAgent factories - Step01.1: Replace manual AIProjectClient/AsAIAgent with FoundryVersionedAgent.CreateAIAgentAsync/GetAIAgentAsync/DeleteAIAgentAsync - Step01.2: Replace manual AIProjectClient with FoundryVersionedAgent.CreateAIAgentAsync/DeleteAIAgentAsync - Remove env var boilerplate and Azure.Identity dependency Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add DeleteAIAgentVersionAsync to FoundryVersionedAgent - DeleteAIAgentAsync: deletes the agent and all its versions (existing) - DeleteAIAgentVersionAsync: deletes only the specific version associated with the agent instance - Internally delegates to Agents.DeleteAgentAsync vs Agents.DeleteAgentVersionAsync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix cleanup comments: DeleteAIAgentAsync deletes the agent and all its versions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update all FoundryVersionedAgents READMEs for FoundryVersionedAgent and auto-discovery - Rewrite main README with FoundryVersionedAgent usage, auto-discovery table, code example - Fix sample table links from FoundryAgents_Step* to FoundryVersionedAgents_Step* - Add FoundryAITool references in tool-specific sample descriptions - Update individual READMEs: fix stale paths, add auto-discovery note after env var blocks - Update tool references: AgentTool/ResponseTool -> FoundryAITool - Update parent 02-agents/README.md with FoundryVersionedAgent description Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert unrelated AGUI and Hosting.OpenAI formatting changes to main Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove env-var auto-discovery, add AsAIAgent, mark extensions Obsolete - Remove 2 env-var constructors from FoundryAgent (keep explicit endpoint ctors) - Remove 5 env-var factory methods from FoundryVersionedAgent (keep explicit ones) - Add 3 AsAIAgent static methods to FoundryVersionedAgent (AgentVersion/AgentRecord/AgentReference) - Mark all 8 AIProjectClient extension methods as [Obsolete] pointing to FoundryVersionedAgent - Remove ApplyModelDeploymentFallback, env var constants, Azure.Identity usings from source Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update all samples to use explicit endpoint, credential, and model parameters - Add explicit Environment.GetEnvironmentVariable reads for AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME to all 48 sample files - Pass new Uri(endpoint), new DefaultAzureCredential(), deploymentName to FoundryAgent constructors and FoundryVersionedAgent factory methods - Add using Azure.Identity where missing - Matches repo-wide pattern used by other non-Foundry samples - All 49 samples build successfully Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Migrate remaining samples and source from obsoleted extension methods - Migrate AgentProviders, AgentWithRAG, AgentWithMemory, HostedWorkflow samples to FoundryVersionedAgent - Migrate AzureAgentProvider.cs to FoundryVersionedAgent.AsAIAgent - Migrate AzureAIProjectChatClientTests.cs to FoundryVersionedAgent.GetAIAgentAsync - Remove pragma suppressions from migrated files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add unit tests for FoundryAgent and FoundryVersionedAgent - FoundryAgentTests.cs: 14 tests covering constructors, validation, properties, metadata, GetService, chat client factory, user-agent header - FoundryVersionedAgentTests.cs: 31 tests covering CreateAIAgentAsync, GetAIAgentAsync, AsAIAgent (3 overloads), DeleteAIAgentAsync, DeleteAIAgentVersionAsync, validation, invalid names, metadata, GetService Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Finalize Foundry agent migration Align FoundryAgent and FoundryVersionedAgent samples, docs, and tests with the explicit configuration model, clean up stale README guidance, and fix AzureAI unit test validation/build issues. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply formatter cleanup after validation Capture the dotnet format follow-up changes produced during branch validation so the committed state matches the successfully built and tested branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add integration tests for FoundryAgent and FoundryVersionedAgent Mark old AIProjectClient extension-method integration tests as obsolete and add new integration test suites for both FoundryAgent (Responses API) and FoundryVersionedAgent (versioned agents). All 71 non-skipped tests pass against the live Foundry service. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update ADR 0020 with test coverage details Add integration test coverage note to the Current State section of ADR 0020. * Simplify Foundry agents and validate moved samples * Rename FoundryAgent integration tests to ResponsesAgent The test classes exercise the non-versioned Responses path via AIProjectClient.AsAIAgent(), not the removed FoundryAgent wrapper type. Rename files and class names to reflect the actual test surface. * Update documentation for ChatClientAgent usage Added example usage of ChatClientAgent with JokerAgent. * Refactor ChatClientAgent instantiation for clarity * Revise agent type naming and usage examples Updated documentation to reflect changes in agent creation methods and added examples for using `ChatClientAgent`. * Fix Azure SDK namespace migration after rebase Update Azure.AI.Projects.OpenAI references to Azure.AI.Projects.Agents and Azure.AI.Extensions.OpenAI to match Azure.AI.Projects 2.0.0-beta.2. - Replace deprecated namespace across samples, tests, and src - Fix renamed types: OpenAPIFunctionDefinition -> OpenApiFunctionDefinition, BingCustomSearchToolParameters -> BingCustomSearchToolOptions, BrowserAutomationToolParameters -> BrowserAutomationToolOptions - Fix API changes: AgentRecord.Versions -> GetLatestVersion(), ResponsesClient constructor, FunctionApprovalRequestContent -> ToolApprovalRequestContent - Apply dotnet format Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address merge markers * Replace obsolete GetAIAgentAsync with AsAIAgent in samples Switch Agent_Step07_AsMcpTool and A2AServer to use the non-obsolete PersistentAgentsClient.AsAIAgent(PersistentAgent) extension instead of the deprecated GetAIAgentAsync, fixing CS0618 build errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix broken markdown links in Responses sample READMEs Replace stale ChatClientAgents_Step* folder references with the correct Agent_Step* names across all Responses sample READMEs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix format errors and address PR review comments - Fix charset and remove unused using in AzureAIProjectResponsesChatClient - Fix doc comment tags (code -> c) in FoundryAITool - Fix stray period in LocalMCP sample comment - Fix grammar in FoundryMemoryProvider xmldoc - Fix AIProjectClientAgentRunStreamingConversationTests base class Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply dotnet format fixes to PR-changed files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix build errors from format pass and apply naming conventions - Fix static call to CreateSessionAsync in Step02 samples and extension tests - Use expression-bodied lambda in FoundryMemoryProvider (RCS1021) - Apply PascalCase naming to const fields in ResponsesAgentExtensionCreateTests (IDE1006) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Introduce FoundryAgent sealed type and update AsAIAgent extensions - Add FoundryAgent sealed class wrapping ChatClientAgent with: - Public ctors: (projectEndpoint, credential, model, instructions) and (agentEndpoint, credential) - Internal ctor: (AIProjectClient, ChatClientAgent) for extension use - CreateConversationSessionAsync() for server-side conversations - GetService() and GetService() - MEAI user-agent policy on internally-created AIProjectClient - Change all AsAIAgent extension return types from ChatClientAgent to FoundryAgent - Update all samples and tests to use FoundryAgent type - Add 16 FoundryAgentTests covering ctors, GetService, UserAgent, RunAsync - Fix pre-existing Agent_Step12_Plugins build error Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Collapse sample folders and add FoundryAgent_Step01 sample - Move all Responses/* samples up to AgentsWithFoundry/ (flat structure) - Remove entire Versioned/ folder (26 samples) - Add FoundryAgent_Step01 sample showing direct FoundryAgent ctor usage - Update slnx to reflect flat folder structure - Fix csproj ProjectReference paths for new depth Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update READMEs for flat AgentsWithFoundry structure - Rewrite AgentsWithFoundry/README.md with FoundryAgent quick start - Fix cd commands and paths in 11 sample READMEs - Update 02-agents/README.md to single Foundry link - Update AGENTS.md tree to flat structure - Fix AgentWithMemory cross-reference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix FoundryAgent_Step01 sample with full create/run/delete lifecycle Show the complete server-side agent lifecycle: create version with native SDK, wrap as FoundryAgent via AsAIAgent, run, then delete. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert RAPI samples to use AIAgent instead of FoundryAgent RAPI samples should not reference FoundryAgent directly. Restored original sample code with only ChatClientAgent -> AIAgent type change to accommodate the AsAIAgent return type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Convert versioned-pattern samples to pure RAPI Step09, Step13, Step17, Step22 were using CreateAgentVersionAsync + PromptAgentDefinition which is the versioned pattern. Converted to use AsAIAgent(model, instructions, tools) which is the RAPI path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix format issues from Docker CI check - FoundryAgent_Step01: CRLF -> LF - Agent_Step09: missing final newline - Agent_Step11_Middleware: add internal modifier, final newline - Agent_Step02: remove redundant cast (IDE0004) - Agent_Step08: simplify name (IDE0001) - FoundryAgentTests: s_ prefix, Async suffix naming conventions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Switch Step09 MCP sample to Microsoft Learn HTTP endpoint Replace npx stdio GitHub MCP server with the public Microsoft Learn MCP endpoint (https://learn.microsoft.com/api/mcp) using HTTP transport. No external tooling required to run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix missing final newline in Step09 MCP sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review: use DelegatingAIAgent, clean up Step01 sample - FoundryAgent now inherits DelegatingAIAgent instead of AIAgent, removing manual delegation boilerplate (westey-m feedback) - Simplified Agent_Step01_Basics to single agent creation path, moved composable IChatClient approach to README (westey-m feedback) - Fixed FoundryAgentTests param name assertion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update sample using Project specialized type instead * Address PR review feedback: DefaultAzureCredential warnings, sample simplifications, format fixes - Add DefaultAzureCredential production warning comments to ~25 samples - Simplify Anthropic and OpenAI Step01 samples to single agent - Convert Step11 Middleware regex patterns to [GeneratedRegex] - Remove unnecessary cleanup comment from Step06 - Fix Step09 README MCP transport description - Enhance FoundryAgent xmldoc with non-persistent agent comparison Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Split Step02, simplify RAG Step04, sharpen Step23 differentiation - Split Step02 into 02.1 (simple multi-turn via sessions) and 02.2 (server-side conversations via CreateConversationSessionAsync) - RAG Step04: replace HostedFileSearchTool + MEAI wrapping with native OpenAI FileSearchTool - Step23: clarify DelegatingAIFunction wrapping pattern vs Step09 basic MCP Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Hosted MCP sample: use ResponseTool.CreateMcpTool and move tool to PromptAgentDefinition Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix broken README link after Step02 split Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Sergey round 3 feedback: branding, README nav, sample rename - Replace 'Azure AI Foundry' with 'Microsoft Foundry' in ADR 0020 - Fix 3 READMEs: 'ChatClientAgents' → 'AgentsWithFoundry' sample directory - Rename FoundryAgent_Step01 → Agent_Step00_FoundryAgentLifecycle for naming consistency Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../0020-foundry-agent-type-naming.md | 125 +++++++ dotnet/agent-framework-dotnet.slnx | 61 ++-- .../AGUI/Step04_HumanInLoop/Client/Program.cs | 2 +- .../ServerFunctionApprovalClientAgent.cs | 8 +- .../ServerFunctionApprovalServerAgent.cs | 28 +- .../Client/StatefulAgent.cs | 5 +- .../Agent_With_AzureAIProject/Program.cs | 13 +- .../Agent_Anthropic_Step01_Running/Program.cs | 13 +- .../Program.cs | 14 +- .../02-agents/AgentWithMemory/README.md | 4 +- .../Agent_OpenAI_Step01_Running/Program.cs | 26 +- .../Program.cs | 24 +- .../Agents/Agent_Step07_AsMcpTool/Program.cs | 17 +- .../Agents/Agent_Step11_Middleware/Program.cs | 18 +- .../Agent_Step15_DeepResearch/Program.cs | 2 +- .../Agent_Step15_DeepResearch/README.md | 10 +- .../Program.cs | 8 +- ...Agent_Step00_FoundryAgentLifecycle.csproj} | 2 +- .../Program.cs | 36 ++ .../README.md | 23 ++ .../Agent_Step01_Basics.csproj | 15 + .../Agent_Step01_Basics/Program.cs | 20 ++ .../Agent_Step01_Basics/README.md | 55 +++ ...ent_Step02.1_MultiturnConversation.csproj} | 3 +- .../Program.cs | 26 ++ .../README.md | 36 ++ ....2_MultiturnWithServerConversations.csproj | 15 + .../Program.cs | 34 ++ .../README.md | 36 ++ .../Agent_Step03_UsingFunctionTools.csproj | 15 + .../Program.cs | 41 +++ .../Agent_Step03_UsingFunctionTools/README.md | 37 ++ ...p04_UsingFunctionToolsWithApprovals.csproj | 15 + .../Program.cs | 25 +- .../README.md | 30 ++ .../Agent_Step05_StructuredOutput.csproj | 15 + .../Agent_Step05_StructuredOutput}/Program.cs | 52 +-- .../Agent_Step05_StructuredOutput/README.md | 29 ++ ...Agent_Step06_PersistedConversations.csproj | 15 + .../Program.cs | 13 +- .../README.md | 30 ++ .../Agent_Step07_Observability.csproj} | 2 - .../Agent_Step07_Observability}/Program.cs | 23 +- .../Agent_Step07_Observability/README.md | 31 ++ .../Agent_Step08_DependencyInjection.csproj} | 2 - .../Program.cs | 83 +++++ .../README.md | 30 ++ ...Agent_Step09_UsingMcpClientAsTools.csproj} | 3 +- .../Program.cs | 44 +++ .../README.md | 29 ++ .../Agent_Step10_UsingImages.csproj | 21 ++ .../Agent_Step10_UsingImages}/Program.cs | 18 +- .../Agent_Step10_UsingImages/README.md | 30 ++ .../assets}/walkway.jpg | Bin .../Agent_Step11_AsFunctionTool.csproj | 15 + .../Agent_Step11_AsFunctionTool}/Program.cs | 34 +- .../Agent_Step11_AsFunctionTool/README.md | 30 ++ .../Agent_Step12_Middleware.csproj} | 4 +- .../Agent_Step12_Middleware}/Program.cs | 95 +++--- .../Agent_Step12_Middleware/README.md | 31 ++ .../Agent_Step13_Plugins.csproj} | 5 +- .../Agent_Step13_Plugins/Program.cs | 153 +++++++++ .../Agent_Step13_Plugins/README.md | 29 ++ .../Agent_Step14_CodeInterpreter.csproj} | 1 - .../Agent_Step14_CodeInterpreter}/Program.cs | 50 +-- .../Agent_Step14_CodeInterpreter/README.md | 28 ++ .../Agent_Step15_ComputerUse.csproj} | 2 +- .../Assets/cua_browser_search.png | Bin .../Assets/cua_search_results.png | Bin .../Assets/cua_search_typed.png | Bin .../ComputerUseUtil.cs | 0 .../Agent_Step15_ComputerUse}/Program.cs | 85 ++--- .../Agent_Step15_ComputerUse/README.md | 29 ++ .../Agent_Step16_FileSearch.csproj} | 1 - .../Agent_Step16_FileSearch}/Program.cs | 45 +-- .../Agent_Step16_FileSearch/README.md | 29 ++ .../Agent_Step17_OpenAPITools.csproj} | 3 +- .../Agent_Step17_OpenAPITools}/Program.cs | 77 ++--- .../Agent_Step17_OpenAPITools/README.md | 29 ++ .../Agent_Step18_BingCustomSearch.csproj} | 1 - .../Agent_Step18_BingCustomSearch}/Program.cs | 59 +--- .../Agent_Step18_BingCustomSearch/README.md | 36 ++ .../Agent_Step19_SharePoint.csproj | 19 ++ .../Agent_Step19_SharePoint}/Program.cs | 55 +-- .../Agent_Step19_SharePoint/README.md | 30 ++ .../Agent_Step20_MicrosoftFabric.csproj | 19 ++ .../Agent_Step20_MicrosoftFabric/Program.cs | 42 +++ .../Agent_Step20_MicrosoftFabric/README.md | 30 ++ .../Agent_Step21_WebSearch.csproj | 19 ++ .../Agent_Step21_WebSearch/Program.cs | 44 +++ .../Agent_Step21_WebSearch/README.md | 28 ++ .../Agent_Step22_MemorySearch.csproj | 20 ++ .../Agent_Step22_MemorySearch}/Program.cs | 85 +++-- .../Agent_Step22_MemorySearch/README.md | 31 ++ .../Agent_Step23_LocalMCP.csproj | 20 ++ .../Agent_Step23_LocalMCP}/Program.cs | 75 ++--- .../Agent_Step23_LocalMCP/README.md | 29 ++ .../02-agents/AgentsWithFoundry/README.md | 81 +++++ .../Program.cs | 100 ------ .../README.md | 101 ------ ...s_Evaluations_Step02_SelfReflection.csproj | 25 -- .../Program.cs | 292 ---------------- .../README.md | 118 ------- .../FoundryAgents_Step01.1_Basics/Program.cs | 50 --- .../FoundryAgents_Step01.1_Basics/README.md | 40 --- .../FoundryAgents_Step01.2_Running/Program.cs | 39 --- .../FoundryAgents_Step01.2_Running/README.md | 46 --- .../Program.cs | 56 ---- .../README.md | 59 ---- .../Program.cs | 54 --- .../README.md | 48 --- .../README.md | 51 --- ...undryAgents_Step05_StructuredOutput.csproj | 20 -- .../README.md | 49 --- ...gents_Step06_PersistedConversations.csproj | 20 -- .../README.md | 50 --- .../README.md | 51 --- .../Program.cs | 97 ------ .../README.md | 51 --- ...Agents_Step09_UsingMcpClientAsTools.csproj | 23 -- .../Program.cs | 50 --- .../README.md | 50 --- .../FoundryAgents_Step10_UsingImages.csproj | 26 -- .../README.md | 53 --- ...FoundryAgents_Step11_AsFunctionTool.csproj | 21 -- .../README.md | 49 --- .../FoundryAgents_Step12_Middleware/README.md | 58 ---- .../FoundryAgents_Step13_Plugins.csproj | 22 -- .../FoundryAgents_Step13_Plugins/Program.cs | 142 -------- .../FoundryAgents_Step13_Plugins/README.md | 49 --- ...oundryAgents_Step14_CodeInterpreter.csproj | 22 -- .../README.md | 53 --- .../README.md | 66 ---- .../FoundryAgents_Step16_FileSearch/README.md | 52 --- .../FoundryAgents_Step17_OpenAPITools.csproj | 22 -- .../README.md | 47 --- ...undryAgents_Step18_BingCustomSearch.csproj | 22 -- .../README.md | 63 ---- .../FoundryAgents_Step19_SharePoint.csproj | 22 -- .../FoundryAgents_Step19_SharePoint/README.md | 50 --- ...oundryAgents_Step20_MicrosoftFabric.csproj | 22 -- .../Program.cs | 72 ---- .../README.md | 57 ---- .../FoundryAgents_Step21_WebSearch.csproj | 22 -- .../FoundryAgents_Step21_WebSearch/Program.cs | 65 ---- .../FoundryAgents_Step21_WebSearch/README.md | 52 --- .../FoundryAgents_Step22_MemorySearch.csproj | 22 -- .../README.md | 92 ----- .../FoundryAgents_Step23_LocalMCP/README.md | 48 --- .../samples/02-agents/FoundryAgents/README.md | 121 ------- .../FoundryAgent_Hosted_MCP/Program.cs | 59 ++-- dotnet/samples/02-agents/README.md | 35 +- .../Agents/FoundryAgent/Program.cs | 18 +- .../Declarative/HostedWorkflow/Program.cs | 3 +- dotnet/samples/03-workflows/README.md | 32 +- .../A2AServer/HostAgentFactory.cs | 5 +- dotnet/samples/AGENTS.md | 2 +- .../AzureAIProjectChatClient.cs | 2 +- .../AzureAIProjectChatClientExtensions.cs | 254 ++++++++++---- .../AzureAIProjectResponsesChatClient.cs | 34 ++ .../FoundryAITool.cs | 210 ++++++++++++ .../FoundryAgent.cs | 208 ++++++++++++ .../RequestOptionsExtensions.cs | 3 + .../FoundryMemoryProvider.cs | 18 +- .../FoundryMemoryProviderScope.cs | 2 +- .../AzureAgentProvider.cs | 2 +- .../AIProjectClientAgentRunStreamingTests.cs | 5 +- .../AIProjectClientAgentRunTests.cs | 3 + ...jectClientAgentStructuredOutputRunTests.cs | 4 + ...tClientChatClientAgentRunStreamingTests.cs | 3 + .../AIProjectClientChatClientAgentRunTests.cs | 3 + .../AIProjectClientCreateTests.cs | 10 +- .../AIProjectClientFixture.cs | 17 +- ...sponsesAgentChatClientRunStreamingTests.cs | 15 + .../ResponsesAgentChatClientRunTests.cs | 15 + .../ResponsesAgentExtensionCreateTests.cs | 122 +++++++ .../ResponsesAgentFixture.cs | 187 +++++++++++ .../ResponsesAgentRunStreamingTests.cs | 32 ++ .../ResponsesAgentRunTests.cs | 32 ++ .../ResponsesAgentStructuredOutputRunTests.cs | 100 ++++++ ...AzureAIProjectChatClientExtensionsTests.cs | 295 ++++++++++++---- .../AzureAIProjectChatClientTests.cs | 31 +- .../FoundryAgentTests.cs | 316 ++++++++++++++++++ .../TestDataUtil.cs | 8 +- .../FoundryMemoryProviderTests.cs | 2 + .../MessageMergerTests.cs | 4 +- 186 files changed, 3961 insertions(+), 3892 deletions(-) create mode 100644 docs/decisions/0020-foundry-agent-type-naming.md rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/FoundryAgents_Step03_UsingFunctionTools.csproj => AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Agent_Step00_FoundryAgentLifecycle.csproj} (92%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/README.md create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Agent_Step01_Basics.csproj create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Program.cs create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/README.md rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/FoundryAgents_Evaluations_Step01_RedTeaming.csproj => AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Agent_Step02.1_MultiturnConversation.csproj} (70%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Program.cs create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/README.md create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Agent_Step02.2_MultiturnWithServerConversations.csproj create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/README.md create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Program.cs create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/README.md create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals => AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals}/Program.cs (69%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/README.md create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step05_StructuredOutput => AgentsWithFoundry/Agent_Step05_StructuredOutput}/Program.cs (53%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/README.md create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step06_PersistedConversations => AgentsWithFoundry/Agent_Step06_PersistedConversations}/Program.cs (78%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/README.md rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step07_Observability/FoundryAgents_Step07_Observability.csproj => AgentsWithFoundry/Agent_Step07_Observability/Agent_Step07_Observability.csproj} (85%) rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step07_Observability => AgentsWithFoundry/Agent_Step07_Observability}/Program.cs (71%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/README.md rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step08_DependencyInjection/FoundryAgents_Step08_DependencyInjection.csproj => AgentsWithFoundry/Agent_Step08_DependencyInjection/Agent_Step08_DependencyInjection.csproj} (83%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Program.cs create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/README.md rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step23_LocalMCP/FoundryAgents_Step23_LocalMCP.csproj => AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Agent_Step09_UsingMcpClientAsTools.csproj} (84%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Program.cs create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/README.md create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Agent_Step10_UsingImages.csproj rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step10_UsingImages => AgentsWithFoundry/Agent_Step10_UsingImages}/Program.cs (61%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/README.md rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step10_UsingImages/Assets => AgentsWithFoundry/Agent_Step10_UsingImages/assets}/walkway.jpg (100%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Agent_Step11_AsFunctionTool.csproj rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step11_AsFunctionTool => AgentsWithFoundry/Agent_Step11_AsFunctionTool}/Program.cs (57%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/README.md rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step12_Middleware/FoundryAgents_Step12_Middleware.csproj => AgentsWithFoundry/Agent_Step12_Middleware/Agent_Step12_Middleware.csproj} (81%) rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step12_Middleware => AgentsWithFoundry/Agent_Step12_Middleware}/Program.cs (70%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/README.md rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step16_FileSearch/FoundryAgents_Step16_FileSearch.csproj => AgentsWithFoundry/Agent_Step13_Plugins/Agent_Step13_Plugins.csproj} (91%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Program.cs create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/README.md rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj => AgentsWithFoundry/Agent_Step14_CodeInterpreter/Agent_Step14_CodeInterpreter.csproj} (89%) rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step14_CodeInterpreter => AgentsWithFoundry/Agent_Step14_CodeInterpreter}/Program.cs (59%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/README.md rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj => AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj} (99%) rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step15_ComputerUse => AgentsWithFoundry/Agent_Step15_ComputerUse}/Assets/cua_browser_search.png (100%) rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step15_ComputerUse => AgentsWithFoundry/Agent_Step15_ComputerUse}/Assets/cua_search_results.png (100%) rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step15_ComputerUse => AgentsWithFoundry/Agent_Step15_ComputerUse}/Assets/cua_search_typed.png (100%) rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step15_ComputerUse => AgentsWithFoundry/Agent_Step15_ComputerUse}/ComputerUseUtil.cs (100%) rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step15_ComputerUse => AgentsWithFoundry/Agent_Step15_ComputerUse}/Program.cs (61%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj => AgentsWithFoundry/Agent_Step16_FileSearch/Agent_Step16_FileSearch.csproj} (89%) rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step16_FileSearch => AgentsWithFoundry/Agent_Step16_FileSearch}/Program.cs (69%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/README.md rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj => AgentsWithFoundry/Agent_Step17_OpenAPITools/Agent_Step17_OpenAPITools.csproj} (82%) rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step17_OpenAPITools => AgentsWithFoundry/Agent_Step17_OpenAPITools}/Program.cs (55%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/README.md rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj => AgentsWithFoundry/Agent_Step18_BingCustomSearch/Agent_Step18_BingCustomSearch.csproj} (89%) rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step18_BingCustomSearch => AgentsWithFoundry/Agent_Step18_BingCustomSearch}/Program.cs (54%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/README.md create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Agent_Step19_SharePoint.csproj rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step19_SharePoint => AgentsWithFoundry/Agent_Step19_SharePoint}/Program.cs (53%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/README.md create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Agent_Step20_MicrosoftFabric.csproj create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/README.md create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Agent_Step21_WebSearch.csproj create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Program.cs create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/README.md create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step22_MemorySearch => AgentsWithFoundry/Agent_Step22_MemorySearch}/Program.cs (70%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/README.md create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj rename dotnet/samples/02-agents/{FoundryAgents/FoundryAgents_Step23_LocalMCP => AgentsWithFoundry/Agent_Step23_LocalMCP}/Program.cs (50%) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/README.md create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/Program.cs delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/FoundryAgents_Evaluations_Step02_SelfReflection.csproj delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/Program.cs delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/Program.cs delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/Program.cs delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/FoundryAgents_Step09_UsingMcpClientAsTools.csproj delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/Program.cs delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/FoundryAgents_Step10_UsingImages.csproj delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/FoundryAgents_Step11_AsFunctionTool.csproj delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/Program.cs delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/FoundryAgents_Step17_OpenAPITools.csproj delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/FoundryAgents_Step18_BingCustomSearch.csproj delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/FoundryAgents_Step19_SharePoint.csproj delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/FoundryAgents_Step20_MicrosoftFabric.csproj delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/Program.cs delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/FoundryAgents_Step21_WebSearch.csproj delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/Program.cs delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/FoundryAgents_Step22_MemorySearch.csproj delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/README.md delete mode 100644 dotnet/samples/02-agents/FoundryAgents/README.md create mode 100644 dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectResponsesChatClient.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAITool.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAgent.cs create mode 100644 dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs create mode 100644 dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunTests.cs create mode 100644 dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentExtensionCreateTests.cs create mode 100644 dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentFixture.cs create mode 100644 dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunStreamingTests.cs create mode 100644 dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunTests.cs create mode 100644 dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FoundryAgentTests.cs diff --git a/docs/decisions/0020-foundry-agent-type-naming.md b/docs/decisions/0020-foundry-agent-type-naming.md new file mode 100644 index 0000000000..03d43f64ac --- /dev/null +++ b/docs/decisions/0020-foundry-agent-type-naming.md @@ -0,0 +1,125 @@ +--- +status: accepted +contact: rogerbarreto +date: 2026-03-06 +deciders: rogerbarreto, alliscode +consulted: "" +informed: "" +--- + +# Foundry agent surface stays centered on `ChatClientAgent` + +## Context + +The Microsoft Foundry integration exposes two distinct usage patterns: + +1. Direct Responses usage, where callers provide model, instructions, and tools at runtime. +2. Server-side versioned agents, where callers create and manage `AgentVersion` resources through `AIProjectClient.Agents`. + +We briefly explored adding public wrapper types such as `FoundryAgent`, `FoundryVersionedAgent`, and `FoundryResponsesChatClient` to make those paths feel more specialized. That direction created extra public types, duplicated existing `ChatClientAgent` behavior, and pushed samples toward compatibility helpers instead of the native Azure SDK flow. + +## Decision + +Keep the public surface centered on `ChatClientAgent`. + +- Direct Responses scenarios use `AIProjectClient.AsAIAgent(...)`. +- Server-side versioned scenarios use native `AIProjectClient.Agents` APIs to create or retrieve agent resources, then wrap `AgentRecord` or `AgentVersion` with `AIProjectClient.AsAIAgent(...)`. +- Compatibility helpers such as `AIProjectClient.CreateAIAgentAsync(...)` and `AIProjectClient.GetAIAgentAsync(...)` remain only as obsolete migration shims. +- Public wrapper types `FoundryAgent`, `FoundryVersionedAgent`, `FoundryResponsesChatClient`, and `FoundryResponsesChatClientAgent` are not part of the chosen direction. + +## Why + +- `ChatClientAgent` is already the framework abstraction used everywhere else. +- `AIProjectClient` is the native Azure SDK entry point for versioned agent lifecycle operations. +- A single agent abstraction avoids parallel type hierarchies for the same backend. +- Samples become clearer when they show either: + - direct Responses construction via `AIProjectClient.AsAIAgent(...)`, or + - native Foundry resource management via `AIProjectClient.Agents`. + +## Consequences + +### Direct Responses path + +Use the convenience overloads on `AIProjectClient`: + +```csharp +AIProjectClient aiProjectClient = new(new Uri(endpoint), credential); + +ChatClientAgent agent = aiProjectClient.AsAIAgent( + model: deploymentName, + instructions: "You are good at telling jokes.", + name: "JokerAgent"); +``` + +Or use composed `ChatClientAgent` + +```csharp +ProjectResponsesClient projectResponsesClient = new(new Uri(endpoint), new DefaultAzureCredential(), new AgentReference($"model:{deploymentName}")); + +ChatClientAgent agent = new( + chatClient: projectResponsesClient.AsIChatClient(), + instructions: "You are good at telling jokes.", + name: "JokerAgent"); +``` + +This path is code-first and does not create a persistent server-side agent. + +### Versioned agent path + +Use the convenience overloads on `AIProjectClient`: + +```csharp +AIProjectClient aiProjectClient = new(new Uri(endpoint), credential); + +AgentVersion version = await aiProjectClient.Agents.CreateAgentVersionAsync( + "JokerAgent", + new AgentVersionCreationOptions( + new PromptAgentDefinition(deploymentName) + { + Instructions = "You are good at telling jokes." + })); + +ChatClientAgent agent = aiProjectClient.AsAIAgent(version); +``` + +Or use composed `ChatClientAgent` + +```csharp +AIProjectClient aiProjectClient = new(new Uri(endpoint), credential); + +AgentVersion version = await aiProjectClient.Agents.CreateAgentVersionAsync( + "JokerAgent", + new AgentVersionCreationOptions( + new PromptAgentDefinition(deploymentName) + { + Instructions = "You are good at telling jokes." + })); + +ProjectResponsesClient projectResponsesClient = aiProjectClient + .GetProjectOpenAIClient() + .GetProjectResponsesClientForAgent(new AgentReference(version.Name, version.Version)); + +ChatClientAgent agent = new( + chatClient: projectResponsesClient.AsIChatClient(), + name: "JokerAgent"); +``` + +### Samples + +- `FoundryAgents/` samples show the direct Responses path with `AIProjectClient.AsAIAgent(...)`. +- `FoundryVersionedAgents/` samples should show native `AIProjectClient.Agents` create/get/delete flows plus `AsAIAgent(...)`. + +### Compatibility APIs + +Obsolete helper extensions remain only to ease migration of existing code. New samples and new guidance should not be written against them. + +## Rejected direction + +Do not introduce or preserve separate public wrapper types whose main purpose is to forward to `ChatClientAgent` while carrying Foundry-specific naming. + +That approach: + +- duplicates lifecycle concepts already present on `AIProjectClient`, +- fragments the public API, +- complicates samples and docs, +- and makes migration harder by encouraging wrapper-specific affordances. diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index a5b93487b3..1d0178e615 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -1,4 +1,4 @@ - + @@ -121,6 +121,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -143,35 +171,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -318,8 +317,8 @@ - + diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs index 5d770ff3fd..0c3e75cf96 100644 --- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs @@ -70,7 +70,7 @@ while ((input = Console.ReadLine()) != null && !input.Equals("exit", StringCompa if (approvalRequest.AdditionalProperties != null) { - approvalResponse.AdditionalProperties = new AdditionalPropertiesDictionary(); + approvalResponse.AdditionalProperties = []; foreach (var kvp in approvalRequest.AdditionalProperties) { approvalResponse.AdditionalProperties[kvp.Key] = kvp.Value; diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs index 866bbfad31..135d87bffd 100644 --- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs +++ b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs @@ -131,9 +131,9 @@ internal sealed class ServerFunctionApprovalClientAgent : DelegatingAIAgent transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex); approvalCalls.Remove(functionResult.CallId); } - else if (transformedContents != null) + else { - transformedContents.Add(content); + transformedContents?.Add(content); } } @@ -155,10 +155,10 @@ internal sealed class ServerFunctionApprovalClientAgent : DelegatingAIAgent result ??= CopyMessagesUpToIndex(messages, messageIndex); result.Add(newMessage); } - else if (result != null) + else { // We're already copying messages, so copy this unchanged message too - result.Add(message); + result?.Add(message); } // If result is null, we haven't made any changes yet, so keep processing } diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs index ff3e6ffbb1..8c1f27eea9 100644 --- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs +++ b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs @@ -57,16 +57,10 @@ internal sealed class ServerFunctionApprovalAgent : DelegatingAIAgent throw new InvalidOperationException("Invalid request_approval tool call"); } - var request = toolCall.Arguments.TryGetValue("request", out var reqObj) && + var request = (toolCall.Arguments.TryGetValue("request", out var reqObj) && reqObj is JsonElement argsElement && argsElement.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalRequest))) is ApprovalRequest approvalRequest && - approvalRequest != null ? approvalRequest : null; - - if (request == null) - { - throw new InvalidOperationException("Failed to deserialize approval request from tool call"); - } - + approvalRequest != null ? approvalRequest : null) ?? throw new InvalidOperationException("Failed to deserialize approval request from tool call"); return new ToolApprovalRequestContent( requestId: request.ApprovalId, new FunctionCallContent( @@ -77,17 +71,11 @@ internal sealed class ServerFunctionApprovalAgent : DelegatingAIAgent private static ToolApprovalResponseContent ConvertToolResultToApprovalResponse(FunctionResultContent result, ToolApprovalRequestContent approval, JsonSerializerOptions jsonSerializerOptions) { - var approvalResponse = result.Result is JsonElement je ? + var approvalResponse = (result.Result is JsonElement je ? (ApprovalResponse?)je.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse))) : result.Result is string str ? (ApprovalResponse?)JsonSerializer.Deserialize(str, jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse))) : - result.Result as ApprovalResponse; - - if (approvalResponse == null) - { - throw new InvalidOperationException("Failed to deserialize approval response from tool result"); - } - + result.Result as ApprovalResponse) ?? throw new InvalidOperationException("Failed to deserialize approval response from tool result"); return approval.CreateResponse(approvalResponse.Approved); } #pragma warning restore MEAI001 @@ -121,7 +109,7 @@ internal sealed class ServerFunctionApprovalAgent : DelegatingAIAgent // Track approval ID to original call ID mapping _ = new Dictionary(); #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - Dictionary trackedRequestApprovalToolCalls = new(); // Remote approvals + Dictionary trackedRequestApprovalToolCalls = []; // Remote approvals for (int messageIndex = 0; messageIndex < messages.Count; messageIndex++) { var message = messages[messageIndex]; @@ -146,7 +134,7 @@ internal sealed class ServerFunctionApprovalAgent : DelegatingAIAgent }); } else if (content is FunctionResultContent toolResult && - trackedRequestApprovalToolCalls.TryGetValue(toolResult.CallId, out var approval) == true) + trackedRequestApprovalToolCalls.TryGetValue(toolResult.CallId, out var approval)) { result ??= CopyMessagesUpToIndex(messages, messageIndex); transformedContents ??= CopyContentsUpToIndex(message.Contents, j); @@ -161,9 +149,9 @@ internal sealed class ServerFunctionApprovalAgent : DelegatingAIAgent AdditionalProperties = message.AdditionalProperties }); } - else if (result != null) + else { - result.Add(message); + result?.Add(message); } } } diff --git a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/StatefulAgent.cs b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/StatefulAgent.cs index 41c94d5686..8a8062befe 100644 --- a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/StatefulAgent.cs +++ b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/StatefulAgent.cs @@ -72,10 +72,9 @@ internal sealed class StatefulAgent : DelegatingAIAgent if (content is DataContent dataContent && dataContent.MediaType == "application/json") { // Deserialize the state - TState? newState = JsonSerializer.Deserialize( + if (JsonSerializer.Deserialize( dataContent.Data.Span, - this._jsonSerializerOptions.GetTypeInfo(typeof(TState))) as TState; - if (newState != null) + this._jsonSerializerOptions.GetTypeInfo(typeof(TState))) is TState newState) { this.State = newState; } diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs index aab95d5b38..b2cd14f68a 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs @@ -6,6 +6,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; @@ -30,14 +31,18 @@ var createdAgentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: J // agentVersion.Name = // You can use an AIAgent with an already created server side agent version. -AIAgent existingJokerAgent = aiProjectClient.AsAIAgent(createdAgentVersion); +FoundryAgent existingJokerAgent = aiProjectClient.AsAIAgent(createdAgentVersion); // You can also create another AIAgent version by providing the same name with a different definition. -AIAgent newJokerAgent = await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: "You are extremely hilarious at telling jokes."); +AgentVersion newJokerAgentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( + JokerName, + new AgentVersionCreationOptions(new PromptAgentDefinition(model: deploymentName) { Instructions = "You are extremely hilarious at telling jokes." })); +FoundryAgent newJokerAgent = aiProjectClient.AsAIAgent(newJokerAgentVersion); // You can also get the AIAgent latest version just providing its name. -AIAgent jokerAgentLatest = await aiProjectClient.GetAIAgentAsync(name: JokerName); -var latestAgentVersion = jokerAgentLatest.GetService()!; +AgentRecord jokerAgentRecord = await aiProjectClient.Agents.GetAgentAsync(JokerName); +FoundryAgent jokerAgentLatest = aiProjectClient.AsAIAgent(jokerAgentRecord); +AgentVersion latestAgentVersion = jokerAgentRecord.GetLatestVersion(); // The AIAgent version can be accessed via the GetService method. Console.WriteLine($"Latest agent version id: {latestAgentVersion.Id}"); diff --git a/dotnet/samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step01_Running/Program.cs b/dotnet/samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step01_Running/Program.cs index 3d9c715588..04df345cd6 100644 --- a/dotnet/samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step01_Running/Program.cs +++ b/dotnet/samples/02-agents/AgentWithAnthropic/Agent_Anthropic_Step01_Running/Program.cs @@ -5,20 +5,13 @@ using Anthropic; using Anthropic.Core; using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? throw new InvalidOperationException("ANTHROPIC_API_KEY is not set."); var model = Environment.GetEnvironmentVariable("ANTHROPIC_CHAT_MODEL_NAME") ?? "claude-haiku-4-5"; -AIAgent agent = new AnthropicClient(new ClientOptions { ApiKey = apiKey }) +AIAgent agent = + new AnthropicClient(new ClientOptions { ApiKey = apiKey }) .AsAIAgent(model: model, instructions: "You are good at telling jokes.", name: "Joker"); // Invoke the agent and output the text result. -var response = await agent.RunAsync("Tell me a joke about a pirate."); -Console.WriteLine(response); - -// Invoke the agent with streaming support. -await foreach (var update in agent.RunStreamingAsync("Tell me a joke about a pirate.")) -{ - Console.WriteLine(update); -} +Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs index 914eda330a..d6410d3308 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs @@ -11,6 +11,7 @@ using System.Text.Json; using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; using Microsoft.Agents.AI.FoundryMemory; string foundryEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); @@ -19,6 +20,9 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLO string embeddingModelName = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002"; // Create an AIProjectClient for Foundry with Azure Identity authentication. +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. DefaultAzureCredential credential = new(); AIProjectClient projectClient = new(new Uri(foundryEndpoint), credential); @@ -33,11 +37,15 @@ FoundryMemoryProvider memoryProvider = new( memoryStoreName, stateInitializer: _ => new(new FoundryMemoryProviderScope("sample-user-123"))); -AIAgent agent = await projectClient.CreateAIAgentAsync(deploymentName, - options: new ChatClientAgentOptions() +FoundryAgent agent = projectClient.AsAIAgent( + new ChatClientAgentOptions() { Name = "TravelAssistantWithFoundryMemory", - ChatOptions = new() { Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details." }, + ChatOptions = new() + { + ModelId = deploymentName, + Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details." + }, AIContextProviders = [memoryProvider] }); diff --git a/dotnet/samples/02-agents/AgentWithMemory/README.md b/dotnet/samples/02-agents/AgentWithMemory/README.md index 87818c77d6..aa95012f68 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/README.md +++ b/dotnet/samples/02-agents/AgentWithMemory/README.md @@ -1,4 +1,4 @@ -# Agent Framework Retrieval Augmented Generation (RAG) +# Agent Framework Retrieval Augmented Generation (RAG) These samples show how to create an agent with the Agent Framework that uses Memory to remember previous conversations or facts from previous conversations. @@ -10,4 +10,4 @@ These samples show how to create an agent with the Agent Framework that uses Mem |[Memory with Azure AI Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories.| |[Bounded Chat History with Overflow](./AgentWithMemory_Step05_BoundedChatHistory/)|This sample demonstrates how to create a bounded chat history provider that overflows older messages to a vector store and recalls them as memories.| -> **See also**: [Memory Search with Foundry Agents](../FoundryAgents/FoundryAgents_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Azure Foundry Agents. +> **See also**: [Memory Search with Foundry Agents](../AgentsWithFoundry/Agent_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Azure Foundry agents. diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs index e2bd31055a..e82f420105 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs @@ -4,28 +4,14 @@ using System.ClientModel; using Microsoft.Agents.AI; -using OpenAI; -using OpenAI.Chat; +using OpenAI.Responses; var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-4o-mini"; -AIAgent agent = new OpenAIClient(apiKey) - .GetChatClient(model) - .AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); +AIAgent agent = + new ResponsesClient(new ApiKeyCredential(apiKey)) + .AsAIAgent(model: model, instructions: "You are good at telling jokes.", name: "Joker"); -UserChatMessage chatMessage = new("Tell me a joke about a pirate."); - -// Invoke the agent and output the text result. -ChatCompletion chatCompletion = await agent.RunAsync([chatMessage]); -Console.WriteLine(chatCompletion.Content.Last().Text); - -// Invoke the agent with streaming support. -AsyncCollectionResult completionUpdates = agent.RunStreamingAsync([chatMessage]); -await foreach (StreamingChatCompletionUpdate completionUpdate in completionUpdates) -{ - if (completionUpdate.ContentUpdate.Count > 0) - { - Console.WriteLine(completionUpdate.ContentUpdate[0].Text); - } -} +// Once you have the agent, you can invoke it like any other AIAgent. +Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs index c356bccbd9..e049618a72 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs @@ -4,11 +4,13 @@ using System.ClientModel; using Azure.AI.Projects; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; +using Microsoft.Agents.AI.AzureAI; using OpenAI; using OpenAI.Files; +using OpenAI.Responses; using OpenAI.VectorStores; var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); @@ -37,14 +39,20 @@ ClientResult vectorStoreCreate = await vectorStoreClient.CreateVect FileIds = { uploadResult.Value.Id } }); -var fileSearchTool = new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreCreate.Value.Id)] }; +// Use the native OpenAI SDK FileSearchTool directly with the vector store ID. +#pragma warning disable OPENAI001 +FileSearchTool fileSearchTool = new([vectorStoreCreate.Value.Id]); +#pragma warning restore OPENAI001 -AIAgent agent = await aiProjectClient - .CreateAIAgentAsync( - model: deploymentName, - name: "AskContoso", - instructions: "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.", - tools: [fileSearchTool]); +AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( + "AskContoso", + new AgentVersionCreationOptions( + new PromptAgentDefinition(model: deploymentName) + { + Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.", + Tools = { fileSearchTool } + })); +FoundryAgent agent = aiProjectClient.AsAIAgent(agentVersion); AgentSession session = await agent.CreateSessionAsync(); diff --git a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs index 7bc6478968..a63063b6a5 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs @@ -3,6 +3,7 @@ // This sample shows how to expose an AI agent as an MCP tool. using Azure.AI.Projects; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.DependencyInjection; @@ -18,11 +19,17 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYME var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); // Create a server side agent and expose it as an AIAgent. -AIAgent agent = await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - instructions: "You are good at telling jokes, and you always start each joke with 'Aye aye, captain!'.", - name: "Joker", - description: "An agent that tells jokes."); +AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( + "Joker", + new AgentVersionCreationOptions( + new PromptAgentDefinition(model: deploymentName) + { + Instructions = "You are good at telling jokes, and you always start each joke with 'Aye aye, captain!'.", + }) + { + Description = "An agent that tells jokes.", + }); +AIAgent agent = aiProjectClient.AsAIAgent(agentVersion); // Convert the agent to an AIFunction and then to an MCP tool. // The agent name and description will be used as the mcp tool name and description. diff --git a/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs index 18969ed66e..bab09bc886 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs @@ -189,9 +189,9 @@ async Task PIIMiddleware(IEnumerable messages, Agent // Regex patterns for PII detection (simplified for demonstration) Regex[] piiPatterns = [ - new(@"\b\d{3}-\d{3}-\d{4}\b", RegexOptions.Compiled), // Phone number (e.g., 123-456-7890) - new(@"\b[\w\.-]+@[\w\.-]+\.\w+\b", RegexOptions.Compiled), // Email address - new(@"\b[A-Z][a-z]+\s[A-Z][a-z]+\b", RegexOptions.Compiled) // Full name (e.g., John Doe) + MyRegex(), // Phone number (e.g., 123-456-7890) + EmailRegex(), // Email address + FullNameRegex() // Full name (e.g., John Doe) ]; foreach (var pattern in piiPatterns) @@ -309,3 +309,15 @@ internal sealed class DateTimeContextProvider : MessageAIContextProvider ]); } } + +internal partial class Program +{ + [GeneratedRegex(@"\b\d{3}-\d{3}-\d{4}\b", RegexOptions.Compiled)] + private static partial Regex MyRegex(); + + [GeneratedRegex(@"\b[\w\.-]+@[\w\.-]+\.\w+\b", RegexOptions.Compiled)] + private static partial Regex EmailRegex(); + + [GeneratedRegex(@"\b[A-Z][a-z]+\s[A-Z][a-z]+\b", RegexOptions.Compiled)] + private static partial Regex FullNameRegex(); +} diff --git a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs index 7a76f73455..11d3f561f4 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs @@ -17,10 +17,10 @@ var bingConnectionId = Environment.GetEnvironmentVariable("AZURE_AI_BING_CONNECT PersistentAgentsAdministrationClientOptions persistentAgentsClientOptions = new(); persistentAgentsClientOptions.Retry.NetworkTimeout = TimeSpan.FromMinutes(20); -// Get a client to create/retrieve server side agents with. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +// Get a client to create/retrieve server side agents with. PersistentAgentsClient persistentAgentsClient = new(endpoint, new DefaultAzureCredential(), persistentAgentsClientOptions); // Define and configure the Deep Research tool. diff --git a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md index dc24ba4554..1848b10826 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md @@ -23,12 +23,14 @@ Before running this sample, ensure you have: Pay special attention to the purple `Note` boxes in the Azure documentation. -**Note**: The Bing Connection ID must be from the **project**, not the resource. It has the following format: +**Note**: The Bing Grounding Connection ID must be the **full ARM resource URI** from the project, not just the connection name. It has the following format: ``` -/subscriptions//resourceGroups//providers//accounts//projects//connections/ +/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/ ``` +You can find this in the Azure AI Foundry portal under **Management > Connected resources**, or retrieve it programmatically via the connections API (`.id` property). + ## Environment Variables Set the following environment variables: @@ -37,8 +39,8 @@ Set the following environment variables: # Replace with your Azure AI Foundry project endpoint $env:AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/" -# Replace with your Bing connection ID from the project -$env:AZURE_AI_BING_CONNECTION_ID="/subscriptions/.../connections/your-bing-connection" +# Replace with your Bing Grounding connection ID (full ARM resource URI) +$env:AZURE_AI_BING_CONNECTION_ID="/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/" # Optional, defaults to o3-deep-research $env:AZURE_AI_REASONING_DEPLOYMENT_NAME="o3-deep-research" diff --git a/dotnet/samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Program.cs index e3913c9f0e..0fd21833e1 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Program.cs @@ -24,12 +24,12 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT Func> loadNextThreeCalendarEvents = async () => { // In a real implementation, this method would connect to a calendar service - return new string[] - { + return + [ "Doctor's appointment today at 15:00", "Team meeting today at 17:00", "Birthday party today at 20:00" - }; + ]; }; // Create an agent with an AI context provider attached that aggregates two other providers: @@ -87,7 +87,7 @@ namespace SampleApp internal sealed class TodoListAIContextProvider : AIContextProvider { private static List GetTodoItems(AgentSession? session) - => session?.StateBag.GetValue>(nameof(TodoListAIContextProvider)) ?? new List(); + => session?.StateBag.GetValue>(nameof(TodoListAIContextProvider)) ?? []; private static void SetTodoItems(AgentSession? session, List items) => session?.StateBag.SetValue(nameof(TodoListAIContextProvider), items); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/FoundryAgents_Step03_UsingFunctionTools.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Agent_Step00_FoundryAgentLifecycle.csproj similarity index 92% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/FoundryAgents_Step03_UsingFunctionTools.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Agent_Step00_FoundryAgentLifecycle.csproj index daf7e24494..d861331d9f 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/FoundryAgents_Step03_UsingFunctionTools.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Agent_Step00_FoundryAgentLifecycle.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs new file mode 100644 index 0000000000..c6b2d5c764 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create, use, and clean up a FoundryAgent backed by a server-side +// versioned agent in Azure AI Foundry. It demonstrates the full lifecycle: +// create agent version -> wrap as FoundryAgent -> run -> delete. + +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Azure.Identity; +using Microsoft.Agents.AI.AzureAI; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +const string JokerName = "JokerAgent"; + +// Create the AIProjectClient to manage server-side agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +// Create a server-side agent version using the native SDK. +AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( + JokerName, + new AgentVersionCreationOptions( + new PromptAgentDefinition(model: deploymentName) + { + Instructions = "You are good at telling jokes.", + })); + +// Wrap the agent version as a FoundryAgent using the AsAIAgent extension. +FoundryAgent agent = aiProjectClient.AsAIAgent(agentVersion); + +// Once you have the agent, you can invoke it like any other AIAgent. +Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); + +// Cleanup: deletes the agent and all its versions. +await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/README.md new file mode 100644 index 0000000000..738a1d2e42 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/README.md @@ -0,0 +1,23 @@ +# Agent Step 00 - FoundryAgent Lifecycle + +This sample demonstrates the full lifecycle of a `FoundryAgent` backed by a server-side versioned agent in Microsoft Foundry: create → run → delete. + +## Prerequisites + +- A Microsoft Foundry project endpoint +- A model deployment name (defaults to `gpt-4o-mini`) +- Azure CLI installed and authenticated + +## Environment Variables + +| Variable | Description | Required | +| --- | --- | --- | +| `AZURE_AI_PROJECT_ENDPOINT` | Microsoft Foundry project endpoint | Yes | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Model deployment name | No (defaults to `gpt-4o-mini`) | + +## Running the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step00_FoundryAgentLifecycle +``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Agent_Step01_Basics.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Agent_Step01_Basics.csproj new file mode 100644 index 0000000000..7367c1d2f8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Agent_Step01_Basics.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Program.cs new file mode 100644 index 0000000000..cd89116db7 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Program.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and run a basic agent with AIProjectClient.AsAIAgent(...). + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIAgent agent = + new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) + .AsAIAgent(model: deploymentName, instructions: "You are good at telling jokes.", name: "JokerAgent"); + +// Once you have the agent, you can invoke it like any other AIAgent. +Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/README.md new file mode 100644 index 0000000000..88eebb2a82 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/README.md @@ -0,0 +1,55 @@ +# Creating and Running a Basic Agent with the Responses API + +This sample demonstrates how to create and run a basic AI agent using the `ChatClientAgent`, which uses the Microsoft Foundry Responses API directly without creating server-side agent definitions. + +## What this sample demonstrates + +- Creating a `ChatClientAgent` with instructions and a model +- Running a simple single-turn conversation +- No server-side agent creation or cleanup required + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (for Azure credential authentication) + +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +Navigate to the AgentsWithFoundry sample directory and run: + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step01_Basics +``` + +## Alternative: Composable approach + +You can also create the same agent by composing the underlying `IChatClient` directly. This gives you full control over the chat client pipeline: + +```csharp +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +AIAgent agent = new ChatClientAgent( + chatClient: aiProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient().AsIChatClient(deploymentName), + instructions: "You are good at telling jokes.", + name: "JokerAgent"); +``` + +This approach is useful when you need to customize the chat client pipeline or swap providers (e.g., Anthropic, OpenAI) while keeping the same agent code. diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/FoundryAgents_Evaluations_Step01_RedTeaming.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Agent_Step02.1_MultiturnConversation.csproj similarity index 70% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/FoundryAgents_Evaluations_Step01_RedTeaming.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Agent_Step02.1_MultiturnConversation.csproj index d77c0bb0d3..5e73fd236a 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/FoundryAgents_Evaluations_Step01_RedTeaming.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Agent_Step02.1_MultiturnConversation.csproj @@ -9,8 +9,7 @@ - - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Program.cs new file mode 100644 index 0000000000..15b880102f --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Program.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create a multi-turn conversation agent using sessions. +// Context is preserved across multiple runs via response ID chaining in the session. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) + .AsAIAgent(deploymentName, instructions: "You are good at telling jokes.", name: "JokerAgent"); + +// Create a session to maintain context across multiple runs. +AgentSession session = await agent.CreateSessionAsync(); + +// First turn +Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", session)); + +// Second turn — the agent remembers the first turn via the session. +Console.WriteLine(await agent.RunAsync("Now add some emojis to the joke and tell it in the voice of a pirate's parrot.", session)); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/README.md new file mode 100644 index 0000000000..a895981952 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/README.md @@ -0,0 +1,36 @@ +# Multi-turn Conversation + +This sample demonstrates how to implement multi-turn conversations where context is preserved across multiple agent runs using sessions and response ID chaining. + +## What this sample demonstrates + +- Creating an agent with instructions +- Using sessions to maintain conversation context across multiple runs +- Response ID chaining for multi-turn conversations +- No server-side conversation creation required + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (for Azure credential authentication) + +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +Navigate to the AgentsWithFoundry sample directory and run: + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step02.1_MultiturnConversation +``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Agent_Step02.2_MultiturnWithServerConversations.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Agent_Step02.2_MultiturnWithServerConversations.csproj new file mode 100644 index 0000000000..7367c1d2f8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Agent_Step02.2_MultiturnWithServerConversations.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs new file mode 100644 index 0000000000..7a66555c18 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use server-side conversations with a FoundryAgent. +// Server-side conversations persist on the Foundry service and are visible in the Foundry Project UI. +// Use this when you need conversation history to be stored and accessible server-side. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +FoundryAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) + .AsAIAgent(deploymentName, instructions: "You are good at telling jokes.", name: "JokerAgent"); + +// CreateConversationSessionAsync creates a server-side ProjectConversation +// that persists on the Foundry service and is visible in the Foundry Project UI. +AgentSession session = await agent.CreateConversationSessionAsync(); + +Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", session)); +Console.WriteLine(await agent.RunAsync("Now add some emojis to the joke and tell it in the voice of a pirate's parrot.", session)); + +// Streaming with server-side conversation context. +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("Tell me another joke, but about a ninja this time.", session)) +{ + Console.Write(update); +} + +Console.WriteLine(); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/README.md new file mode 100644 index 0000000000..9d35133b39 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/README.md @@ -0,0 +1,36 @@ +# Multi-turn Conversation with Server-Side Conversations + +This sample demonstrates how to use server-side conversations with a `FoundryAgent`. Server-side conversations persist on the Foundry service and are visible in the Foundry Project UI, making them ideal when you need conversation history to be stored and accessible server-side. + +## What this sample demonstrates + +- Creating a `FoundryAgent` with instructions +- Using `CreateConversationSessionAsync` to create a server-side `ProjectConversation` +- Multi-turn conversations with both text and streaming output +- Server-side conversation persistence visible in the Foundry Project UI + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (for Azure credential authentication) + +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +Navigate to the AgentsWithFoundry sample directory and run: + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step02.2_MultiturnWithServerConversations +``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj new file mode 100644 index 0000000000..7367c1d2f8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Program.cs new file mode 100644 index 0000000000..7935835a24 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Program.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use function tools. + +using System.ComponentModel; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +[Description("Get the weather for a given location.")] +static string GetWeather([Description("The location to get the weather for.")] string location) + => $"The weather in {location} is cloudy with a high of 15°C."; + +// Define the function tool. +AITool tool = AIFunctionFactory.Create(GetWeather); + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +// Create a AIAgent with function tools. +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are a helpful assistant that can get weather information.", + name: "WeatherAssistant", + tools: [tool]); + +// Non-streaming agent interaction with function tools. +AgentSession session = await agent.CreateSessionAsync(); +Console.WriteLine(await agent.RunAsync("What is the weather like in Amsterdam?", session)); + +// Streaming agent interaction with function tools. +session = await agent.CreateSessionAsync(); +await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("What is the weather like in Amsterdam?", session)) +{ + Console.Write(update); +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/README.md new file mode 100644 index 0000000000..9aa2a4ee69 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/README.md @@ -0,0 +1,37 @@ +# Using Function Tools with the Responses API + +This sample demonstrates how to use function tools with the `ChatClientAgent`, allowing the agent to call custom functions to retrieve information. + +## What this sample demonstrates + +- Creating function tools using `AIFunctionFactory` +- Passing function tools to a `ChatClientAgent` +- Running agents with function tools (text output) +- Running agents with function tools (streaming output) +- No server-side agent creation or cleanup required + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (for Azure credential authentication) + +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +Navigate to the AgentsWithFoundry sample directory and run: + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step03_UsingFunctionTools +``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj new file mode 100644 index 0000000000..7367c1d2f8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs similarity index 69% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs index 08051a500e..9a85dba83b 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs @@ -1,9 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. // This sample demonstrates how to use an agent with function tools that require a human in the loop for approvals. -// It shows both non-streaming and streaming agent interactions using weather-related tools. -// If the agent is hosted in a service, with a remote user, combine this sample with the Persisted Conversations sample to persist the chat history -// while the agent is waiting for user input. using System.ComponentModel; using Azure.AI.Projects; @@ -11,18 +8,13 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -// Create a sample function tool that the agent can use. [Description("Get the weather for a given location.")] static string GetWeather([Description("The location to get the weather for.")] string location) => $"The weather in {location} is cloudy with a high of 15°C."; -const string AssistantInstructions = "You are a helpful assistant that can get weather information."; -const string AssistantName = "WeatherAssistant"; +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. @@ -30,16 +22,16 @@ AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredent ApprovalRequiredAIFunction approvalTool = new(AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather))); -// Create AIAgent directly -AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: AssistantName, model: deploymentName, instructions: AssistantInstructions, tools: [approvalTool]); +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are a helpful assistant that can get weather information.", + name: "WeatherAssistant", + tools: [approvalTool]); // Call the agent with approval-required function tools. -// The agent will request approval before invoking the function. AgentSession session = await agent.CreateSessionAsync(); AgentResponse response = await agent.RunAsync("What is the weather like in Amsterdam?", session); // Check if there are any approval requests. -// For simplicity, we are assuming here that only function approvals are pending. List approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); while (approvalRequests.Count > 0) @@ -53,13 +45,8 @@ while (approvalRequests.Count > 0) return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved)]); }); - // Pass the user input responses back to the agent for further processing. response = await agent.RunAsync(userInputMessages, session); - approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); } Console.WriteLine($"\nAgent: {response}"); - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/README.md new file mode 100644 index 0000000000..430c57548d --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/README.md @@ -0,0 +1,30 @@ +# Using Function Tools with Approvals via the Responses API + +This sample demonstrates how to use function tools that require human-in-the-loop approval before execution. + +## What this sample demonstrates + +- Creating function tools that require approval using `ApprovalRequiredAIFunction` +- Handling approval requests from the agent +- Passing approval responses back to the agent +- No server-side agent creation or cleanup required + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step04_UsingFunctionToolsWithApprovals +``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj new file mode 100644 index 0000000000..7367c1d2f8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Program.cs similarity index 53% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Program.cs index 3c02a4cec2..28cd4cf6bc 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Program.cs @@ -15,29 +15,23 @@ using SampleApp; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -const string AssistantInstructions = "You are a helpful assistant that extracts structured information about people."; -const string AssistantName = "StructuredOutputAssistant"; - -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -// Create ChatClientAgent directly -ChatClientAgent agent = await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - new ChatClientAgentOptions() +AIAgent agent = aiProjectClient.AsAIAgent(new ChatClientAgentOptions +{ + Name = "StructuredOutputAssistant", + ChatOptions = new() { - Name = AssistantName, - ChatOptions = new() - { - Instructions = AssistantInstructions, - ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema() - } - }); + ModelId = deploymentName, + Instructions = "You are a helpful assistant that extracts structured information about people.", + ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema() + } +}); -// Set PersonInfo as the type parameter of RunAsync method to specify the expected structured output from the agent and invoke the agent with some unstructured input. +// Set PersonInfo as the type parameter of RunAsync method to specify the expected structured output. AgentResponse response = await agent.RunAsync("Please provide information about John Smith, who is a 35-year-old software engineer."); // Access the structured output via the Result property of the agent response. @@ -46,39 +40,21 @@ Console.WriteLine($"Name: {response.Result.Name}"); Console.WriteLine($"Age: {response.Result.Age}"); Console.WriteLine($"Occupation: {response.Result.Occupation}"); -// Create the ChatClientAgent with the specified name, instructions, and expected structured output the agent should produce. -ChatClientAgent agentWithPersonInfo = await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - new ChatClientAgentOptions() - { - Name = AssistantName, - ChatOptions = new() - { - Instructions = AssistantInstructions, - ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema() - } - }); +// Invoke the agent with streaming support, then deserialize the assembled response. +IAsyncEnumerable updates = agent.RunStreamingAsync("Please provide information about Jane Doe, who is a 28-year-old data scientist."); -// Invoke the agent with some unstructured input while streaming, to extract the structured information from. -IAsyncEnumerable updates = agentWithPersonInfo.RunStreamingAsync("Please provide information about John Smith, who is a 35-year-old software engineer."); - -// Assemble all the parts of the streamed output, since we can only deserialize once we have the full json, -// then deserialize the response into the PersonInfo class. PersonInfo personInfo = JsonSerializer.Deserialize((await updates.ToAgentResponseAsync()).Text, JsonSerializerOptions.Web) ?? throw new InvalidOperationException("Failed to deserialize the streamed response into PersonInfo."); -Console.WriteLine("Assistant Output:"); +Console.WriteLine("\nStreaming Assistant Output:"); Console.WriteLine($"Name: {personInfo.Name}"); Console.WriteLine($"Age: {personInfo.Age}"); Console.WriteLine($"Occupation: {personInfo.Occupation}"); -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); - namespace SampleApp { /// - /// Represents information about a person, including their name, age, and occupation, matched to the JSON schema used in the agent. + /// Represents information about a person. /// [Description("Information about a person including their name, age, and occupation")] public class PersonInfo diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/README.md new file mode 100644 index 0000000000..f5421b3f64 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/README.md @@ -0,0 +1,29 @@ +# Structured Output with the Responses API + +This sample demonstrates how to configure an agent to produce structured output using JSON schema. + +## What this sample demonstrates + +- Using `RunAsync()` to get typed structured output from the agent +- Deserializing streamed responses into structured types +- No server-side agent creation or cleanup required + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step05_StructuredOutput +``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj new file mode 100644 index 0000000000..7367c1d2f8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Program.cs similarity index 78% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Program.cs index d8a5a7cd35..c9774cb1bf 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Program.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to create and use a simple AI agent with a conversation that can be persisted to disk. +// This sample shows how to persist and resume conversations. using System.Text.Json; using Azure.AI.Projects; @@ -10,16 +10,14 @@ using Microsoft.Agents.AI; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -const string JokerInstructions = "You are good at telling jokes."; -const string JokerName = "JokerAgent"; - -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: JokerInstructions); +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are good at telling jokes.", + name: "JokerAgent"); // Start a new session for the agent conversation. AgentSession session = await agent.CreateSessionAsync(); @@ -42,6 +40,3 @@ AgentSession resumedSession = await agent.DeserializeSessionAsync(reloadedSerial // Run the agent again with the resumed session. Console.WriteLine(await agent.RunAsync("Now tell the same joke in the voice of a pirate, and add some emojis to the joke.", resumedSession)); - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/README.md new file mode 100644 index 0000000000..a8cfda07ca --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/README.md @@ -0,0 +1,30 @@ +# Persisted Conversations with the Responses API + +This sample demonstrates how to persist and resume agent conversations using session serialization. + +## What this sample demonstrates + +- Serializing agent sessions to JSON for persistence +- Saving and loading sessions from disk +- Resuming conversations with preserved context +- No server-side agent creation or cleanup required + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step06_PersistedConversations +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/FoundryAgents_Step07_Observability.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Agent_Step07_Observability.csproj similarity index 85% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/FoundryAgents_Step07_Observability.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Agent_Step07_Observability.csproj index 5ceeabb204..1189939bc0 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/FoundryAgents_Step07_Observability.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Agent_Step07_Observability.csproj @@ -9,8 +9,6 @@ - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Program.cs similarity index 71% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Program.cs index 257e24859f..68bfb91af0 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Program.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to create and use a simple AI agent with Azure Foundry Agents as the backend that logs telemetry using OpenTelemetry. +// This sample shows how to add OpenTelemetry observability to an agent. using Azure.AI.Projects; using Azure.Identity; @@ -9,15 +9,11 @@ using Microsoft.Agents.AI; using OpenTelemetry; using OpenTelemetry.Trace; +string? applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -string? applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); -const string JokerInstructions = "You are good at telling jokes."; -const string JokerName = "JokerAgent"; - -// Create TracerProvider with console exporter -// This will output the telemetry data to the console. +// Create TracerProvider with console exporter. string sourceName = Guid.NewGuid().ToString("N"); TracerProviderBuilder tracerProviderBuilder = Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) @@ -28,14 +24,16 @@ if (!string.IsNullOrWhiteSpace(applicationInsightsConnectionString)) } using var tracerProvider = tracerProviderBuilder.Build(); -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -// Define the agent you want to create. (Prompt Agent in this case) -AIAgent agent = (await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: JokerInstructions)) +AIAgent agent = aiProjectClient + .AsAIAgent( + deploymentName, + instructions: "You are good at telling jokes.", + name: "JokerAgent") .AsBuilder() .UseOpenTelemetry(sourceName: sourceName) .Build(); @@ -48,8 +46,7 @@ Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", session session = await agent.CreateSessionAsync(); await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("Tell me a joke about a pirate.", session)) { - Console.WriteLine(update); + Console.Write(update); } -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); +Console.WriteLine(); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/README.md new file mode 100644 index 0000000000..cb3bb729ff --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/README.md @@ -0,0 +1,31 @@ +# Observability with the Responses API + +This sample demonstrates how to add OpenTelemetry observability to an agent using console and Azure Monitor exporters. + +## What this sample demonstrates + +- Configuring OpenTelemetry tracing with console exporter +- Optional Azure Application Insights integration +- Using `.AsBuilder().UseOpenTelemetry()` to add telemetry to the agent +- No server-side agent creation or cleanup required + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:APPLICATIONINSIGHTS_CONNECTION_STRING="..." # Optional +``` + +## Run the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step07_Observability +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/FoundryAgents_Step08_DependencyInjection.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Agent_Step08_DependencyInjection.csproj similarity index 83% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/FoundryAgents_Step08_DependencyInjection.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Agent_Step08_DependencyInjection.csproj index f1812befeb..72af634725 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/FoundryAgents_Step08_DependencyInjection.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Agent_Step08_DependencyInjection.csproj @@ -11,8 +11,6 @@ - - diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Program.cs new file mode 100644 index 0000000000..52a7d73132 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Program.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use dependency injection to register a AIAgent and use it from a hosted service. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SampleApp; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are good at telling jokes.", + name: "JokerAgent"); + +// Create a host builder that we will register services with and then run. +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + +// Add the AI agent to the service collection. +builder.Services.AddSingleton(agent); + +// Add a sample service that will use the agent to respond to user input. +builder.Services.AddHostedService(); + +// Build and run the host. +using IHost host = builder.Build(); +await host.RunAsync().ConfigureAwait(false); + +namespace SampleApp +{ + /// + /// A sample service that uses an AI agent to respond to user input. + /// + internal sealed class SampleService(AIAgent agent, IHostApplicationLifetime appLifetime) : IHostedService + { + private AgentSession? _session; + + public async Task StartAsync(CancellationToken cancellationToken) + { + this._session = await agent.CreateSessionAsync(cancellationToken); + _ = this.RunAsync(appLifetime.ApplicationStopping); + } + + public async Task RunAsync(CancellationToken cancellationToken) + { + await Task.Delay(100, cancellationToken); + + while (!cancellationToken.IsCancellationRequested) + { + Console.WriteLine("\nAgent: Ask me to tell you a joke about a specific topic. To exit just press Ctrl+C or enter without any input.\n"); + Console.Write("> "); + string? input = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(input)) + { + appLifetime.StopApplication(); + break; + } + + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(input, this._session, cancellationToken: cancellationToken)) + { + Console.Write(update); + } + + Console.WriteLine(); + } + } + + public Task StopAsync(CancellationToken cancellationToken) + { + Console.WriteLine("\nShutting down..."); + return Task.CompletedTask; + } + } +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/README.md new file mode 100644 index 0000000000..c9ad936fd6 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/README.md @@ -0,0 +1,30 @@ +# Dependency Injection with the Responses API + +This sample demonstrates how to register a `ChatClientAgent` in a dependency injection container and use it from a hosted service. + +## What this sample demonstrates + +- Registering `ChatClientAgent` as an `AIAgent` in the service collection +- Using the agent from a `IHostedService` with an interactive chat loop +- Streaming responses in a hosted service context +- No server-side agent creation or cleanup required + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step08_DependencyInjection +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/FoundryAgents_Step23_LocalMCP.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Agent_Step09_UsingMcpClientAsTools.csproj similarity index 84% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/FoundryAgents_Step23_LocalMCP.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Agent_Step09_UsingMcpClientAsTools.csproj index 1e3e6f57e3..96cdf948fe 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/FoundryAgents_Step23_LocalMCP.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Agent_Step09_UsingMcpClientAsTools.csproj @@ -6,16 +6,15 @@ enable enable - $(NoWarn);CA1812 - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Program.cs new file mode 100644 index 0000000000..87f80297af --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Program.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use MCP client tools with an agent. +// It connects to the Microsoft Learn MCP server via HTTP and uses its tools. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// Connect to the Microsoft Learn MCP server via HTTP (Streamable HTTP transport). +Console.WriteLine("Connecting to MCP server at https://learn.microsoft.com/api/mcp ..."); + +await using McpClient mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new() +{ + Endpoint = new Uri("https://learn.microsoft.com/api/mcp"), + Name = "Microsoft Learn MCP", +})); + +// Retrieve the list of tools available on the MCP server. +IList mcpTools = await mcpClient.ListToolsAsync(); +Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}"); + +List agentTools = [.. mcpTools.Cast()]; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are a helpful assistant that can help with Microsoft documentation questions. Use the Microsoft Learn MCP tool to search for documentation.", + name: "DocsAgent", + tools: agentTools); + +Console.WriteLine($"Agent '{agent.Name}' created. Asking a question...\n"); + +const string Prompt = "How does one create an Azure storage account using az cli?"; +Console.WriteLine($"User: {Prompt}\n"); +Console.WriteLine($"Agent: {await agent.RunAsync(Prompt)}"); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/README.md new file mode 100644 index 0000000000..72437e0802 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/README.md @@ -0,0 +1,29 @@ +# Using MCP Client as Tools with the Responses API + +This sample shows how to use MCP (Model Context Protocol) client tools with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Connecting to an MCP server via HTTP client transport +- Retrieving MCP tools and passing them to a `ChatClientAgent` +- Using MCP tools for agent interactions without server-side agent creation + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) +- Node.js installed (for npx/MCP server) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Agent_Step10_UsingImages.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Agent_Step10_UsingImages.csproj new file mode 100644 index 0000000000..6064cf9334 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Agent_Step10_UsingImages.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + PreserveNewest + + + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Program.cs similarity index 61% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Program.cs index d810c8046a..2fdc150be9 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Program.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to use Image Multi-Modality with an AI agent. +// This sample shows how to use image multi-modality with an agent. using Azure.AI.Projects; using Azure.Identity; @@ -10,29 +10,25 @@ using Microsoft.Extensions.AI; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; -const string VisionInstructions = "You are a helpful agent that can analyze images"; -const string VisionName = "VisionAgent"; - -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -// Define the agent you want to create. (Prompt Agent in this case) -AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: VisionName, model: deploymentName, instructions: VisionInstructions); +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are a helpful agent that can analyze images.", + name: "VisionAgent"); ChatMessage message = new(ChatRole.User, [ new TextContent("What do you see in this image?"), - await DataContent.LoadFromAsync("Assets/walkway.jpg"), + await DataContent.LoadFromAsync("assets/walkway.jpg"), ]); AgentSession session = await agent.CreateSessionAsync(); await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(message, session)) { - Console.WriteLine(update); + Console.Write(update); } -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); +Console.WriteLine(); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/README.md new file mode 100644 index 0000000000..370bf896cf --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/README.md @@ -0,0 +1,30 @@ +# Using Images with the Responses API + +This sample demonstrates how to use image multi-modality with an agent. + +## What this sample demonstrates + +- Loading images using `DataContent.LoadFromAsync` +- Sending images alongside text to the agent +- Streaming the agent's image analysis response +- No server-side agent creation or cleanup required + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and a vision-capable model deployment (e.g., `gpt-4o`) +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o" +``` + +## Run the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step10_UsingImages +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Assets/walkway.jpg b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/assets/walkway.jpg similarity index 100% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Assets/walkway.jpg rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/assets/walkway.jpg diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Agent_Step11_AsFunctionTool.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Agent_Step11_AsFunctionTool.csproj new file mode 100644 index 0000000000..7367c1d2f8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Agent_Step11_AsFunctionTool.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Program.cs similarity index 57% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Program.cs index 585725322e..3715ab194f 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Program.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to create and use an Azure Foundry Agents AI agent as a function tool. +// This sample shows how to use one agent as a function tool for another agent. using System.ComponentModel; using Azure.AI.Projects; @@ -8,43 +8,29 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -const string WeatherInstructions = "You answer questions about the weather."; -const string WeatherName = "WeatherAgent"; -const string MainInstructions = "You are a helpful assistant who responds in French."; -const string MainName = "MainAgent"; - [Description("Get the weather for a given location.")] static string GetWeather([Description("The location to get the weather for.")] string location) => $"The weather in {location} is cloudy with a high of 15°C."; -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -// Create the weather agent with function tools. AITool weatherTool = AIFunctionFactory.Create(GetWeather); -AIAgent weatherAgent = await aiProjectClient.CreateAIAgentAsync( - name: WeatherName, - model: deploymentName, - instructions: WeatherInstructions, +AIAgent weatherAgent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You answer questions about the weather.", + name: "WeatherAgent", tools: [weatherTool]); -// Create the main agent, and provide the weather agent as a function tool. -AIAgent agent = await aiProjectClient.CreateAIAgentAsync( - name: MainName, - model: deploymentName, - instructions: MainInstructions, +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are a helpful assistant who responds in French.", + name: "MainAgent", tools: [weatherAgent.AsAIFunction()]); // Invoke the agent and output the text result. AgentSession session = await agent.CreateSessionAsync(); Console.WriteLine(await agent.RunAsync("What is the weather like in Amsterdam?", session)); - -// Cleanup by agent name removes the agent versions created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); -await aiProjectClient.Agents.DeleteAgentAsync(weatherAgent.Name); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/README.md new file mode 100644 index 0000000000..7d361305b9 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/README.md @@ -0,0 +1,30 @@ +# Agent as a Function Tool with the Responses API + +This sample demonstrates how to use one agent as a function tool for another agent. + +## What this sample demonstrates + +- Creating a specialized agent (weather) with function tools +- Exposing an agent as a function tool using `.AsAIFunction()` +- Composing agents where one agent delegates to another +- No server-side agent creation or cleanup required + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step11_AsFunctionTool +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/FoundryAgents_Step12_Middleware.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Agent_Step12_Middleware.csproj similarity index 81% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/FoundryAgents_Step12_Middleware.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Agent_Step12_Middleware.csproj index 9f29a8d7e6..b30baccd54 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/FoundryAgents_Step12_Middleware.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Agent_Step12_Middleware.csproj @@ -3,15 +3,13 @@ Exe net10.0 - + enable enable - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Program.cs similarity index 70% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Program.cs index 824e1507b3..e37bf89639 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Program.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows multiple middleware layers working together with Azure Foundry Agents: +// This sample shows multiple middleware layers working together with a ChatClientAgent: // agent run (PII filtering and guardrails), // function invocation (logging and result overrides), and human-in-the-loop // approval workflows for sensitive function calls. @@ -12,19 +12,6 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -// Get Azure AI Foundry configuration from environment variables -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = System.Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; - -const string AssistantInstructions = "You are an AI assistant that helps people find information."; -const string AssistantName = "InformationAssistant"; - -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - [Description("Get the weather for a given location.")] static string GetWeather([Description("The location to get the weather for.")] string location) => $"The weather in {location} is cloudy with a high of 15°C."; @@ -33,14 +20,20 @@ static string GetWeather([Description("The location to get the weather for.")] s static string GetDateTime() => DateTimeOffset.Now.ToString(); +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + AITool dateTimeTool = AIFunctionFactory.Create(GetDateTime, name: nameof(GetDateTime)); AITool getWeatherTool = AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather)); -// Define the agent you want to create. (Prompt Agent in this case) -AIAgent originalAgent = await aiProjectClient.CreateAIAgentAsync( - name: AssistantName, - model: deploymentName, - instructions: AssistantInstructions, +AIAgent originalAgent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are an AI assistant that helps people find information.", + name: "InformationAssistant", tools: [getWeatherTool, dateTimeTool]); // Adding middleware to the agent level @@ -63,24 +56,17 @@ AgentResponse piiResponse = await middlewareEnabledAgent.RunAsync("My name is Jo Console.WriteLine($"Pii filtered response: {piiResponse}"); Console.WriteLine("\n\n=== Example 3: Agent function middleware ==="); - -// Agent function middleware support is limited to agents that wraps a upstream ChatClientAgent or derived from it. - AgentResponse functionCallResponse = await middlewareEnabledAgent.RunAsync("What's the current time and the weather in Seattle?", session); Console.WriteLine($"Function calling response: {functionCallResponse}"); // Special per-request middleware agent. Console.WriteLine("\n\n=== Example 4: Middleware with human in the loop function approval ==="); -AIAgent humanInTheLoopAgent = await aiProjectClient.CreateAIAgentAsync( +AIAgent humanInTheLoopAgent = aiProjectClient.AsAIAgent(deploymentName, + instructions: "You are a Human in the loop testing AI assistant that helps people find information.", name: "HumanInTheLoopAgent", - model: deploymentName, - instructions: "You are an Human in the loop testing AI assistant that helps people find information.", - - // Adding a function with approval required tools: [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather)))]); -// Using the ConsolePromptingApprovalMiddleware for a specific request to handle user approval during function calls. AgentResponse response = await humanInTheLoopAgent .AsBuilder() .Use(ConsolePromptingApprovalMiddleware, null) @@ -108,7 +94,6 @@ async ValueTask FunctionCallOverrideWeather(AIAgent agent, FunctionInvo if (context.Function.Name == nameof(GetWeather)) { - // Override the result of the GetWeather function result = "The weather is sunny with a high of 25°C."; } Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware 2 Post-Invoke"); @@ -118,18 +103,16 @@ async ValueTask FunctionCallOverrideWeather(AIAgent agent, FunctionInvo // This middleware redacts PII information from input and output messages. async Task PIIMiddleware(IEnumerable messages, AgentSession? session, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) { - // Redact PII information from input messages var filteredMessages = FilterMessages(messages); Console.WriteLine("Pii Middleware - Filtered Messages Pre-Run"); - var response = await innerAgent.RunAsync(filteredMessages, session, options, cancellationToken).ConfigureAwait(false); + var agentResponse = await innerAgent.RunAsync(filteredMessages, session, options, cancellationToken).ConfigureAwait(false); - // Redact PII information from output messages - response.Messages = FilterMessages(response.Messages); + agentResponse.Messages = FilterMessages(agentResponse.Messages); Console.WriteLine("Pii Middleware - Filtered Messages Post-Run"); - return response; + return agentResponse; static IList FilterMessages(IEnumerable messages) { @@ -138,11 +121,10 @@ async Task PIIMiddleware(IEnumerable messages, Agent static string FilterPii(string content) { - // Regex patterns for PII detection (simplified for demonstration) Regex[] piiPatterns = [ - new(@"\b\d{3}-\d{3}-\d{4}\b", RegexOptions.Compiled), // Phone number (e.g., 123-456-7890) - new(@"\b[\w\.-]+@[\w\.-]+\.\w+\b", RegexOptions.Compiled), // Email address - new(@"\b[A-Z][a-z]+\s[A-Z][a-z]+\b", RegexOptions.Compiled) // Full name (e.g., John Doe) + MyRegex(), + EmailRegex(), + FullNameRegex() ]; foreach (var pattern in piiPatterns) @@ -157,20 +139,17 @@ async Task PIIMiddleware(IEnumerable messages, Agent // This middleware enforces guardrails by redacting certain keywords from input and output messages. async Task GuardrailMiddleware(IEnumerable messages, AgentSession? session, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) { - // Redact keywords from input messages var filteredMessages = FilterMessages(messages); Console.WriteLine("Guardrail Middleware - Filtered messages Pre-Run"); - // Proceed with the agent run - var response = await innerAgent.RunAsync(filteredMessages, session, options, cancellationToken); + var agentResponse = await innerAgent.RunAsync(filteredMessages, session, options, cancellationToken); - // Redact keywords from output messages - response.Messages = FilterMessages(response.Messages); + agentResponse.Messages = FilterMessages(agentResponse.Messages); Console.WriteLine("Guardrail Middleware - Filtered messages Post-Run"); - return response; + return agentResponse; List FilterMessages(IEnumerable messages) { @@ -194,16 +173,13 @@ async Task GuardrailMiddleware(IEnumerable messages, // This middleware handles Human in the loop console interaction for any user approval required during function calling. async Task ConsolePromptingApprovalMiddleware(IEnumerable messages, AgentSession? session, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) { - AgentResponse response = await innerAgent.RunAsync(messages, session, options, cancellationToken); + AgentResponse agentResponse = await innerAgent.RunAsync(messages, session, options, cancellationToken); - // For simplicity, we are assuming here that only function approvals are pending. - List approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + List approvalRequests = agentResponse.Messages.SelectMany(m => m.Contents).OfType().ToList(); while (approvalRequests.Count > 0) { - // Ask the user to approve each function call request. - // Pass the user input responses back to the agent for further processing. - response.Messages = approvalRequests + agentResponse.Messages = approvalRequests .ConvertAll(functionApprovalRequest => { Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {((FunctionCallContent)functionApprovalRequest.ToolCall).Name}"); @@ -211,13 +187,22 @@ async Task ConsolePromptingApprovalMiddleware(IEnumerable m.Contents).OfType().ToList(); + approvalRequests = agentResponse.Messages.SelectMany(m => m.Contents).OfType().ToList(); } - return response; + return agentResponse; } -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(middlewareEnabledAgent.Name); +internal partial class Program +{ + [GeneratedRegex(@"\b\d{3}-\d{3}-\d{4}\b", RegexOptions.Compiled)] + private static partial Regex MyRegex(); + + [GeneratedRegex(@"\b[\w\.-]+@[\w\.-]+\.\w+\b", RegexOptions.Compiled)] + private static partial Regex EmailRegex(); + + [GeneratedRegex(@"\b[A-Z][a-z]+\s[A-Z][a-z]+\b", RegexOptions.Compiled)] + private static partial Regex FullNameRegex(); +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/README.md new file mode 100644 index 0000000000..26b12a22b8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/README.md @@ -0,0 +1,31 @@ +# Middleware with the Responses API + +This sample demonstrates multiple middleware layers working together: PII filtering, guardrails, function invocation logging, and human-in-the-loop approval. + +## What this sample demonstrates + +- Agent-level run middleware (PII filtering, guardrail enforcement) +- Function-level middleware (logging, result overrides) +- Human-in-the-loop approval workflows for sensitive function calls +- Using `.AsBuilder().Use()` to compose middleware +- No server-side agent creation or cleanup required + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\Agent_Step12_Middleware +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/FoundryAgents_Step16_FileSearch.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Agent_Step13_Plugins.csproj similarity index 91% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/FoundryAgents_Step16_FileSearch.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Agent_Step13_Plugins.csproj index 4a34560946..1f5e37c1a3 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/FoundryAgents_Step16_FileSearch.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Agent_Step13_Plugins.csproj @@ -10,13 +10,12 @@ - - + - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Program.cs new file mode 100644 index 0000000000..3af63090c5 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Program.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use plugins with an AI agent. Plugin classes can +// depend on other services that need to be injected. In this sample, the +// AgentPlugin class uses the WeatherProvider and CurrentTimeProvider classes +// to get weather and current time information. Both services are registered +// in the service collection and injected into the plugin. +// Plugin classes may have many methods, but only some are intended to be used +// as AI functions. The AsAITools method of the plugin class shows how to specify +// which methods should be exposed to the AI agent. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using SampleApp; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +const string AssistantInstructions = "You are a helpful assistant that helps people find information."; +const string AssistantName = "PluginAssistant"; + +// Create a service collection to hold the agent plugin and its dependencies. +ServiceCollection services = new(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); // The plugin depends on WeatherProvider and CurrentTimeProvider registered above. + +IServiceProvider serviceProvider = services.BuildServiceProvider(); + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +// Create a ChatClientAgent with the options-based constructor to pass services. +AIAgent agent = aiProjectClient.AsAIAgent(new ChatClientAgentOptions +{ + Name = AssistantName, + ChatOptions = new() { ModelId = deploymentName, Instructions = AssistantInstructions, Tools = serviceProvider.GetRequiredService().AsAITools().ToList() } +}, + services: serviceProvider); + +// Invoke the agent and output the text result. +AgentSession session = await agent.CreateSessionAsync(); +Console.WriteLine(await agent.RunAsync("Tell me current time and weather in Seattle.", session)); + +namespace SampleApp +{ + /// + /// The agent plugin that provides weather and current time information. + /// + internal sealed class AgentPlugin + { + private readonly WeatherProvider _weatherProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The weather provider to get weather information. + public AgentPlugin(WeatherProvider weatherProvider) + { + this._weatherProvider = weatherProvider; + } + + /// + /// Gets the weather information for the specified location. + /// + /// + /// This method demonstrates how to use the dependency that was injected into the plugin class. + /// + /// The location to get the weather for. + /// The weather information for the specified location. + public string GetWeather(string location) + { + return this._weatherProvider.GetWeather(location); + } + + /// + /// Gets the current date and time for the specified location. + /// + /// + /// This method demonstrates how to resolve a dependency using the service provider passed to the method. + /// + /// The service provider to resolve the . + /// The location to get the current time for. + /// The current date and time as a . + public DateTimeOffset GetCurrentTime(IServiceProvider sp, string location) + { + CurrentTimeProvider currentTimeProvider = sp.GetRequiredService(); + return currentTimeProvider.GetCurrentTime(location); + } + + /// + /// Returns the functions provided by this plugin. + /// + /// + /// In real world scenarios, a class may have many methods and only a subset of them may be intended to be exposed as AI functions. + /// This method demonstrates how to explicitly specify which methods should be exposed to the AI agent. + /// + /// The functions provided by this plugin. + public IEnumerable AsAITools() + { + yield return AIFunctionFactory.Create(this.GetWeather); + yield return AIFunctionFactory.Create(this.GetCurrentTime); + } + } + + internal sealed class WeatherProvider + { + private readonly string _weatherSummary = "cloudy with a high of 15°C"; + + /// + /// The weather provider that returns weather information. + /// + /// + /// Gets the weather information for the specified location. + /// + /// + /// The weather information is hardcoded for demonstration purposes. + /// In a real application, this could call a weather API to get actual weather data. + /// + /// The location to get the weather for. + /// The weather information for the specified location. + public string GetWeather(string location) + { + return $"The weather in {location} is {this._weatherSummary}."; + } + } + + internal sealed class CurrentTimeProvider + { + private readonly TimeProvider _timeProvider = TimeProvider.System; + + /// + /// Provides the current date and time. + /// + /// + /// This class returns the current date and time using the system's clock. + /// + /// + /// Gets the current date and time. + /// + /// The location to get the current time for (not used in this implementation). + /// The current date and time as a . + public DateTimeOffset GetCurrentTime(string location) + { + return this._timeProvider.GetLocalNow(); + } + } +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/README.md new file mode 100644 index 0000000000..8cc2f59116 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/README.md @@ -0,0 +1,29 @@ +# Using Plugins with the Responses API + +This sample shows how to use plugins with a `ChatClientAgent` using the Responses API directly, with dependency injection for plugin services. + +## What this sample demonstrates + +- Creating plugin classes with injected dependencies +- Registering services and building a service provider +- Passing `services` to the `ChatClientAgent` via the options-based constructor +- Using `AIFunctionFactory` to expose plugin methods as AI tools + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Agent_Step14_CodeInterpreter.csproj similarity index 89% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Agent_Step14_CodeInterpreter.csproj index daf7e24494..e11688b6ba 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Agent_Step14_CodeInterpreter.csproj @@ -9,7 +9,6 @@ - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Program.cs similarity index 59% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Program.cs index 5a27daed12..8d7f598a4a 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Program.cs @@ -1,61 +1,31 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to use Code Interpreter Tool with AI Agents. +// This sample shows how to use Code Interpreter Tool with AIProjectClient.AsAIAgent(...). using System.Text; using Azure.AI.Projects; -using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI.Assistants; -using OpenAI.Responses; + +const string AgentInstructions = "You are a personal math tutor. When asked a math question, write and run code using the python tool to answer the question."; +const string AgentName = "CoderAgent-RAPI"; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -const string AgentInstructions = "You are a personal math tutor. When asked a math question, write and run code using the python tool to answer the question."; -const string AgentNameMEAI = "CoderAgent-MEAI"; -const string AgentNameNative = "CoderAgent-NATIVE"; - -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Option 1 - Using HostedCodeInterpreterTool + AgentOptions (MEAI + AgentFramework) -// Create the server side agent version -AIAgent agentOption1 = await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - name: AgentNameMEAI, +AIAgent agent = aiProjectClient.AsAIAgent( + deploymentName, instructions: AgentInstructions, + name: AgentName, tools: [new HostedCodeInterpreterTool() { Inputs = [] }]); -// Option 2 - Using PromptAgentDefinition SDK native type -// Create the server side agent version -AIAgent agentOption2 = await aiProjectClient.CreateAIAgentAsync( - name: AgentNameNative, - creationOptions: new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = { - ResponseTool.CreateCodeInterpreterTool( - new CodeInterpreterToolContainer( - CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration(fileIds: []) - ) - ), - } - }) -); - -// Either invoke option1 or option2 agent, should have same result -// Option 1 -AgentResponse response = await agentOption1.RunAsync("I need to solve the equation sin(x) + x^2 = 42"); - -// Option 2 -// AgentResponse response = await agentOption2.RunAsync("I need to solve the equation sin(x) + x^2 = 42"); +AgentResponse response = await agent.RunAsync("I need to solve the equation sin(x) + x^2 = 42"); // Get the CodeInterpreterToolCallContent CodeInterpreterToolCallContent? toolCallContent = response.Messages.SelectMany(m => m.Contents).OfType().FirstOrDefault(); @@ -87,7 +57,3 @@ foreach (AIAnnotation annotation in response.Messages.SelectMany(m => m.Contents """); } } - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agentOption1.Name); -await aiProjectClient.Agents.DeleteAgentAsync(agentOption2.Name); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/README.md new file mode 100644 index 0000000000..1a8cfc8aae --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/README.md @@ -0,0 +1,28 @@ +# Code Interpreter with the Responses API + +This sample shows how to use the Code Interpreter tool with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Using `HostedCodeInterpreterTool` with `ChatClientAgent` +- Extracting code input and output from agent responses +- Handling code interpreter annotations and file citations + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj similarity index 99% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj index 041c72c43e..f739f56123 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Agent_Step15_ComputerUse.csproj @@ -29,5 +29,5 @@ Always - + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_browser_search.png b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_browser_search.png similarity index 100% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_browser_search.png rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_browser_search.png diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_search_results.png b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_results.png similarity index 100% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_search_results.png rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_results.png diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_search_typed.png b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_typed.png similarity index 100% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Assets/cua_search_typed.png rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Assets/cua_search_typed.png diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/ComputerUseUtil.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/ComputerUseUtil.cs similarity index 100% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/ComputerUseUtil.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/ComputerUseUtil.cs diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs similarity index 61% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs index 7f6382d085..22f03e27b3 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs @@ -1,11 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to use Computer Use Tool with AI Agents. +// This sample shows how to use Computer Use Tool with a ChatClientAgent. using Azure.AI.Projects; -using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; using Microsoft.Extensions.AI; using OpenAI.Responses; @@ -15,59 +15,32 @@ internal sealed class Program { private static async Task Main(string[] args) { - string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); - string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "computer-use-preview"; - - // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. - // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid - // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. - // Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. - AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); const string AgentInstructions = @" You are a computer automation assistant. Be direct and efficient. When you reach the search results page, read and describe the actual search result titles and descriptions you can see. "; - const string AgentNameMEAI = "ComputerAgent-MEAI"; - const string AgentNameNative = "ComputerAgent-NATIVE"; + const string AgentName = "ComputerAgent-RAPI"; - // Option 1 - Using ComputerUseTool + AgentOptions (MEAI + AgentFramework) - // Create AIAgent directly - AIAgent agentOption1 = await aiProjectClient.CreateAIAgentAsync( - name: AgentNameMEAI, - model: deploymentName, + string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); + string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "computer-use-preview"; + + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + + // Create a AIAgent with ComputerUseTool. + AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, instructions: AgentInstructions, + name: AgentName, description: "Computer automation agent with screen interaction capabilities.", tools: [ - ResponseTool.CreateComputerTool(ComputerToolEnvironment.Browser, 1026, 769).AsAITool(), + FoundryAITool.CreateComputerTool(ComputerToolEnvironment.Browser, 1026, 769), ]); - // Option 2 - Using PromptAgentDefinition SDK native type - // Create the server side agent version - AIAgent agentOption2 = await aiProjectClient.CreateAIAgentAsync( - name: AgentNameNative, - creationOptions: new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = { ResponseTool.CreateComputerTool( - environment: new ComputerToolEnvironment("windows"), - displayWidth: 1026, - displayHeight: 769) } - }) - ); - - // Either invoke option1 or option2 agent, should have same result - // Option 1 - await InvokeComputerUseAgentAsync(agentOption1); - - // Option 2 - //await InvokeComputerUseAgentAsync(agentOption2); - - // Cleanup by agent name removes the agent version created. - await aiProjectClient.Agents.DeleteAgentAsync(agentOption1.Name); - await aiProjectClient.Agents.DeleteAgentAsync(agentOption2.Name); + await InvokeComputerUseAgentAsync(agent); } private static async Task InvokeComputerUseAgentAsync(AIAgent agent) @@ -94,10 +67,8 @@ internal sealed class Program // Initial request with screenshot - start with Bing search page Console.WriteLine("Starting computer automation session (initial screenshot: cua_browser_search.png)..."); - // IMPORTANT: Computer-use with the Azure Agents API differs from the vanilla OpenAI Responses API. - // The Azure Agents API rejects requests that include previous_response_id alongside - // computer_call_output items. To work around this, each call uses a fresh session (avoiding - // previous_response_id) and re-sends the full conversation context as input items instead. + // We use PreviousResponseId to chain calls, sending only the new computer_call_output items + // instead of re-sending the full context. AgentSession session = await agent.CreateSessionAsync(); AgentResponse response = await agent.RunAsync(message, session: session, options: runOptions); @@ -161,31 +132,15 @@ internal sealed class Program Console.WriteLine("Sending action result back to agent..."); - // Build the follow-up messages with full conversation context. - // The Azure Agents API rejects previous_response_id when computer_call_output items are - // present, so we must re-send all prior output items (reasoning, computer_call, etc.) - // as input items alongside the computer_call_output to maintain conversation continuity. - List followUpMessages = []; - - // Re-send all response output items as an assistant message so the API has full context - List priorOutputContents = response.Messages - .SelectMany(m => m.Contents) - .ToList(); - followUpMessages.Add(new ChatMessage(ChatRole.Assistant, priorOutputContents)); - - // Add the computer_call_output as a user message + // Send only the computer_call_output — the session carries PreviousResponseId for context continuity. AIContent callOutput = new() { RawRepresentation = new ComputerCallOutputResponseItem( currentCallId, output: ComputerCallOutput.CreateScreenshotOutput(new BinaryData(screenInfo.ImageBytes), "image/png")) }; - followUpMessages.Add(new ChatMessage(ChatRole.User, [callOutput])); - // Create a fresh session so ConversationId does not carry over a previous_response_id. - // Without this, the Azure Agents API returns an error when computer_call_output is present. - session = await agent.CreateSessionAsync(); - response = await agent.RunAsync(followUpMessages, session: session, options: runOptions); + response = await agent.RunAsync([new ChatMessage(ChatRole.User, [callOutput])], session: session, options: runOptions); } } } diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md new file mode 100644 index 0000000000..ecaa18e10f --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md @@ -0,0 +1,29 @@ +# Computer Use with the Responses API + +This sample shows how to use the Computer Use tool with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Using `FoundryAITool.CreateComputerTool()` with `ChatClientAgent` +- Processing computer call actions (click, type, key press) +- Managing the computer use interaction loop with screenshots +- Handling the Azure Agents API workaround for `previous_response_id` with `computer_call_output` + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="computer-use-preview" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Agent_Step16_FileSearch.csproj similarity index 89% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Agent_Step16_FileSearch.csproj index daf7e24494..e11688b6ba 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Agent_Step16_FileSearch.csproj @@ -9,7 +9,6 @@ - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Program.cs similarity index 69% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Program.cs index 5371903a9f..c01a951df4 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Program.cs @@ -1,22 +1,20 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to use File Search Tool with AI Agents. +// This sample shows how to use File Search Tool with a ChatClientAgent. using Azure.AI.Projects; -using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI.Assistants; using OpenAI.Files; -using OpenAI.Responses; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; const string AgentInstructions = "You are a helpful assistant that can search through uploaded files to answer questions."; -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +// We need the AIProjectClient to upload files and create vector stores. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. @@ -52,8 +50,11 @@ var vectorStoreResult = await vectorStoresClient.CreateVectorStoreAsync( string vectorStoreId = vectorStoreResult.Value.Id; Console.WriteLine($"Created vector store, vector store ID: {vectorStoreId}"); -AIAgent agent = await CreateAgentWithMEAI(); -// AIAgent agent = await CreateAgentWithNativeSDK(); +// Create a AIAgent with HostedFileSearchTool. +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: AgentInstructions, + name: "FileSearchAgent-RAPI", + tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreId)] }]); // Run the agent Console.WriteLine("\n--- Running File Search Agent ---"); @@ -73,39 +74,9 @@ foreach (AIAnnotation annotation in response.Messages.SelectMany(m => m.Contents } } -// Cleanup. +// Cleanup file resources. Console.WriteLine("\n--- Cleanup ---"); -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); await vectorStoresClient.DeleteVectorStoreAsync(vectorStoreId); await filesClient.DeleteFileAsync(uploadedFile.Id); File.Delete(searchFilePath); Console.WriteLine("Cleanup completed successfully."); - -// --- Agent Creation Options --- - -#pragma warning disable CS8321 // Local function is declared but never used -// Option 1 - Using HostedFileSearchTool (MEAI + AgentFramework) -async Task CreateAgentWithMEAI() -{ - return await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - name: "FileSearchAgent-MEAI", - instructions: AgentInstructions, - tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreId)] }]); -} - -// Option 2 - Using PromptAgentDefinition with ResponseTool.CreateFileSearchTool (Native SDK) -async Task CreateAgentWithNativeSDK() -{ - return await aiProjectClient.CreateAIAgentAsync( - name: "FileSearchAgent-NATIVE", - creationOptions: new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = { - ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStoreId]) - } - }) - ); -} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/README.md new file mode 100644 index 0000000000..5ce7f11cda --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/README.md @@ -0,0 +1,29 @@ +# File Search with the Responses API + +This sample shows how to use the File Search tool with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Uploading files and creating vector stores via `AIProjectClient` +- Using `HostedFileSearchTool` with `ChatClientAgent` +- Handling file citation annotations in agent responses +- Cleaning up file resources after use + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Agent_Step17_OpenAPITools.csproj similarity index 82% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Agent_Step17_OpenAPITools.csproj index 89b9d8ddc0..4602e9c9e0 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Agent_Step17_OpenAPITools.csproj @@ -6,15 +6,14 @@ enable enable - $(NoWarn);IDE0059 - + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs similarity index 55% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs index ebf66e6c2c..5cc2720dd9 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs @@ -6,16 +6,32 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using OpenAI.Responses; +using Microsoft.Agents.AI.AzureAI; +using Microsoft.Extensions.AI; -// Warning: DefaultAzureCredential is intended for simplicity in development. For production scenarios, consider using a more specific credential. string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; const string AgentInstructions = "You are a helpful assistant that can use the countries API to retrieve information about countries by their currency code."; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -// A simple OpenAPI specification for the REST Countries API -const string CountriesOpenApiSpec = """ +AITool openApiTool = FoundryAITool.CreateOpenApiTool(CreateOpenAPIFunctionDefinition()); + +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: AgentInstructions, + name: "OpenAPIToolsAgent", + tools: [openApiTool]); + +// Run the agent with a question about countries +Console.WriteLine(await agent.RunAsync("What countries use the Euro (EUR) as their currency? Please list them.")); + +OpenApiFunctionDefinition CreateOpenAPIFunctionDefinition() +{ + // A simple OpenAPI specification for the REST Countries API + const string CountriesOpenApiSpec = """ { "openapi": "3.1.0", "info": { @@ -68,49 +84,12 @@ const string CountriesOpenApiSpec = """ } """; -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Create the OpenAPI function definition -var openApiFunction = new OpenApiFunctionDefinition( - "get_countries", - BinaryData.FromString(CountriesOpenApiSpec), - new OpenAPIAnonymousAuthenticationDetails()) -{ - Description = "Retrieve information about countries by currency code" -}; - -AIAgent agent = await CreateAgentWithMEAI(); -// AIAgent agent = await CreateAgentWithNativeSDK(); - -// Run the agent with a question about countries -Console.WriteLine(await agent.RunAsync("What countries use the Euro (EUR) as their currency? Please list them.")); - -// Cleanup by deleting the agent -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); - -// --- Agent Creation Options --- - -// Option 1 - Using AsAITool wrapping for OpenApiTool (MEAI + AgentFramework) -async Task CreateAgentWithMEAI() -{ - return await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - name: "OpenAPIToolsAgent-MEAI", - instructions: AgentInstructions, - tools: [((ResponseTool)AgentTool.CreateOpenApiTool(openApiFunction)).AsAITool()]); -} - -// Option 2 - Using PromptAgentDefinition with AgentTool.CreateOpenApiTool (Native SDK) -async Task CreateAgentWithNativeSDK() -{ - return await aiProjectClient.CreateAIAgentAsync( - name: "OpenAPIToolsAgent-NATIVE", - creationOptions: new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = { (ResponseTool)AgentTool.CreateOpenApiTool(openApiFunction) } - }) - ); + // Create the OpenAPI function definition + return new( + "get_countries", + BinaryData.FromString(CountriesOpenApiSpec), + new OpenAPIAnonymousAuthenticationDetails()) + { + Description = "Retrieve information about countries by currency code" + }; } diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/README.md new file mode 100644 index 0000000000..52a11ec1dd --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/README.md @@ -0,0 +1,29 @@ +# OpenAPI Tools with the Responses API + +This sample shows how to use OpenAPI tools with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Defining an OpenAPI specification inline +- Creating an `OpenAPIFunctionDefinition` for the REST Countries API +- Using `FoundryAITool.CreateOpenApiTool()` with `ChatClientAgent` +- Server-side execution of OpenAPI tool calls + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Agent_Step18_BingCustomSearch.csproj similarity index 89% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Agent_Step18_BingCustomSearch.csproj index daf7e24494..e11688b6ba 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Agent_Step18_BingCustomSearch.csproj @@ -9,7 +9,6 @@ - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs similarity index 54% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs index 98ea576226..4ab548403d 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs @@ -1,15 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to use Bing Custom Search Tool with AI Agents. +// This sample shows how to use Bing Custom Search Tool with a ChatClientAgent. using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using OpenAI.Responses; +using Microsoft.Agents.AI.AzureAI; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; string connectionId = Environment.GetEnvironmentVariable("AZURE_AI_CUSTOM_SEARCH_CONNECTION_ID") ?? throw new InvalidOperationException("AZURE_AI_CUSTOM_SEARCH_CONNECTION_ID is not set."); string instanceName = Environment.GetEnvironmentVariable("AZURE_AI_CUSTOM_SEARCH_INSTANCE_NAME") ?? throw new InvalidOperationException("AZURE_AI_CUSTOM_SEARCH_INSTANCE_NAME is not set."); @@ -18,19 +16,24 @@ const string AgentInstructions = """ Use the available Bing Custom Search tools to answer questions and perform tasks. """; -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +// Bing Custom Search tool parameters +BingCustomSearchToolOptions bingCustomSearchToolParameters = new([ + new BingCustomSearchConfiguration(connectionId, instanceName) +]); + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -// Bing Custom Search tool parameters shared by both options -BingCustomSearchToolOptions bingCustomSearchToolParameters = new([ - new BingCustomSearchConfiguration(connectionId, instanceName) -]); - -AIAgent agent = await CreateAgentWithMEAIAsync(); -// AIAgent agent = await CreateAgentWithNativeSDKAsync(); +// Create a AIAgent with Bing Custom Search tool. +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: AgentInstructions, + name: "BingCustomSearchAgent-RAPI", + tools: [FoundryAITool.CreateBingCustomSearchTool(bingCustomSearchToolParameters)]); Console.WriteLine($"Created agent: {agent.Name}"); @@ -42,35 +45,3 @@ foreach (var message in response.Messages) { Console.WriteLine(message.Text); } - -// Cleanup by deleting the agent -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); -Console.WriteLine($"\nDeleted agent: {agent.Name}"); - -// --- Agent Creation Options --- - -// Option 1 - Using AsAITool wrapping for the ResponseTool returned by AgentTool.CreateBingCustomSearchTool (MEAI + AgentFramework) -async Task CreateAgentWithMEAIAsync() -{ - return await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - name: "BingCustomSearchAgent-MEAI", - instructions: AgentInstructions, - tools: [((ResponseTool)AgentTool.CreateBingCustomSearchTool(bingCustomSearchToolParameters)).AsAITool()]); -} - -// Option 2 - Using PromptAgentDefinition with AgentTool.CreateBingCustomSearchTool (Native SDK) -async Task CreateAgentWithNativeSDKAsync() -{ - return await aiProjectClient.CreateAIAgentAsync( - name: "BingCustomSearchAgent-NATIVE", - creationOptions: new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = { - (ResponseTool)AgentTool.CreateBingCustomSearchTool(bingCustomSearchToolParameters), - } - }) - ); -} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/README.md new file mode 100644 index 0000000000..fc48fd8744 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/README.md @@ -0,0 +1,36 @@ +# Bing Custom Search with the Responses API + +This sample shows how to use the Bing Custom Search tool with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Configuring `BingCustomSearchToolParameters` with connection ID and instance name +- Using `FoundryAITool.CreateBingCustomSearchTool()` with `ChatClientAgent` +- Processing search results from agent responses + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) +- Bing Custom Search resource configured with a connection ID + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_CUSTOM_SEARCH_CONNECTION_ID="your-connection-id" # The full ARM resource URI, e.g., "/subscriptions/.../connections/your-bing-connection" +$env:AZURE_AI_CUSTOM_SEARCH_INSTANCE_NAME="your-instance-name" # The Bing Custom Search configuration name (from Azure portal) +``` + +### Finding the connection ID and instance name + +- **Connection ID** (`AZURE_AI_CUSTOM_SEARCH_CONNECTION_ID`): The full ARM resource URI including the `/projects//connections/` segment. Find the connection name in your Foundry project under **Management center** → **Connected resources**. +- **Instance Name** (`AZURE_AI_CUSTOM_SEARCH_INSTANCE_NAME`): The **configuration name** from your Bing Custom Search resource (Azure portal → your Bing Custom Search resource → **Configurations**). This is _not_ the Azure resource name or the connection name — it's the name of the specific search configuration that defines which domains/sites to search against. + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Agent_Step19_SharePoint.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Agent_Step19_SharePoint.csproj new file mode 100644 index 0000000000..e11688b6ba --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Agent_Step19_SharePoint.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs similarity index 53% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs index ad6a08abaa..aafca6b8bd 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs @@ -1,15 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample shows how to use SharePoint Grounding Tool with AI Agents. +// This sample shows how to use SharePoint Grounding Tool with a ChatClientAgent. using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; -using OpenAI.Responses; +using Microsoft.Agents.AI.AzureAI; -string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; string sharepointConnectionId = Environment.GetEnvironmentVariable("SHAREPOINT_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("SHAREPOINT_PROJECT_CONNECTION_ID is not set."); const string AgentInstructions = """ @@ -17,18 +15,23 @@ const string AgentInstructions = """ Use the available SharePoint tools to answer questions and perform tasks. """; -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +// Create SharePoint tool options with project connection +var sharepointOptions = new SharePointGroundingToolOptions(); +sharepointOptions.ProjectConnections.Add(new ToolProjectConnection(sharepointConnectionId)); + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -// Create SharePoint tool options with project connection -var sharepointOptions = new SharePointGroundingToolOptions(); -sharepointOptions.ProjectConnections.Add(new ToolProjectConnection(sharepointConnectionId)); - -AIAgent agent = await CreateAgentWithMEAIAsync(); -// AIAgent agent = await CreateAgentWithNativeSDKAsync(); +// Create a AIAgent with SharePoint tool. +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: AgentInstructions, + name: "SharePointAgent-RAPI", + tools: [FoundryAITool.CreateSharepointTool(sharepointOptions)]); Console.WriteLine($"Created agent: {agent.Name}"); @@ -52,33 +55,3 @@ foreach (var message in response.Messages) } } } - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); -Console.WriteLine($"\nDeleted agent: {agent.Name}"); - -// --- Agent Creation Options --- - -// Option 1 - Using AgentTool.CreateSharepointTool + AsAITool() (MEAI + AgentFramework) -async Task CreateAgentWithMEAIAsync() -{ - return await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - name: "SharePointAgent-MEAI", - instructions: AgentInstructions, - tools: [((ResponseTool)AgentTool.CreateSharepointTool(sharepointOptions)).AsAITool()]); -} - -// Option 2 - Using PromptAgentDefinition SDK native type -async Task CreateAgentWithNativeSDKAsync() -{ - return await aiProjectClient.CreateAIAgentAsync( - name: "SharePointAgent-NATIVE", - creationOptions: new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = { AgentTool.CreateSharepointTool(sharepointOptions) } - }) - ); -} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/README.md new file mode 100644 index 0000000000..bfaacee860 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/README.md @@ -0,0 +1,30 @@ +# SharePoint Grounding with the Responses API + +This sample shows how to use the SharePoint Grounding tool with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Configuring `SharePointGroundingToolOptions` with project connections +- Using `FoundryAITool.CreateSharepointTool()` with `ChatClientAgent` +- Displaying grounding annotations from agent responses + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) +- SharePoint connection configured in your Microsoft Foundry project + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:SHAREPOINT_PROJECT_CONNECTION_ID="your-sharepoint-connection-id" # The full ARM resource URI, e.g., "/subscriptions/.../connections/SharepointTestTool" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Agent_Step20_MicrosoftFabric.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Agent_Step20_MicrosoftFabric.csproj new file mode 100644 index 0000000000..e11688b6ba --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Agent_Step20_MicrosoftFabric.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs new file mode 100644 index 0000000000..7c49afa7ee --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use Microsoft Fabric Tool with a ChatClientAgent. + +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; + +string fabricConnectionId = Environment.GetEnvironmentVariable("FABRIC_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("FABRIC_PROJECT_CONNECTION_ID is not set."); + +const string AgentInstructions = "You are a helpful assistant with access to Microsoft Fabric data. Answer questions based on data available through your Fabric connection."; + +// Configure Microsoft Fabric tool options with project connection +var fabricToolOptions = new FabricDataAgentToolOptions(); +fabricToolOptions.ProjectConnections.Add(new ToolProjectConnection(fabricConnectionId)); + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +// Create a AIAgent with Microsoft Fabric tool. +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: AgentInstructions, + name: "FabricAgent-RAPI", + tools: [FoundryAITool.CreateMicrosoftFabricTool(fabricToolOptions)]); + +Console.WriteLine($"Created agent: {agent.Name}"); + +// Run the agent with a sample query +AgentResponse response = await agent.RunAsync("What data is available in the connected Fabric workspace?"); + +Console.WriteLine("\n=== Agent Response ==="); +foreach (var message in response.Messages) +{ + Console.WriteLine(message.Text); +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/README.md new file mode 100644 index 0000000000..4a4d31e151 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/README.md @@ -0,0 +1,30 @@ +# Microsoft Fabric with the Responses API + +This sample shows how to use the Microsoft Fabric tool with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Configuring `FabricDataAgentToolOptions` with project connections +- Using `FoundryAITool.CreateMicrosoftFabricTool()` with `ChatClientAgent` +- Querying data available through a Fabric connection + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) +- Microsoft Fabric connection configured in your Microsoft Foundry project + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:FABRIC_PROJECT_CONNECTION_ID="your-fabric-connection-id" # The full ARM resource URI, e.g., "/subscriptions/.../connections/FabricTestTool" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Agent_Step21_WebSearch.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Agent_Step21_WebSearch.csproj new file mode 100644 index 0000000000..e11688b6ba --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Agent_Step21_WebSearch.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Program.cs new file mode 100644 index 0000000000..20d74b0401 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Program.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use the Web Search Tool with a ChatClientAgent. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +const string AgentInstructions = "You are a helpful assistant that can search the web to find current information and answer questions accurately."; +const string AgentName = "WebSearchAgent-RAPI"; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +// Create a AIAgent with HostedWebSearchTool. +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: AgentInstructions, + name: AgentName, + tools: [new HostedWebSearchTool()]); + +AgentResponse response = await agent.RunAsync("What's the weather today in Seattle?"); + +// Get the text response +Console.WriteLine($"Response: {response.Text}"); + +// Getting any annotations/citations generated by the web search tool +foreach (AIAnnotation annotation in response.Messages.SelectMany(m => m.Contents).SelectMany(c => c.Annotations ?? [])) +{ + Console.WriteLine($"Annotation: {annotation}"); + if (annotation.RawRepresentation is UriCitationMessageAnnotation urlCitation) + { + Console.WriteLine($$""" + Title: {{urlCitation.Title}} + URL: {{urlCitation.Uri}} + """); + } +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/README.md new file mode 100644 index 0000000000..1a9d80106d --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/README.md @@ -0,0 +1,28 @@ +# Web Search with the Responses API + +This sample shows how to use the Web Search tool with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Using `HostedWebSearchTool` with `ChatClientAgent` +- Processing web search citations and annotations +- Extracting URL citation details (title, URL) from responses + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj new file mode 100644 index 0000000000..4602e9c9e0 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs similarity index 70% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs index 60452b7d19..9e1a902f29 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs @@ -9,6 +9,8 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; +using Microsoft.Extensions.AI; using OpenAI.Responses; string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); @@ -22,27 +24,25 @@ const string AgentInstructions = """ When a user shares personal details or preferences, remember them for future conversations. """; -const string AgentNameMEAI = "MemorySearchAgent-MEAI"; -const string AgentNameNative = "MemorySearchAgent-NATIVE"; +const string AgentName = "MemorySearchAgent"; -// Scope identifies the user or context for memory isolation. -// Using a unique user identifier ensures memories are private to that user. string userScope = $"user_{Environment.MachineName}"; -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -DefaultAzureCredential credential = new(); -AIProjectClient aiProjectClient = new(new Uri(endpoint), credential); +MemorySearchPreviewTool memorySearchTool = new(memoryStoreName, userScope) { UpdateDelayInSecs = 0 }; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +// Create agent using the RAPI path with the MemorySearch tool +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, + instructions: AgentInstructions, + name: AgentName, + tools: [FoundryAITool.FromResponseTool(memorySearchTool)]); // Ensure the memory store exists and has memories to retrieve. await EnsureMemoryStoreAsync(); -// Create the Memory Search tool configuration -MemorySearchPreviewTool memorySearchTool = new(memoryStoreName, userScope) { UpdateDelayInSecs = 0 }; - -// Create agent using Option 1 (MEAI) or Option 2 (Native SDK) -AIAgent agent = await CreateAgentWithMEAI(); -// AIAgent agent = await CreateAgentWithNativeSDK(); - try { Console.WriteLine("Agent created with Memory Search tool. Starting conversation...\n"); @@ -73,41 +73,14 @@ try } finally { - // Cleanup: Delete the agent and memory store. + // Cleanup: Delete the memory store (no server-side agent to clean up in RAPI path). Console.WriteLine("\nCleaning up..."); - await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); - Console.WriteLine("Agent deleted."); await aiProjectClient.MemoryStores.DeleteMemoryStoreAsync(memoryStoreName); Console.WriteLine("Memory store deleted."); } -#pragma warning disable CS8321 // Local function is declared but never used - -// Option 1 - Using MemorySearchTool wrapped as MEAI AITool -async Task CreateAgentWithMEAI() -{ - return await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - name: AgentNameMEAI, - instructions: AgentInstructions, - tools: [((ResponseTool)memorySearchTool).AsAITool()]); -} - -// Option 2 - Using PromptAgentDefinition with MemorySearchTool (Native SDK) -async Task CreateAgentWithNativeSDK() -{ - return await aiProjectClient.CreateAIAgentAsync( - name: AgentNameNative, - creationOptions: new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = { memorySearchTool } - }) - ); -} - // Helpers — kept at the bottom so the main agent flow above stays clean. + async Task EnsureMemoryStoreAsync() { Console.WriteLine($"Creating memory store '{memoryStoreName}'..."); @@ -123,19 +96,37 @@ async Task EnsureMemoryStoreAsync() Console.WriteLine("Memory store created."); } + // Explicitly add memories from a simulated prior conversation. Console.WriteLine("Storing memories from a prior conversation..."); MemoryUpdateOptions memoryOptions = new(userScope) { UpdateDelay = 0 }; - memoryOptions.Items.Add(ResponseItem.CreateUserMessageItem("My name is Alice and I love programming in C#.")); + memoryOptions.Items.Add(ResponseItem.CreateUserMessageItem("My name is Alice and I prefer C#.")); MemoryUpdateResult updateResult = await aiProjectClient.MemoryStores.WaitForMemoriesUpdateAsync( memoryStoreName: memoryStoreName, - pollingInterval: 500, - options: memoryOptions); + options: memoryOptions, + pollingInterval: 500); if (updateResult.Status == MemoryStoreUpdateStatus.Failed) { throw new InvalidOperationException($"Memory update failed: {updateResult.ErrorDetails}"); } - Console.WriteLine($"Memory update completed (status: {updateResult.Status}).\n"); + Console.WriteLine($"Memory update completed (status: {updateResult.Status})."); + + // Quick verification that memories are searchable. + Console.WriteLine("Verifying stored memories..."); + MemorySearchOptions searchOptions = new(userScope) + { + Items = { ResponseItem.CreateUserMessageItem("What are Alice's preferences?") } + }; + MemoryStoreSearchResponse searchResult = await aiProjectClient.MemoryStores.SearchMemoriesAsync( + memoryStoreName: memoryStoreName, + options: searchOptions); + + foreach (var memory in searchResult.Memories) + { + Console.WriteLine($" - {memory.MemoryItem.Content}"); + } + + Console.WriteLine(); } diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/README.md new file mode 100644 index 0000000000..b8020553af --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/README.md @@ -0,0 +1,31 @@ +# Memory Search with the Responses API + +This sample demonstrates how to use the Memory Search tool with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Configuring `MemorySearchPreviewTool` with a memory store and user scope +- Using memory search for cross-conversation recall +- Inspecting `MemorySearchToolCallResponseItem` results +- User profile persistence across conversations + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) +- A memory store created beforehand via Azure Portal or Python SDK + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:AZURE_AI_MEMORY_STORE_ID="your-memory-store-name" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj new file mode 100644 index 0000000000..e51f57c439 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Program.cs similarity index 50% rename from dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/Program.cs rename to dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Program.cs index d41771ef37..c9f8c0060b 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Program.cs @@ -1,24 +1,20 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample demonstrates how to use a local MCP (Model Context Protocol) client with Azure Foundry Agents. -// The MCP tools are resolved locally by connecting directly to the MCP server via HTTP, -// and then passed to the Foundry agent as client-side tools. -// This sample uses the Microsoft Learn MCP endpoint to search documentation. +// This sample demonstrates how to wrap MCP tools with a DelegatingAIFunction to add custom behavior (e.g., logging). +// Compare with Step09 which shows basic MCP tool usage without wrapping. +// The LoggingMcpTool pattern is useful for diagnostics, metering, or adding approval logic around tool calls. using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using ModelContextProtocol.Client; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +using SampleApp; const string AgentInstructions = "You are a helpful assistant that can help with Microsoft documentation questions. Use the Microsoft Learn MCP tool to search for documentation."; -const string AgentName = "DocsAgent"; +const string AgentName = "DocsAgent-RAPI"; // Connect to the MCP server locally via HTTP (Streamable HTTP transport). -// The MCP server is hosted at Microsoft Learn and provides documentation search capabilities. Console.WriteLine("Connecting to MCP server at https://learn.microsoft.com/api/mcp ..."); await using McpClient mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new() @@ -34,53 +30,48 @@ Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => // Wrap each MCP tool with a DelegatingAIFunction to log local invocations. List wrappedTools = mcpTools.Select(tool => (AITool)new LoggingMcpTool(tool)).ToList(); -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); -// Create the agent with the locally-resolved MCP tools. -AIAgent agent = await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - name: AgentName, +// Create a AIAgent with the locally-resolved MCP tools. +AIAgent agent = aiProjectClient.AsAIAgent(deploymentName, instructions: AgentInstructions, + name: AgentName, tools: wrappedTools); Console.WriteLine($"Agent '{agent.Name}' created successfully."); -try -{ - // First query - const string Prompt1 = "How does one create an Azure storage account using az cli?"; - Console.WriteLine($"\nUser: {Prompt1}\n"); - AgentResponse response1 = await agent.RunAsync(Prompt1); - Console.WriteLine($"Agent: {response1}"); +// First query +const string Prompt1 = "How does one create an Azure storage account using az cli?"; +Console.WriteLine($"\nUser: {Prompt1}\n"); +AgentResponse response1 = await agent.RunAsync(Prompt1); +Console.WriteLine($"Agent: {response1}"); - Console.WriteLine("\n=======================================\n"); +Console.WriteLine("\n=======================================\n"); - // Second query - const string Prompt2 = "What is Microsoft Agent Framework?"; - Console.WriteLine($"User: {Prompt2}\n"); - AgentResponse response2 = await agent.RunAsync(Prompt2); - Console.WriteLine($"Agent: {response2}"); -} -finally -{ - // Cleanup by removing the agent when done - await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); - Console.WriteLine($"\nAgent '{agent.Name}' deleted."); -} +// Second query +const string Prompt2 = "What is Microsoft Agent Framework?"; +Console.WriteLine($"User: {Prompt2}\n"); +AgentResponse response2 = await agent.RunAsync(Prompt2); +Console.WriteLine($"Agent: {response2}"); -/// -/// Wraps an MCP tool to log when it is invoked locally, -/// confirming that the MCP call is happening client-side. -/// -internal sealed class LoggingMcpTool(AIFunction innerFunction) : DelegatingAIFunction(innerFunction) +namespace SampleApp { - protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + /// + /// Wraps an MCP tool to log when it is invoked locally, + /// confirming that the MCP call is happening client-side. + /// + internal sealed class LoggingMcpTool(AIFunction innerFunction) : DelegatingAIFunction(innerFunction) { - Console.WriteLine($" >> [LOCAL MCP] Invoking tool '{this.Name}' locally..."); - return base.InvokeCoreAsync(arguments, cancellationToken); + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + Console.WriteLine($" >> [LOCAL MCP] Invoking tool '{this.Name}' locally..."); + return base.InvokeCoreAsync(arguments, cancellationToken); + } } } diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/README.md new file mode 100644 index 0000000000..84456df2b7 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/README.md @@ -0,0 +1,29 @@ +# Local MCP with the Responses API + +This sample demonstrates how to use a local MCP (Model Context Protocol) client with a `ChatClientAgent` using the Responses API directly. + +## What this sample demonstrates + +- Connecting to an MCP server via HTTP (Streamable HTTP transport) +- Resolving MCP tools locally and wrapping them with logging +- Using `DelegatingAIFunction` to add custom behavior to MCP tools +- Passing locally-resolved MCP tools to `ChatClientAgent` + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +## Run the sample + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/README.md new file mode 100644 index 0000000000..b5802053b3 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/README.md @@ -0,0 +1,81 @@ +# Getting started with Foundry Agents + +These samples demonstrate how to use Azure AI Foundry with Agent Framework. + +## Quick start + +The simplest way to create a Foundry agent is using the `FoundryAgent` type directly: + +```csharp +FoundryAgent agent = new( + new Uri(endpoint), + new AzureCliCredential(), + model: "gpt-4o-mini", + instructions: "You are good at telling jokes.", + name: "JokerAgent"); + +Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); +``` + +Or using the `AIProjectClient.AsAIAgent(...)` extensions: + +```csharp +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +FoundryAgent agent = aiProjectClient.AsAIAgent( + model: deploymentName, + instructions: "You are good at telling jokes.", + name: "JokerAgent"); +``` + +## Prerequisites + +- .NET 10 SDK or later +- Foundry project endpoint +- Azure CLI installed and authenticated + +Set: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +Some samples require extra tool-specific environment variables. See each sample for details. + +## Samples + +| Sample | Description | +| --- | --- | +| [FoundryAgent lifecycle](./Agent_Step00_FoundryAgentLifecycle/) | Create a FoundryAgent directly with endpoint and credentials | +| [Basics (Responses API)](./Agent_Step01_Basics/) | Create and run an agent using AsAIAgent extensions | +| [Multi-turn conversation](./Agent_Step02.1_MultiturnConversation/) | Multi-turn using sessions and response ID chaining | +| [Multi-turn with server conversations](./Agent_Step02.2_MultiturnWithServerConversations/) | Server-side conversations visible in Foundry UI | +| [Using function tools](./Agent_Step03_UsingFunctionTools/) | Function tools | +| [Function tools with approvals](./Agent_Step04_UsingFunctionToolsWithApprovals/) | Human-in-the-loop approval | +| [Structured output](./Agent_Step05_StructuredOutput/) | Structured output with JSON schema | +| [Persisted conversations](./Agent_Step06_PersistedConversations/) | Persisting and resuming conversations | +| [Observability](./Agent_Step07_Observability/) | OpenTelemetry observability | +| [Dependency injection](./Agent_Step08_DependencyInjection/) | DI with a hosted service | +| [Using MCP client as tools](./Agent_Step09_UsingMcpClientAsTools/) | MCP client tools | +| [Using images](./Agent_Step10_UsingImages/) | Image multi-modality | +| [Agent as function tool](./Agent_Step11_AsFunctionTool/) | Agent as a function tool for another | +| [Middleware](./Agent_Step12_Middleware/) | Multiple middleware layers | +| [Plugins](./Agent_Step13_Plugins/) | Plugins with dependency injection | +| [Code interpreter](./Agent_Step14_CodeInterpreter/) | Code interpreter tool | +| [Computer use](./Agent_Step15_ComputerUse/) | Computer use tool | +| [File search](./Agent_Step16_FileSearch/) | File search tool | +| [OpenAPI tools](./Agent_Step17_OpenAPITools/) | OpenAPI tools | +| [Bing custom search](./Agent_Step18_BingCustomSearch/) | Bing Custom Search tool | +| [SharePoint](./Agent_Step19_SharePoint/) | SharePoint grounding tool | +| [Microsoft Fabric](./Agent_Step20_MicrosoftFabric/) | Microsoft Fabric tool | +| [Web search](./Agent_Step21_WebSearch/) | Web search tool | +| [Memory search](./Agent_Step22_MemorySearch/) | Memory search tool | +| [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport | + +## Running the samples + +```powershell +cd dotnet/samples/02-agents/AgentsWithFoundry +dotnet run --project .\FoundryAgent_Step01 +``` \ No newline at end of file diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/Program.cs deleted file mode 100644 index 1e1e48d54b..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/Program.cs +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates how to use Azure AI Foundry's Red Teaming service to assess -// the safety and resilience of an AI model against adversarial attacks. -// -// It uses the RedTeam API from Azure.AI.Projects to run automated attack simulations -// with various attack strategies (encoding, obfuscation, jailbreaks) across multiple -// risk categories (Violence, HateUnfairness, Sexual, SelfHarm). -// -// For more details, see: -// https://learn.microsoft.com/azure/ai-foundry/concepts/ai-red-teaming-agent - -using Azure.AI.Projects; -using Azure.Identity; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -Console.WriteLine("=" + new string('=', 79)); -Console.WriteLine("RED TEAMING EVALUATION SAMPLE"); -Console.WriteLine("=" + new string('=', 79)); -Console.WriteLine(); - -// Initialize Azure credentials and clients -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -DefaultAzureCredential credential = new(); -AIProjectClient aiProjectClient = new(new Uri(endpoint), credential); - -// Configure the target model for red teaming -AzureOpenAIModelConfiguration targetConfig = new(deploymentName); - -// Create the red team run configuration -RedTeam redTeamConfig = new(targetConfig) -{ - DisplayName = "FinancialAdvisor-RedTeam", - ApplicationScenario = "A financial advisor assistant that provides general financial advice and information.", - NumTurns = 3, - RiskCategories = - { - RiskCategory.Violence, - RiskCategory.HateUnfairness, - RiskCategory.Sexual, - RiskCategory.SelfHarm, - }, - AttackStrategies = - { - AttackStrategy.Easy, - AttackStrategy.Moderate, - AttackStrategy.Jailbreak, - }, -}; - -Console.WriteLine($"Target model: {deploymentName}"); -Console.WriteLine("Risk categories: Violence, HateUnfairness, Sexual, SelfHarm"); -Console.WriteLine("Attack strategies: Easy, Moderate, Jailbreak"); -Console.WriteLine($"Simulation turns: {redTeamConfig.NumTurns}"); -Console.WriteLine(); - -// Submit the red team run to the service -Console.WriteLine("Submitting red team run..."); -RedTeam redTeamRun = await aiProjectClient.RedTeams.CreateAsync(redTeamConfig, options: null); - -Console.WriteLine($"Red team run created: {redTeamRun.Name}"); -Console.WriteLine($"Status: {redTeamRun.Status}"); -Console.WriteLine(); - -// Poll for completion -Console.WriteLine("Waiting for red team run to complete (this may take several minutes)..."); -while (redTeamRun.Status != "Completed" && redTeamRun.Status != "Failed" && redTeamRun.Status != "Canceled") -{ - await Task.Delay(TimeSpan.FromSeconds(15)); - redTeamRun = await aiProjectClient.RedTeams.GetAsync(redTeamRun.Name); - Console.WriteLine($" Status: {redTeamRun.Status}"); -} - -Console.WriteLine(); - -if (redTeamRun.Status == "Completed") -{ - Console.WriteLine("Red team run completed successfully!"); - Console.WriteLine(); - Console.WriteLine("Results:"); - Console.WriteLine(new string('-', 80)); - Console.WriteLine($" Run name: {redTeamRun.Name}"); - Console.WriteLine($" Display name: {redTeamRun.DisplayName}"); - Console.WriteLine($" Status: {redTeamRun.Status}"); - - Console.WriteLine(); - Console.WriteLine("Review the detailed results in the Azure AI Foundry portal:"); - Console.WriteLine($" {endpoint}"); -} -else -{ - Console.WriteLine($"Red team run ended with status: {redTeamRun.Status}"); -} - -Console.WriteLine(); -Console.WriteLine(new string('=', 80)); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/README.md deleted file mode 100644 index 24e4a62b35..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/README.md +++ /dev/null @@ -1,101 +0,0 @@ -# Red Teaming with Azure AI Foundry (Classic) - -> [!IMPORTANT] -> This sample uses the **classic Azure AI Foundry** red teaming API (`/redTeams/runs`) via `Azure.AI.Projects`. Results are viewable in the classic Foundry portal experience. The **new Foundry** portal's red teaming feature uses a different evaluation-based API that is not yet available in the .NET SDK. - -This sample demonstrates how to use Azure AI Foundry's Red Teaming service to assess the safety and resilience of an AI model against adversarial attacks. - -## What this sample demonstrates - -- Configuring a red team run targeting an Azure OpenAI model deployment -- Using multiple `AttackStrategy` options (Easy, Moderate, Jailbreak) -- Evaluating across `RiskCategory` categories (Violence, HateUnfairness, Sexual, SelfHarm) -- Submitting a red team scan and polling for completion -- Reviewing results in the Azure AI Foundry portal - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure AI Foundry project (hub and project created) -- Azure OpenAI deployment (e.g., gpt-4o or gpt-4o-mini) -- Azure CLI installed and authenticated (for Azure credential authentication) - -### Regional Requirements - -Red teaming is only available in regions that support risk and safety evaluators: -- **East US 2**, **Sweden Central**, **US North Central**, **France Central**, **Switzerland West** - -### Environment Variables - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project" # Replace with your Azure Foundry project endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming -dotnet run -``` - -## Expected behavior - -The sample will: - -1. Configure a `RedTeam` run targeting the specified model deployment -2. Define risk categories and attack strategies -3. Submit the scan to Azure AI Foundry's Red Teaming service -4. Poll for completion (this may take several minutes) -5. Display the run status and direct you to the Azure AI Foundry portal for detailed results - -## Understanding Red Teaming - -### Attack Strategies - -| Strategy | Description | -|----------|-------------| -| Easy | Simple encoding/obfuscation attacks (ROT13, Leetspeak, etc.) | -| Moderate | Moderate complexity attacks requiring an LLM for orchestration | -| Jailbreak | Crafted prompts designed to bypass AI safeguards (UPIA) | - -### Risk Categories - -| Category | Description | -|----------|-------------| -| Violence | Content related to violence | -| HateUnfairness | Hate speech or unfair content | -| Sexual | Sexual content | -| SelfHarm | Self-harm related content | - -### Interpreting Results - -- Results are available in the Azure AI Foundry portal (**classic view** — toggle at top-right) under the red teaming section -- Lower Attack Success Rate (ASR) is better — target ASR < 5% for production -- Review individual attack conversations to understand vulnerabilities - -### Current Limitations - -> [!NOTE] -> - The .NET Red Teaming API (`Azure.AI.Projects`) currently supports targeting **model deployments only** via `AzureOpenAIModelConfiguration`. The `AzureAIAgentTarget` type exists in the SDK but is consumed by the **Evaluation Taxonomy** API (`/evaluationtaxonomies`), not by the Red Teaming API (`/redTeams/runs`). -> - Agent-targeted red teaming with agent-specific risk categories (Prohibited actions, Sensitive data leakage, Task adherence) is documented in the [concept docs](https://learn.microsoft.com/azure/ai-foundry/concepts/ai-red-teaming-agent) but is not yet available via the public REST API or .NET SDK. -> - Results from this API appear in the **classic** Azure AI Foundry portal view. The new Foundry portal uses a separate evaluation-based system with `eval_*` identifiers. - -## Related Resources - -- [Azure AI Red Teaming Agent](https://learn.microsoft.com/azure/ai-foundry/concepts/ai-red-teaming-agent) -- [RedTeam .NET API Reference](https://learn.microsoft.com/dotnet/api/azure.ai.projects.redteam?view=azure-dotnet-preview) -- [Risk and Safety Evaluations](https://learn.microsoft.com/azure/ai-foundry/concepts/evaluation-metrics-built-in#risk-and-safety-evaluators) - -## Next Steps - -After running red teaming: -1. Review attack results and strengthen agent guardrails -2. Explore the Self-Reflection sample (FoundryAgents_Evaluations_Step02_SelfReflection) for quality assessment -3. Set up continuous red teaming in your CI/CD pipeline diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/FoundryAgents_Evaluations_Step02_SelfReflection.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/FoundryAgents_Evaluations_Step02_SelfReflection.csproj deleted file mode 100644 index 646cd75532..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/FoundryAgents_Evaluations_Step02_SelfReflection.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/Program.cs deleted file mode 100644 index 8f8c9fa4ee..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/Program.cs +++ /dev/null @@ -1,292 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates how to use Microsoft.Extensions.AI.Evaluation.Quality to evaluate -// an Agent Framework agent's response quality with a self-reflection loop. -// -// It uses GroundednessEvaluator, RelevanceEvaluator, and CoherenceEvaluator to score responses, -// then iteratively asks the agent to improve based on evaluation feedback. -// -// Based on: Reflexion: Language Agents with Verbal Reinforcement Learning (NeurIPS 2023) -// Reference: https://arxiv.org/abs/2303.11366 -// -// For more details, see: -// https://learn.microsoft.com/dotnet/ai/evaluation/libraries - -using Azure.AI.OpenAI; -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.AI.Evaluation; -using Microsoft.Extensions.AI.Evaluation.Quality; -using Microsoft.Extensions.AI.Evaluation.Safety; - -using ChatMessage = Microsoft.Extensions.AI.ChatMessage; -using ChatRole = Microsoft.Extensions.AI.ChatRole; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -string openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string evaluatorDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? deploymentName; - -Console.WriteLine("=" + new string('=', 79)); -Console.WriteLine("SELF-REFLECTION EVALUATION SAMPLE"); -Console.WriteLine("=" + new string('=', 79)); -Console.WriteLine(); - -// Initialize Azure credentials and client -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -DefaultAzureCredential credential = new(); -AIProjectClient aiProjectClient = new(new Uri(endpoint), credential); - -// Set up the LLM-based chat client for quality evaluators -IChatClient chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential) - .GetChatClient(evaluatorDeploymentName) - .AsIChatClient(); - -// Configure evaluation: quality evaluators use the LLM, safety evaluators use Azure AI Foundry -ContentSafetyServiceConfiguration safetyConfig = new( - credential: credential, - endpoint: new Uri(endpoint)); - -ChatConfiguration chatConfiguration = safetyConfig.ToChatConfiguration( - originalChatConfiguration: new ChatConfiguration(chatClient)); - -// Create a test agent -AIAgent agent = await aiProjectClient.CreateAIAgentAsync( - name: "KnowledgeAgent", - model: deploymentName, - instructions: "You are a helpful assistant. Answer questions accurately based on the provided context."); -Console.WriteLine($"Created agent: {agent.Name}"); -Console.WriteLine(); - -// Example question and grounding context -const string Question = """ - What are the main benefits of using Azure AI Foundry for building AI applications? - """; - -const string Context = """ - Azure AI Foundry is a comprehensive platform for building, deploying, and managing AI applications. - Key benefits include: - 1. Unified development environment with support for multiple AI frameworks and models - 2. Built-in safety and security features including content filtering and red teaming tools - 3. Scalable infrastructure that handles deployment and monitoring automatically - 4. Integration with Azure services like Azure OpenAI, Cognitive Services, and Machine Learning - 5. Evaluation tools for assessing model quality, safety, and performance - 6. Support for RAG (Retrieval-Augmented Generation) patterns with vector search - 7. Enterprise-grade compliance and governance features - """; - -Console.WriteLine("Question:"); -Console.WriteLine(Question); -Console.WriteLine(); - -// Run evaluations -try -{ - await RunSelfReflectionWithGroundedness(agent, Question, Context, chatConfiguration); - await RunQualityEvaluation(agent, Question, Context, chatConfiguration); - await RunCombinedQualityAndSafetyEvaluation(agent, Question, chatConfiguration); -} -finally -{ - // Cleanup - await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); - Console.WriteLine(); - Console.WriteLine("Cleanup: Agent deleted."); -} - -// ============================================================================ -// Implementation Functions -// ============================================================================ - -static async Task RunSelfReflectionWithGroundedness( - AIAgent agent, string question, string context, ChatConfiguration chatConfiguration) -{ - Console.WriteLine("Running Self-Reflection with Groundedness Evaluation..."); - Console.WriteLine(); - - GroundednessEvaluator groundednessEvaluator = new(); - GroundednessEvaluatorContext groundingContext = new(context); - - const int MaxReflections = 3; - double bestScore = 0; - - string currentPrompt = $"Context: {context}\n\nQuestion: {question}"; - - for (int i = 0; i < MaxReflections; i++) - { - Console.WriteLine($"Iteration {i + 1}/{MaxReflections}:"); - Console.WriteLine(new string('-', 40)); - - // Create a new session for each reflection iteration so that - // conversation context does not carry over between runs. This keeps - // each evaluation independent and avoids biasing groundedness scores. - AgentSession session = await agent.CreateSessionAsync(); - AgentResponse agentResponse = await agent.RunAsync(currentPrompt, session); - string responseText = agentResponse.Text; - - Console.WriteLine($"Response: {responseText[..Math.Min(150, responseText.Length)]}..."); - - List messages = - [ - new(ChatRole.User, currentPrompt), - ]; - ChatResponse chatResponse = new(new ChatMessage(ChatRole.Assistant, responseText)); - - EvaluationResult result = await groundednessEvaluator.EvaluateAsync( - messages, - chatResponse, - chatConfiguration, - additionalContext: [groundingContext]); - - NumericMetric groundedness = result.Get(GroundednessEvaluator.GroundednessMetricName); - double score = groundedness.Value ?? 0; - string rating = groundedness.Interpretation?.Rating.ToString() ?? "N/A"; - - Console.WriteLine($"Groundedness score: {score:F1}/5 (Rating: {rating})"); - Console.WriteLine(); - - if (score > bestScore) - { - bestScore = score; - } - - if (score >= 4.0 || i == MaxReflections - 1) - { - if (score >= 4.0) - { - Console.WriteLine("Good groundedness achieved!"); - } - - break; - } - - // Ask for improvement in the next iteration, including the previous response - // so the LLM knows what to improve on (each iteration uses a new session). - currentPrompt = $""" - Context: {context} - - Your previous answer scored {score}/5 on groundedness. - Your previous answer was: - {responseText} - - Please improve your answer to be more grounded in the provided context. - Only include information that is directly supported by the context. - - Question: {question} - """; - Console.WriteLine("Requesting improvement..."); - Console.WriteLine(); - } - - Console.WriteLine($"Best groundedness score: {bestScore:F1}/5"); - Console.WriteLine(new string('=', 80)); - Console.WriteLine(); -} - -static async Task RunQualityEvaluation( - AIAgent agent, string question, string context, ChatConfiguration chatConfiguration) -{ - Console.WriteLine("Running Quality Evaluation (Relevance, Coherence, Groundedness)..."); - Console.WriteLine(); - - IEvaluator[] evaluators = - [ - new RelevanceEvaluator(), - new CoherenceEvaluator(), - new GroundednessEvaluator(), - ]; - - CompositeEvaluator compositeEvaluator = new(evaluators); - GroundednessEvaluatorContext groundingContext = new(context); - - string prompt = $"Context: {context}\n\nQuestion: {question}"; - - AgentSession session = await agent.CreateSessionAsync(); - AgentResponse agentResponse = await agent.RunAsync(prompt, session); - string responseText = agentResponse.Text; - - Console.WriteLine($"Response: {responseText[..Math.Min(150, responseText.Length)]}..."); - Console.WriteLine(); - - List messages = - [ - new(ChatRole.User, prompt), - ]; - ChatResponse chatResponse = new(new ChatMessage(ChatRole.Assistant, responseText)); - - EvaluationResult result = await compositeEvaluator.EvaluateAsync( - messages, - chatResponse, - chatConfiguration, - additionalContext: [groundingContext]); - - foreach (EvaluationMetric metric in result.Metrics.Values) - { - if (metric is NumericMetric n) - { - string rating = n.Interpretation?.Rating.ToString() ?? "N/A"; - Console.WriteLine($" {n.Name,-20} Score: {n.Value:F1}/5 Rating: {rating}"); - } - } - - Console.WriteLine(new string('=', 80)); - Console.WriteLine(); -} - -static async Task RunCombinedQualityAndSafetyEvaluation( - AIAgent agent, string question, ChatConfiguration chatConfiguration) -{ - Console.WriteLine("Running Combined Quality + Safety Evaluation..."); - Console.WriteLine(); - - IEvaluator[] evaluators = - [ - new RelevanceEvaluator(), - new CoherenceEvaluator(), - new ContentHarmEvaluator(), - new ProtectedMaterialEvaluator(), - ]; - - CompositeEvaluator compositeEvaluator = new(evaluators); - - AgentSession session = await agent.CreateSessionAsync(); - AgentResponse agentResponse = await agent.RunAsync(question, session); - string responseText = agentResponse.Text; - - Console.WriteLine($"Response: {responseText[..Math.Min(150, responseText.Length)]}..."); - Console.WriteLine(); - - List messages = - [ - new(ChatRole.User, question), // No context in this evaluation — testing quality and safety on raw question - ]; - ChatResponse chatResponse = new(new ChatMessage(ChatRole.Assistant, responseText)); - - EvaluationResult result = await compositeEvaluator.EvaluateAsync( - messages, - chatResponse, - chatConfiguration); - - Console.WriteLine("Quality Metrics:"); - foreach (EvaluationMetric metric in result.Metrics.Values) - { - if (metric is NumericMetric n) - { - string rating = n.Interpretation?.Rating.ToString() ?? "N/A"; - bool failed = n.Interpretation?.Failed ?? false; - Console.WriteLine($" {n.Name,-25} Score: {n.Value:F1,-6} Rating: {rating,-15} Failed: {failed}"); - } - else if (metric is BooleanMetric b) - { - string rating = b.Interpretation?.Rating.ToString() ?? "N/A"; - bool failed = b.Interpretation?.Failed ?? false; - Console.WriteLine($" {b.Name,-25} Value: {b.Value,-6} Rating: {rating,-15} Failed: {failed}"); - } - } - - Console.WriteLine(new string('=', 80)); -} diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/README.md deleted file mode 100644 index d71eeca6af..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/README.md +++ /dev/null @@ -1,118 +0,0 @@ -# Self-Reflection Evaluation with Groundedness Assessment - -This sample demonstrates the self-reflection pattern using Agent Framework with `Microsoft.Extensions.AI.Evaluation.Quality` evaluators. The agent iteratively improves its responses based on real groundedness evaluation scores. - -For details on the self-reflection approach, see [Reflexion: Language Agents with Verbal Reinforcement Learning](https://arxiv.org/abs/2303.11366) (NeurIPS 2023). - -## What this sample demonstrates - -- Self-reflection loop that improves responses using real `GroundednessEvaluator` scores -- Using `RelevanceEvaluator` and `CoherenceEvaluator` for multi-metric quality assessment -- Combining quality and safety evaluators with `CompositeEvaluator` -- Configuring `ContentSafetyServiceConfiguration` for safety evaluators alongside LLM-based quality evaluators -- Tracking improvement across iterations - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure AI Foundry project (hub and project created) -- Azure OpenAI deployment (e.g., gpt-4o or gpt-4o-mini) -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -### Azure Resources Required - -1. **Azure AI Hub and Project**: Create these in the Azure Portal - - Follow: https://learn.microsoft.com/azure/ai-foundry/how-to/create-projects -2. **Azure OpenAI Deployment**: Deploy a model (e.g., gpt-4o or gpt-4o-mini) - - Agent model: Used to generate responses - - Evaluator model: Quality evaluators use an LLM; best results with GPT-4o -3. **Azure CLI**: Install and authenticate with `az login` - -### Environment Variables - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-project.api.azureml.ms" # Azure Foundry project endpoint -$env:AZURE_OPENAI_ENDPOINT="https://your-openai.openai.azure.com/" # Azure OpenAI endpoint (for quality evaluators) -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Model deployment name -``` - -**Note**: For best evaluation results, use GPT-4o or GPT-4o-mini as the evaluator model. The groundedness evaluator has been tested and tuned for these models. - -## Run the sample - -Navigate to the sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection -dotnet run -``` - -## Expected behavior - -The sample runs three evaluation scenarios: - -### 1. Self-Reflection with Groundedness -- Asks a question with grounding context -- Evaluates response groundedness using `GroundednessEvaluator` -- If score is below 4/5, asks the agent to improve with feedback -- Repeats up to 3 iterations -- Tracks and reports the best score achieved - -### 2. Quality Evaluation -- Evaluates a single response with multiple quality evaluators: - - `RelevanceEvaluator` — is the response relevant to the question? - - `CoherenceEvaluator` — is the response logically coherent? - - `GroundednessEvaluator` — is the response grounded in the provided context? - -### 3. Combined Quality + Safety Evaluation -- Runs both quality and safety evaluators together: - - `RelevanceEvaluator`, `CoherenceEvaluator` (quality) - - `ContentHarmEvaluator` (safety — violence, hate, sexual, self-harm) - - `ProtectedMaterialEvaluator` (safety — copyrighted content detection) - -## Understanding the Evaluation - -### Groundedness Score (1-5 scale) - -The `GroundednessEvaluator` measures how well the agent's response is grounded in the provided context: - -- **5** = Excellent - Response is fully grounded in context -- **4** = Good - Mostly grounded with minor deviations -- **3** = Fair - Partially grounded but includes unsupported claims -- **2** = Poor - Significant amount of ungrounded content -- **1** = Very Poor - Response is largely unsupported by context - -### Self-Reflection Process - -1. **Initial Response**: Agent generates answer based on question + context -2. **Evaluation**: `GroundednessEvaluator` scores the response (1-5) -3. **Feedback**: If score < 4, agent receives the score and is asked to improve -4. **Iteration**: Process repeats until good score or max iterations - -## Best Practices - -1. **Provide Complete Context**: Ensure grounding context contains all information needed to answer the question -2. **Clear Instructions**: Give the agent clear instructions about staying grounded in context -3. **Use Quality Models**: GPT-4o recommended for evaluation tasks -4. **Multiple Evaluators**: Use combination of evaluators (groundedness + relevance + coherence) -5. **Batch Processing**: For production, process multiple questions in batch - -## Related Resources - -- [Reflexion Paper (NeurIPS 2023)](https://arxiv.org/abs/2303.11366) -- [Microsoft.Extensions.AI.Evaluation Libraries](https://learn.microsoft.com/dotnet/ai/evaluation/libraries) -- [GroundednessEvaluator API Reference](https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.evaluation.quality.groundednessevaluator) -- [Azure AI Foundry Evaluation Service](https://learn.microsoft.com/azure/ai-foundry/how-to/develop/evaluate-sdk) - -## Next Steps - -After running self-reflection evaluation: -1. Implement similar patterns for other quality metrics (relevance, coherence, fluency) -2. Integrate into CI/CD pipeline for continuous quality assurance -3. Explore the Safety Evaluation sample (FoundryAgents_Evaluations_Step01_RedTeaming) for content safety assessment diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs deleted file mode 100644 index f4521d8898..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to create and use AI agents with Azure Foundry Agents as the backend. - -using Azure.AI.Projects; -using Azure.AI.Projects.Agents; -using Azure.Identity; -using Microsoft.Agents.AI; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -const string JokerName = "JokerAgent"; - -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Define the agent you want to create. (Prompt Agent in this case) -AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = "You are good at telling jokes." }); - -// Azure.AI.Agents SDK creates and manages agent by name and versions. -// You can create a server side agent version with the Azure.AI.Agents SDK client below. -AgentVersion createdAgentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options); - -// Note: -// agentVersion.Id = ":", -// agentVersion.Version = , -// agentVersion.Name = - -// You can use an AIAgent with an already created server side agent version. -AIAgent existingJokerAgent = aiProjectClient.AsAIAgent(createdAgentVersion); - -// You can also create another AIAgent version by providing the same name with a different definition/instruction. -AIAgent newJokerAgent = await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: "You are extremely hilarious at telling jokes."); - -// You can also get the AIAgent latest version by just providing its name. -AIAgent jokerAgentLatest = await aiProjectClient.GetAIAgentAsync(name: JokerName); -AgentVersion latestAgentVersion = jokerAgentLatest.GetService()!; - -// The AIAgent version can be accessed via the GetService method. -Console.WriteLine($"Latest agent version id: {latestAgentVersion.Id}"); - -// Once you have the AIAgent, you can invoke it like any other AIAgent. -Console.WriteLine(await jokerAgentLatest.RunAsync("Tell me a joke about a pirate.")); - -// Cleanup by agent name removes both agent versions created. -await aiProjectClient.Agents.DeleteAgentAsync(existingJokerAgent.Name); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/README.md deleted file mode 100644 index ce5eca8277..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Creating and Managing AI Agents with Versioning - -This sample demonstrates how to create and manage AI agents with Azure Foundry Agents, including: -- Creating agents with different versions -- Retrieving agents by version or latest version -- Running multi-turn conversations with agents -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step01.1_Basics -``` - -## What this sample demonstrates - -1. **Creating agents with versions**: Shows how to create multiple versions of the same agent with different instructions -2. **Retrieving agents**: Demonstrates retrieving agents by specific version or getting the latest version -3. **Multi-turn conversations**: Shows how to use threads to maintain conversation context across multiple agent runs -4. **Agent cleanup**: Demonstrates proper resource cleanup by deleting agents diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs deleted file mode 100644 index 0bc17aff0a..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to create and use a simple AI agent with Azure Foundry Agents as the backend. - -using Azure.AI.Projects; -using Azure.AI.Projects.Agents; -using Azure.Identity; -using Microsoft.Agents.AI; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -const string JokerInstructions = "You are good at telling jokes."; -const string JokerName = "JokerAgent"; - -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Define the agent you want to create. (Prompt Agent in this case) -AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructions }); - -// Azure.AI.Agents SDK creates and manages agent by name and versions. -// You can create a server side agent version with the Azure.AI.Agents SDK client below. -AgentVersion agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options); - -// You can use an AIAgent with an already created server side agent version. -AIAgent jokerAgent = aiProjectClient.AsAIAgent(agentVersion); - -// Invoke the agent with streaming support. -await foreach (AgentResponseUpdate update in jokerAgent.RunStreamingAsync("Tell me a joke about a pirate.")) -{ - Console.WriteLine(update); -} - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(jokerAgent.Name); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/README.md deleted file mode 100644 index 40cb5e107d..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# Running a Simple AI Agent with Streaming - -This sample demonstrates how to create and run a simple AI agent with Azure Foundry Agents, including both text and streaming responses. - -## What this sample demonstrates - -- Creating a simple AI agent with instructions -- Running an agent with text output -- Running an agent with streaming output -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step01.2_Running -``` - -## Expected behavior - -The sample will: - -1. Create an agent named "JokerAgent" with instructions to tell jokes -2. Run the agent with a text prompt and display the response -3. Run the agent again with streaming to display the response as it's generated -4. Clean up resources by deleting the agent - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs deleted file mode 100644 index 7bf12094fc..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to create and use a simple AI agent with a multi-turn conversation. - -using Azure.AI.Extensions.OpenAI; -using Azure.AI.Projects; -using Azure.AI.Projects.Agents; -using Azure.Identity; -using Microsoft.Agents.AI; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -const string JokerInstructions = "You are good at telling jokes."; -const string JokerName = "JokerAgent"; - -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Define the agent you want to create. (Prompt Agent in this case) -AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructions }); - -// Retrieve an AIAgent for the created server side agent version. -ChatClientAgent jokerAgent = await aiProjectClient.CreateAIAgentAsync(name: JokerName, options); - -// Invoke the agent with a multi-turn conversation, where the context is preserved in the session object. -// Create a conversation in the server -ProjectConversationsClient conversationsClient = aiProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient(); -ProjectConversation conversation = await conversationsClient.CreateProjectConversationAsync(); - -// Providing the conversation Id is not strictly necessary, but by not providing it no information will show up in the Foundry Project UI as conversations. -// Sessions that don't have a conversation Id will work based on the `PreviousResponseId`. -AgentSession session = await jokerAgent.CreateSessionAsync(conversation.Id); - -Console.WriteLine(await jokerAgent.RunAsync("Tell me a joke about a pirate.", session)); -Console.WriteLine(await jokerAgent.RunAsync("Now add some emojis to the joke and tell it in the voice of a pirate's parrot.", session)); - -// Invoke the agent with a multi-turn conversation and streaming, where the context is preserved in the session object. -session = await jokerAgent.CreateSessionAsync(conversation.Id); -await foreach (AgentResponseUpdate update in jokerAgent.RunStreamingAsync("Tell me a joke about a pirate.", session)) -{ - Console.WriteLine(update); -} -await foreach (AgentResponseUpdate update in jokerAgent.RunStreamingAsync("Now add some emojis to the joke and tell it in the voice of a pirate's parrot.", session)) -{ - Console.WriteLine(update); -} - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(jokerAgent.Name); - -// Cleanup the conversation created. -await conversationsClient.DeleteConversationAsync(conversation.Id); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/README.md deleted file mode 100644 index 86721bf960..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Multi-turn Conversation with AI Agents - -This sample demonstrates how to implement multi-turn conversations with AI agents, where context is preserved across multiple agent runs using threads and conversation IDs. - -## What this sample demonstrates - -- Creating an AI agent with instructions -- Creating a project conversation to track conversations in the Foundry UI -- Using threads with conversation IDs to maintain conversation context -- Running multi-turn conversations with text output -- Running multi-turn conversations with streaming output -- Managing agent and conversation lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step02_MultiturnConversation -``` - -## Expected behavior - -The sample will: - -1. Create an agent named "JokerAgent" with instructions to tell jokes -2. Create a project conversation to enable visibility in the Azure Foundry UI -3. Create a thread linked to the conversation ID for context tracking -4. Run the agent with a text prompt and display the response -5. Send a follow-up message to the same thread, demonstrating context preservation -6. Create a new thread sharing the same conversation ID and run the agent with streaming -7. Send a follow-up streaming message to demonstrate multi-turn streaming -8. Clean up resources by deleting the agent and conversation - -## Conversation ID vs PreviousResponseId - -When working with multi-turn conversations, there are two approaches: - -- **With Conversation ID**: By passing a `conversation.Id` to `CreateSessionAsync()`, the conversation will be visible in the Azure Foundry Project UI. This is useful for tracking and debugging conversations. -- **Without Conversation ID**: Sessions created without a conversation ID still work correctly, maintaining context via `PreviousResponseId`. However, these conversations may not appear in the Foundry UI. - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/Program.cs deleted file mode 100644 index cfd74000a6..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/Program.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates how to use an agent with function tools. -// It shows both non-streaming and streaming agent interactions using weather-related tools. - -using System.ComponentModel; -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -[Description("Get the weather for a given location.")] -static string GetWeather([Description("The location to get the weather for.")] string location) - => $"The weather in {location} is cloudy with a high of 15°C."; - -const string AssistantInstructions = "You are a helpful assistant that can get weather information."; -const string AssistantName = "WeatherAssistant"; - -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Define the agent with function tools. -AITool tool = AIFunctionFactory.Create(GetWeather); - -// Create AIAgent directly -var newAgent = await aiProjectClient.CreateAIAgentAsync(name: AssistantName, model: deploymentName, instructions: AssistantInstructions, tools: [tool]); - -// Getting an already existing agent by name with tools. -/* - * IMPORTANT: Since agents that are stored in the server only know the definition of the function tools (JSON Schema), - * you need to provided all invocable function tools when retrieving the agent so it can invoke them automatically. - * If no invocable tools are provided, the function calling needs to handled manually. - */ -var existingAgent = await aiProjectClient.GetAIAgentAsync(name: AssistantName, tools: [tool]); - -// Non-streaming agent interaction with function tools. -AgentSession session = await existingAgent.CreateSessionAsync(); -Console.WriteLine(await existingAgent.RunAsync("What is the weather like in Amsterdam?", session)); - -// Streaming agent interaction with function tools. -session = await existingAgent.CreateSessionAsync(); -await foreach (AgentResponseUpdate update in existingAgent.RunStreamingAsync("What is the weather like in Amsterdam?", session)) -{ - Console.WriteLine(update); -} - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(existingAgent.Name); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/README.md deleted file mode 100644 index fa9b5baf21..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Using Function Tools with AI Agents - -This sample demonstrates how to use function tools with AI agents, allowing agents to call custom functions to retrieve information. - -## What this sample demonstrates - -- Creating function tools using AIFunctionFactory -- Passing function tools to an AI agent -- Running agents with function tools (text output) -- Running agents with function tools (streaming output) -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step03.1_UsingFunctionTools -``` - -## Expected behavior - -The sample will: - -1. Create an agent named "WeatherAssistant" with a GetWeather function tool -2. Run the agent with a text prompt asking about weather -3. The agent will invoke the GetWeather function tool to retrieve weather information -4. Run the agent again with streaming to display the response as it's generated -5. Clean up resources by deleting the agent - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/README.md deleted file mode 100644 index 42cbd6ba32..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# Using Function Tools with Approvals (Human-in-the-Loop) - -This sample demonstrates how to use function tools that require human approval before execution, implementing a human-in-the-loop workflow. - -## What this sample demonstrates - -- Creating approval-required function tools using ApprovalRequiredAIFunction -- Handling user input requests for function approvals -- Implementing human-in-the-loop approval workflows -- Processing agent responses with pending approvals -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step04_UsingFunctionToolsWithApprovals -``` - -## Expected behavior - -The sample will: - -1. Create an agent named "WeatherAssistant" with an approval-required GetWeather function tool -2. Run the agent with a prompt asking about weather -3. The agent will request approval before invoking the GetWeather function -4. The sample will prompt the user to approve or deny the function call (enter 'Y' to approve) -5. After approval, the function will be executed and the result returned to the agent -6. Clean up resources by deleting the agent - -**Note**: For hosted agents with remote users, combine this sample with the Persisted Conversations sample to persist chat history while waiting for user approval. - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj deleted file mode 100644 index daf7e24494..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/README.md deleted file mode 100644 index 4c44230e18..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step05_StructuredOutput/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Structured Output with AI Agents - -This sample demonstrates how to configure AI agents to produce structured output in JSON format using JSON schemas. - -## What this sample demonstrates - -- Configuring agents with JSON schema response formats -- Using generic RunAsync method for structured output -- Deserializing structured responses into typed objects -- Running agents with streaming and structured output -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step05_StructuredOutput -``` - -## Expected behavior - -The sample will: - -1. Create an agent named "StructuredOutputAssistant" configured to produce JSON output -2. Run the agent with a prompt to extract person information -3. Deserialize the JSON response into a PersonInfo object -4. Display the structured data (Name, Age, Occupation) -5. Run the agent again with streaming and deserialize the streamed JSON response -6. Clean up resources by deleting the agent - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj deleted file mode 100644 index daf7e24494..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/README.md deleted file mode 100644 index 57a032e9ec..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step06_PersistedConversations/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Persisted Conversations with AI Agents - -This sample demonstrates how to serialize and persist agent conversation threads to storage, allowing conversations to be resumed later. - -## What this sample demonstrates - -- Serializing agent threads to JSON -- Persisting thread state to disk -- Loading and deserializing thread state from storage -- Resuming conversations with persisted threads -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step06_PersistedConversations -``` - -## Expected behavior - -The sample will: - -1. Create an agent named "JokerAgent" with instructions to tell jokes -2. Create a thread and run the agent with an initial prompt -3. Serialize the thread state to JSON -4. Save the serialized thread to a temporary file -5. Load the thread from the file and deserialize it -6. Resume the conversation with the same thread using a follow-up prompt -7. Clean up resources by deleting the agent - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/README.md deleted file mode 100644 index 459434bce2..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step07_Observability/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# Observability with OpenTelemetry - -This sample demonstrates how to add observability to AI agents using OpenTelemetry for tracing and monitoring. - -## What this sample demonstrates - -- Setting up OpenTelemetry TracerProvider -- Configuring console exporter for telemetry output -- Configuring Azure Monitor exporter for Application Insights -- Adding OpenTelemetry middleware to agents -- Running agents with telemetry collection (text and streaming) -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) -- (Optional) Application Insights connection string for Azure Monitor integration - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -$env:APPLICATIONINSIGHTS_CONNECTION_STRING="your-connection-string" # Optional, for Azure Monitor integration -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step07_Observability -``` - -## Expected behavior - -The sample will: - -1. Create a TracerProvider with console exporter (and optionally Azure Monitor exporter) -2. Create an agent named "JokerAgent" with OpenTelemetry middleware -3. Run the agent with a text prompt and display telemetry traces to console -4. Run the agent again with streaming and display telemetry traces -5. Clean up resources by deleting the agent - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/Program.cs deleted file mode 100644 index b7a9874e7b..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/Program.cs +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to use dependency injection to register an AIAgent and use it from a hosted service with a user input chat loop. - -using System.ClientModel; -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -const string JokerInstructions = "You are good at telling jokes."; -const string JokerName = "JokerAgent"; - -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AIProjectClient aIProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Create a new agent if one doesn't exist already. -ChatClientAgent agent; -try -{ - agent = await aIProjectClient.GetAIAgentAsync(name: JokerName); -} -catch (ClientResultException ex) when (ex.Status == 404) -{ - agent = await aIProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: JokerInstructions); -} - -// Create a host builder that we will register services with and then run. -HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); - -// Add the agents client to the service collection. -builder.Services.AddSingleton((sp) => aIProjectClient); - -// Add the AI agent to the service collection. -builder.Services.AddSingleton((sp) => agent); - -// Add a sample service that will use the agent to respond to user input. -builder.Services.AddHostedService(); - -// Build and run the host. -using IHost host = builder.Build(); -await host.RunAsync().ConfigureAwait(false); - -/// -/// A sample service that uses an AI agent to respond to user input. -/// -internal sealed class SampleService(AIProjectClient client, AIAgent agent, IHostApplicationLifetime appLifetime) : IHostedService -{ - private AgentSession? _session; - - public async Task StartAsync(CancellationToken cancellationToken) - { - // Create a session that will be used for the entirety of the service lifetime so that the user can ask follow up questions. - this._session = await agent.CreateSessionAsync(cancellationToken); - _ = this.RunAsync(appLifetime.ApplicationStopping); - } - - public async Task RunAsync(CancellationToken cancellationToken) - { - // Delay a little to allow the service to finish starting. - await Task.Delay(100, cancellationToken); - - while (!cancellationToken.IsCancellationRequested) - { - Console.WriteLine("\nAgent: Ask me to tell you a joke about a specific topic. To exit just press Ctrl+C or enter without any input.\n"); - Console.Write("> "); - string? input = Console.ReadLine(); - - // If the user enters no input, signal the application to shut down. - if (string.IsNullOrWhiteSpace(input)) - { - appLifetime.StopApplication(); - break; - } - - // Stream the output to the console as it is generated. - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(input, this._session, cancellationToken: cancellationToken)) - { - Console.Write(update); - } - - Console.WriteLine(); - } - } - - public async Task StopAsync(CancellationToken cancellationToken) - { - Console.WriteLine("\nDeleting agent ..."); - await client.Agents.DeleteAgentAsync(agent.Name, cancellationToken).ConfigureAwait(false); - } -} diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/README.md deleted file mode 100644 index 12760e736f..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step08_DependencyInjection/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# Dependency Injection with AI Agents - -This sample demonstrates how to use dependency injection to register and manage AI agents within a hosted service application. - -## What this sample demonstrates - -- Setting up dependency injection with HostApplicationBuilder -- Registering AIProjectClient as a singleton service -- Registering AIAgent as a singleton service -- Using agents in hosted services -- Interactive chat loop with streaming responses -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step08_DependencyInjection -``` - -## Expected behavior - -The sample will: - -1. Create a host with dependency injection configured -2. Register AIProjectClient and AIAgent as services -3. Create an agent named "JokerAgent" with instructions to tell jokes -4. Start an interactive chat loop where you can ask the agent questions -5. The agent will respond with streaming output -6. Enter an empty line or press Ctrl+C to exit -7. Clean up resources by deleting the agent - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/FoundryAgents_Step09_UsingMcpClientAsTools.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/FoundryAgents_Step09_UsingMcpClientAsTools.csproj deleted file mode 100644 index a6d96cb3db..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/FoundryAgents_Step09_UsingMcpClientAsTools.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - 3afc9b74-af74-4d8e-ae96-fa1c511d11ac - - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/Program.cs deleted file mode 100644 index e1968122a4..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/Program.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to expose an AI agent as an MCP tool. - -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using ModelContextProtocol.Client; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -Console.WriteLine("Starting MCP Stdio for @modelcontextprotocol/server-github ... "); - -// Create an MCPClient for the GitHub server -await using var mcpClient = await McpClient.CreateAsync(new StdioClientTransport(new() -{ - Name = "MCPServer", - Command = "npx", - Arguments = ["-y", "--verbose", "@modelcontextprotocol/server-github"], -})); - -// Retrieve the list of tools available on the GitHub server -IList mcpTools = await mcpClient.ListToolsAsync(); -string agentName = "AgentWithMCP"; -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -Console.WriteLine($"Creating the agent '{agentName}' ..."); - -// Define the agent you want to create. (Prompt Agent in this case) -AIAgent agent = await aiProjectClient.CreateAIAgentAsync( - name: agentName, - model: deploymentName, - instructions: "You answer questions related to GitHub repositories only.", - tools: [.. mcpTools.Cast()]); - -string prompt = "Summarize the last four commits to the microsoft/semantic-kernel repository?"; - -Console.WriteLine($"Invoking agent '{agent.Name}' with prompt: {prompt} ..."); - -// Invoke the agent and output the text result. -Console.WriteLine(await agent.RunAsync(prompt)); - -// Clean up the agent after use. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/README.md deleted file mode 100644 index e4e3fe537a..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step09_UsingMcpClientAsTools/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Using MCP Client Tools with AI Agents - -This sample demonstrates how to use Model Context Protocol (MCP) client tools with AI agents, allowing agents to access tools provided by MCP servers. This sample uses the GitHub MCP server to provide tools for querying GitHub repositories. - -## What this sample demonstrates - -- Creating MCP clients to connect to MCP servers (GitHub server) -- Retrieving tools from MCP servers -- Using MCP tools with AI agents -- Running agents with MCP-provided function tools -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) -- Node.js and npm installed (for running the GitHub MCP server) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step09_UsingMcpClientAsTools -``` - -## Expected behavior - -The sample will: - -1. Start the GitHub MCP server using `@modelcontextprotocol/server-github` -2. Create an MCP client to connect to the GitHub server -3. Retrieve the available tools from the GitHub MCP server -4. Create an agent named "AgentWithMCP" with the GitHub tools -5. Run the agent with a prompt to summarize the last four commits to the microsoft/semantic-kernel repository -6. The agent will use the GitHub MCP tools to query the repository information -7. Clean up resources by deleting the agent \ No newline at end of file diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/FoundryAgents_Step10_UsingImages.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/FoundryAgents_Step10_UsingImages.csproj deleted file mode 100644 index 53661ff199..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/FoundryAgents_Step10_UsingImages.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - - - - - - - - - - - - - - Always - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/README.md deleted file mode 100644 index 220104a291..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# Using Images with AI Agents - -This sample demonstrates how to use image multi-modality with an AI agent. It shows how to create a vision-enabled agent that can analyze and describe images using Azure Foundry Agents. - -## What this sample demonstrates - -- Creating a vision-enabled AI agent with image analysis capabilities -- Sending both text and image content to an agent in a single message -- Using `UriContent` for URI-referenced images -- Processing multimodal input (text + image) with an AI agent -- Managing agent lifecycle (creation and deletion) - -## Key features - -- **Vision Agent**: Creates an agent specifically instructed to analyze images -- **Multimodal Input**: Combines text questions with image URI in a single message -- **Azure Foundry Agents Integration**: Uses Azure Foundry Agents with vision capabilities - -## Prerequisites - -Before running this sample, ensure you have: - -1. An Azure OpenAI project set up -2. A compatible model deployment (e.g., gpt-4o) -3. Azure CLI installed and authenticated - -## Environment Variables - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure Foundry Project endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o" # Replace with your model deployment name (optional, defaults to gpt-4o) -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step10_UsingImages -``` - -## Expected behavior - -The sample will: - -1. Create a vision-enabled agent named "VisionAgent" -2. Send a message containing both text ("What do you see in this image?") and a URI-referenced image of a green walkway (nature boardwalk) -3. The agent will analyze the image and provide a description -4. Clean up resources by deleting the agent - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/FoundryAgents_Step11_AsFunctionTool.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/FoundryAgents_Step11_AsFunctionTool.csproj deleted file mode 100644 index 54f37f1aa6..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/FoundryAgents_Step11_AsFunctionTool.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - 3afc9b74-af74-4d8e-ae96-fa1c511d11ac - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/README.md deleted file mode 100644 index 5da59b6edb..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step11_AsFunctionTool/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Using AI Agents as Function Tools (Nested Agents) - -This sample demonstrates how to expose an AI agent as a function tool, enabling nested agent scenarios where one agent can invoke another agent as a tool. - -## What this sample demonstrates - -- Creating an AI agent that can be used as a function tool -- Wrapping an agent as an AIFunction -- Using nested agents where one agent calls another -- Managing multiple agent instances -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step11_AsFunctionTool -``` - -## Expected behavior - -The sample will: - -1. Create a "JokerAgent" that tells jokes -2. Wrap the JokerAgent as a function tool -3. Create a "CoordinatorAgent" that has the JokerAgent as a function tool -4. Run the CoordinatorAgent with a prompt that triggers it to call the JokerAgent -5. The CoordinatorAgent will invoke the JokerAgent as a function tool -6. Clean up resources by deleting both agents - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/README.md deleted file mode 100644 index 96d12d9828..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# Agent Middleware - -This sample demonstrates how to add middleware to intercept agent runs and function calls to implement cross-cutting concerns like logging, validation, and guardrails. - -## What This Sample Shows - -1. Azure Foundry Agents integration via `AIProjectClient` and `DefaultAzureCredential` -2. Agent run middleware (logging and monitoring) -3. Function invocation middleware (logging and overriding tool results) -4. Per-request agent run middleware -5. Per-request function pipeline with approval -6. Combining agent-level and per-request middleware - -## Function Invocation Middleware - -Not all agents support function invocation middleware. - -Attempting to use function middleware on agents that do not wrap a ChatClientAgent or derives from it will throw an InvalidOperationException. - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Running the Sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step12_Middleware -``` - -## Expected Behavior - -When you run this sample, you will see the following demonstrations: - -1. **Example 1: Wording Guardrail** - The agent receives a request for harmful content. The guardrail middleware intercepts the request and prevents the agent from responding to harmful prompts, returning a safe response instead. - -2. **Example 2: PII Detection** - The agent receives a message containing personally identifiable information (name, phone number, email). The PII middleware detects and filters this sensitive information before processing. - -3. **Example 3: Agent Function Middleware** - The agent uses function tools (GetDateTime and GetWeather) to answer a question about the current time and weather in Seattle. The function middleware logs the function calls and can override results if needed. - -4. **Example 4: Human-in-the-Loop Function Approval** - The agent attempts to call a weather function, but the approval middleware intercepts the call and prompts the user to approve or deny the function invocation before it executes. The user can respond with "Y" to approve or any other input to deny. - -Each example demonstrates how middleware can be used to implement cross-cutting concerns and control agent behavior at different levels (agent-level and per-request). diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj deleted file mode 100644 index 4a34560946..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);CA1812 - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/Program.cs deleted file mode 100644 index 244d83d632..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/Program.cs +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to use plugins with an AI agent. Plugin classes can -// depend on other services that need to be injected. In this sample, the -// AgentPlugin class uses the WeatherProvider and CurrentTimeProvider classes -// to get weather and current time information. Both services are registered -// in the service collection and injected into the plugin. -// Plugin classes may have many methods, but only some are intended to be used -// as AI functions. The AsAITools method of the plugin class shows how to specify -// which methods should be exposed to the AI agent. - -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -const string AssistantInstructions = "You are a helpful assistant that helps people find information."; -const string AssistantName = "PluginAssistant"; - -// Create a service collection to hold the agent plugin and its dependencies. -ServiceCollection services = new(); -services.AddSingleton(); -services.AddSingleton(); -services.AddSingleton(); // The plugin depends on WeatherProvider and CurrentTimeProvider registered above. - -IServiceProvider serviceProvider = services.BuildServiceProvider(); - -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Define the agent with plugin tools -// Define the agent you want to create. (Prompt Agent in this case) -AIAgent agent = await aiProjectClient.CreateAIAgentAsync( - name: AssistantName, - model: deploymentName, - instructions: AssistantInstructions, - tools: serviceProvider.GetRequiredService().AsAITools().ToList(), - services: serviceProvider); - -// Invoke the agent and output the text result. -AgentSession session = await agent.CreateSessionAsync(); -Console.WriteLine(await agent.RunAsync("Tell me current time and weather in Seattle.", session)); - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); - -/// -/// The agent plugin that provides weather and current time information. -/// -/// The weather provider to get weather information. -internal sealed class AgentPlugin(WeatherProvider weatherProvider) -{ - /// - /// Gets the weather information for the specified location. - /// - /// - /// This method demonstrates how to use the dependency that was injected into the plugin class. - /// - /// The location to get the weather for. - /// The weather information for the specified location. - public string GetWeather(string location) - { - return weatherProvider.GetWeather(location); - } - - /// - /// Gets the current date and time for the specified location. - /// - /// - /// This method demonstrates how to resolve a dependency using the service provider passed to the method. - /// - /// The service provider to resolve the . - /// The location to get the current time for. - /// The current date and time as a . - public DateTimeOffset GetCurrentTime(IServiceProvider sp, string location) - { - // Resolve the CurrentTimeProvider from the service provider - CurrentTimeProvider currentTimeProvider = sp.GetRequiredService(); - - return currentTimeProvider.GetCurrentTime(location); - } - - /// - /// Returns the functions provided by this plugin. - /// - /// - /// In real world scenarios, a class may have many methods and only a subset of them may be intended to be exposed as AI functions. - /// This method demonstrates how to explicitly specify which methods should be exposed to the AI agent. - /// - /// The functions provided by this plugin. - public IEnumerable AsAITools() - { - yield return AIFunctionFactory.Create(this.GetWeather); - yield return AIFunctionFactory.Create(this.GetCurrentTime); - } -} - -/// -/// The weather provider that returns weather information. -/// -internal sealed class WeatherProvider -{ - /// - /// Gets the weather information for the specified location. - /// - /// - /// The weather information is hardcoded for demonstration purposes. - /// In a real application, this could call a weather API to get actual weather data. - /// - /// The location to get the weather for. - /// The weather information for the specified location. - public string GetWeather(string location) - { - return $"The weather in {location} is cloudy with a high of 15°C."; - } -} - -/// -/// Provides the current date and time. -/// -/// -/// This class returns the current date and time using the system's clock. -/// -internal sealed class CurrentTimeProvider -{ - /// - /// Gets the current date and time. - /// - /// The location to get the current time for (not used in this implementation). - /// The current date and time as a . - public DateTimeOffset GetCurrentTime(string location) - { - return DateTimeOffset.Now; - } -} diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/README.md deleted file mode 100644 index 5c52ffcd1c..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step13_Plugins/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Using Plugins with AI Agents - -This sample demonstrates how to use plugins with AI agents, where plugins are services registered in dependency injection that expose methods as AI function tools. - -## What this sample demonstrates - -- Creating plugin services with methods to expose as tools -- Using AsAITools() to selectively expose plugin methods -- Registering plugins in dependency injection -- Using plugins with AI agents -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step13_Plugins -``` - -## Expected behavior - -The sample will: - -1. Create a plugin service with methods to expose as tools -2. Register the plugin in dependency injection -3. Create an agent named "PluginAgent" with the plugin methods as function tools -4. Run the agent with a prompt that triggers it to call plugin methods -5. The agent will invoke the plugin methods to retrieve information -6. Clean up resources by deleting the agent - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj deleted file mode 100644 index 4a34560946..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);CA1812 - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/README.md deleted file mode 100644 index 34fa18c94c..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# Using Code Interpreter with AI Agents - -This sample demonstrates how to use the code interpreter tool with AI agents. The code interpreter allows agents to write and execute Python code to solve problems, perform calculations, and analyze data. - -## What this sample demonstrates - -- Creating agents with code interpreter capabilities -- Using HostedCodeInterpreterTool (MEAI abstraction) -- Using native SDK code interpreter tools (ResponseTool.CreateCodeInterpreterTool) -- Extracting code inputs and results from agent responses -- Handling code interpreter annotations -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step14_CodeInterpreter -``` - -## Expected behavior - -The sample will: - -1. Create two agents with code interpreter capabilities: - - Option 1: Using HostedCodeInterpreterTool (MEAI abstraction) - - Option 2: Using native SDK code interpreter tools -2. Run the agent with a mathematical problem: "I need to solve the equation sin(x) + x^2 = 42" -3. The agent will use the code interpreter to write and execute Python code to solve the equation -4. Extract and display the code that was executed -5. Display the results from the code execution -6. Display any annotations generated by the code interpreter tool -7. Clean up resources by deleting both agents - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/README.md deleted file mode 100644 index 092f2bd1cf..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Using Computer Use Tool with AI Agents - -This sample demonstrates how to use the computer use tool with AI agents. The computer use tool allows agents to interact with a computer environment by viewing the screen, controlling the mouse and keyboard, and performing various actions to help complete tasks. - -> [!NOTE] -> **Azure Agents API vs. vanilla OpenAI Responses API behavior:** -> The Azure Agents API rejects requests that include `previous_response_id` alongside -> `computer_call_output` items — unlike the vanilla OpenAI Responses API, which accepts them. -> This sample works around the limitation by creating a **fresh session for each follow-up call** -> (so no `previous_response_id` is carried over) and re-sending all prior response output items -> (reasoning, computer_call, etc.) as input items to preserve full conversation context. -> Additionally, the sample uses the **current** `CallId` from each computer call response -> (not the initial one) and clears the `ContinuationToken` after polling completes to prevent -> stale tokens from affecting subsequent requests. - -## What this sample demonstrates - -- Creating agents with computer use capabilities -- Using HostedComputerTool (MEAI abstraction) -- Using native SDK computer use tools (ResponseTool.CreateComputerTool) -- Extracting computer action information from agent responses -- Handling computer tool results (text output and screenshots) -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="computer-use-preview" # Optional, defaults to computer-use-preview -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step15_ComputerUse -``` - -## Expected behavior - -The sample will: - -1. Create two agents with computer use capabilities: - - Option 1: Using HostedComputerTool (MEAI abstraction) - - Option 2: Using native SDK computer use tools -2. Run the agent with a task: "I need you to help me search for 'OpenAI news'. Please type 'OpenAI news' and submit the search. Once you see search results, the task is complete." -3. The agent will use the computer use tool to: - - Interpret the screenshots - - Issue action requests based on the task - - Analyze the search results for "OpenAI news" from the screenshots. -4. Extract and display the computer actions performed -5. Display the results from the computer tool execution -6. Display the final response from the agent -7. Clean up resources by deleting both agents diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/README.md deleted file mode 100644 index db74868d3d..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Using File Search with AI Agents - -This sample demonstrates how to use the file search tool with AI agents. The file search tool allows agents to search through uploaded files stored in vector stores to answer user questions. - -## What this sample demonstrates - -- Uploading files and creating vector stores -- Creating agents with file search capabilities -- Using HostedFileSearchTool (MEAI abstraction) -- Using native SDK file search tools (ResponseTool.CreateFileSearchTool) -- Handling file citation annotations -- Managing agent and resource lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses `DefaultAzureCredential` for authentication. For local development, make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure Identity documentation](https://learn.microsoft.com/dotnet/api/azure.identity.defaultazurecredential). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step16_FileSearch -``` - -## Expected behavior - -The sample will: - -1. Create a temporary text file with employee directory information -2. Upload the file to Azure Foundry -3. Create a vector store with the uploaded file -4. Create an agent with file search capabilities using one of: - - Option 1: Using HostedFileSearchTool (MEAI abstraction) - - Option 2: Using native SDK file search tools -5. Run a query against the agent to search through the uploaded file -6. Display file citation annotations from responses -7. Clean up resources (agent, vector store, and uploaded file) diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/FoundryAgents_Step17_OpenAPITools.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/FoundryAgents_Step17_OpenAPITools.csproj deleted file mode 100644 index 77b76acfa0..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/FoundryAgents_Step17_OpenAPITools.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);CA1812;CS8321 - - - - - - - - - - - - - \ No newline at end of file diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/README.md deleted file mode 100644 index a859f6b963..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Using OpenAPI Tools with AI Agents - -This sample demonstrates how to use OpenAPI tools with AI agents. OpenAPI tools allow agents to call external REST APIs defined by OpenAPI specifications. - -## What this sample demonstrates - -- Creating agents with OpenAPI tool capabilities -- Using AgentTool.CreateOpenApiTool with an embedded OpenAPI specification -- Anonymous authentication for public APIs -- Running an agent that can call external REST APIs -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses `DefaultAzureCredential` for authentication, which supports multiple authentication methods including Azure CLI, managed identity, and more. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure Identity documentation](https://learn.microsoft.com/dotnet/api/azure.identity.defaultazurecredential). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step17_OpenAPITools -``` - -## Expected behavior - -The sample will: - -1. Create an agent with an OpenAPI tool configured to call the REST Countries API -2. Ask the agent: "What countries use the Euro (EUR) as their currency?" -3. The agent will use the OpenAPI tool to call the REST Countries API -4. Display the response containing the list of countries that use EUR -5. Clean up resources by deleting the agent \ No newline at end of file diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/FoundryAgents_Step18_BingCustomSearch.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/FoundryAgents_Step18_BingCustomSearch.csproj deleted file mode 100644 index 730d284bd9..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/FoundryAgents_Step18_BingCustomSearch.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);CA1812;CS8321 - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/README.md deleted file mode 100644 index ccc1873a04..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# Using Bing Custom Search with AI Agents - -This sample demonstrates how to use the Bing Custom Search tool with AI agents to perform customized web searches. - -## What this sample demonstrates - -- Creating agents with Bing Custom Search capabilities -- Configuring custom search instances via connection ID and instance name -- Two agent creation approaches: MEAI abstraction (Option 1) and Native SDK (Option 2) -- Running search queries through the agent -- Managing agent lifecycle (creation and deletion) - -## Agent creation options - -This sample provides two approaches for creating agents with Bing Custom Search: - -- **Option 1 - MEAI + AgentFramework**: Uses the Agent Framework `ResponseTool` wrapped with `AsAITool()` to call the `CreateAIAgentAsync` overload that accepts `tools:[]`, while still relying on the same underlying Azure AI Projects SDK types as Option 2. -- **Option 2 - Native SDK**: Uses `PromptAgentDefinition` with `AgentVersionCreationOptions` to create the agent directly with the Azure AI Projects SDK types. - -Both options produce the same result. Toggle between them by commenting/uncommenting the corresponding `CreateAgentWith*Async` call in `Program.cs`. - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) -- A Bing Custom Search resource configured in Azure and connected to your Foundry project - -**Note**: This demo uses Azure Default credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. - -Set the following environment variables: - -```powershell -$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -$env:BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID="/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/" -$env:BING_CUSTOM_SEARCH_INSTANCE_NAME="your-configuration-name" -``` - -### Finding the connection ID and instance name - -- **Connection ID**: The full ARM resource path including the `/projects//connections/` segment. Find the connection name in your Foundry project under **Management center** → **Connected resources**. -- **Instance Name**: The **configuration name** from the Bing Custom Search resource (Azure portal → your Bing Custom Search resource → **Configurations**). This is _not_ the Azure resource name. - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step18_BingCustomSearch -``` - -## Expected behavior - -The sample will: - -1. Create an agent with Bing Custom Search tool capabilities -2. Run the agent with a search query about Microsoft AI -3. Display the search results returned by the agent -4. Clean up resources by deleting the agent diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/FoundryAgents_Step19_SharePoint.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/FoundryAgents_Step19_SharePoint.csproj deleted file mode 100644 index 4d17fe06bb..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/FoundryAgents_Step19_SharePoint.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);CA1812;CS8321 - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/README.md deleted file mode 100644 index ccbd699011..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Using SharePoint Grounding with AI Agents - -This sample demonstrates how to use the SharePoint grounding tool with AI agents. The SharePoint grounding tool enables agents to search and retrieve information from SharePoint sites. - -## What this sample demonstrates - -- Creating agents with SharePoint grounding capabilities -- Using AgentTool.CreateSharepointTool (MEAI abstraction) -- Using native SDK SharePoint tools (PromptAgentDefinition) -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure authentication configured for `DefaultAzureCredential` (for example, Azure CLI logged in with `az login`, environment variables, managed identity, or IDE sign-in) -- A SharePoint project connection configured in Azure Foundry - -**Note**: This demo uses `DefaultAzureCredential` for authentication. This credential will try multiple authentication mechanisms in order (such as environment variables, managed identity, Azure CLI login, and IDE sign-in) and use the first one that works. A common option for local development is to sign in with the Azure CLI using `az login` and ensure you have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively) and the [DefaultAzureCredential documentation](https://learn.microsoft.com/dotnet/api/azure.identity.defaultazurecredential). - -Set the following environment variables: - -```powershell -$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -$env:SHAREPOINT_PROJECT_CONNECTION_ID="your-sharepoint-connection-id" # Required: SharePoint project connection ID -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step19_SharePoint -``` - -## Expected behavior - -The sample will: - -1. Create two agents with SharePoint grounding capabilities: - - Option 1: Using AgentTool.CreateSharepointTool (MEAI abstraction) - - Option 2: Using native SDK SharePoint tools -2. Run the agent with a query: "List the documents available in SharePoint" -3. The agent will use SharePoint grounding to search and retrieve relevant documents -4. Display the response and any grounding annotations -5. Clean up resources by deleting both agents diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/FoundryAgents_Step20_MicrosoftFabric.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/FoundryAgents_Step20_MicrosoftFabric.csproj deleted file mode 100644 index 4d17fe06bb..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/FoundryAgents_Step20_MicrosoftFabric.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);CA1812;CS8321 - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/Program.cs deleted file mode 100644 index e5ab205f68..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/Program.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to use Microsoft Fabric Tool with AI Agents. - -using Azure.AI.Projects; -using Azure.AI.Projects.Agents; -using Azure.Identity; -using Microsoft.Agents.AI; -using OpenAI.Responses; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -string fabricConnectionId = Environment.GetEnvironmentVariable("FABRIC_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("FABRIC_PROJECT_CONNECTION_ID is not set."); - -const string AgentInstructions = "You are a helpful assistant with access to Microsoft Fabric data. Answer questions based on data available through your Fabric connection."; - -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Configure Microsoft Fabric tool options with project connection -var fabricToolOptions = new FabricDataAgentToolOptions(); -fabricToolOptions.ProjectConnections.Add(new ToolProjectConnection(fabricConnectionId)); - -AIAgent agent = await CreateAgentWithMEAIAsync(); -// AIAgent agent = await CreateAgentWithNativeSDKAsync(); - -Console.WriteLine($"Created agent: {agent.Name}"); - -// Run the agent with a sample query -AgentResponse response = await agent.RunAsync("What data is available in the connected Fabric workspace?"); - -Console.WriteLine("\n=== Agent Response ==="); -foreach (var message in response.Messages) -{ - Console.WriteLine(message.Text); -} - -// Cleanup by deleting the agent -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); -Console.WriteLine($"\nDeleted agent: {agent.Name}"); - -// --- Agent Creation Options --- - -// Option 1 - Using AsAITool wrapping for the ResponseTool returned by AgentTool.CreateMicrosoftFabricTool (MEAI + AgentFramework) -async Task CreateAgentWithMEAIAsync() -{ - return await aiProjectClient.CreateAIAgentAsync( - model: deploymentName, - name: "FabricAgent-MEAI", - instructions: AgentInstructions, - tools: [((ResponseTool)AgentTool.CreateMicrosoftFabricTool(fabricToolOptions)).AsAITool()]); -} - -// Option 2 - Using PromptAgentDefinition with AgentTool.CreateMicrosoftFabricTool (Native SDK) -async Task CreateAgentWithNativeSDKAsync() -{ - return await aiProjectClient.CreateAIAgentAsync( - name: "FabricAgent-NATIVE", - creationOptions: new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = - { - AgentTool.CreateMicrosoftFabricTool(fabricToolOptions), - } - }) - ); -} diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/README.md deleted file mode 100644 index a5faf79d9d..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Using Microsoft Fabric Tool with AI Agents - -This sample demonstrates how to use the Microsoft Fabric tool with AI Agents, allowing agents to query and interact with data in Microsoft Fabric workspaces. - -## What this sample demonstrates - -- Creating agents with Microsoft Fabric data access capabilities -- Using FabricDataAgentToolOptions to configure Fabric connections -- Two agent creation approaches: MEAI abstraction (Option 1) and Native SDK (Option 2) -- Managing agent lifecycle (creation and deletion) - -## Agent creation options - -This sample provides two approaches for creating agents with Microsoft Fabric: - -- **Option 1 - MEAI + AgentFramework**: Uses the Agent Framework `ResponseTool` wrapped with `AsAITool()` to call the `CreateAIAgentAsync` overload that accepts `tools:[]`, while still relying on the same underlying Azure AI Projects SDK types as Option 2. -- **Option 2 - Native SDK**: Uses `PromptAgentDefinition` with `AgentVersionCreationOptions` to create the agent directly with the Azure AI Projects SDK types. - -Both options produce the same result. Toggle between them by commenting/uncommenting the corresponding `CreateAgentWith*Async` call in `Program.cs`. - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) -- A Microsoft Fabric workspace with a configured project connection in Azure Foundry - -**Note**: This demo uses Azure Default credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. - -Set the following environment variables: - -```powershell -$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -$env:FABRIC_PROJECT_CONNECTION_ID="your-fabric-connection-id" # The Fabric project connection ID from Azure Foundry -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step20_MicrosoftFabric -``` - -## Expected behavior - -The sample will: - -1. Create an agent with Microsoft Fabric tool capabilities -2. Configure the agent with a Fabric project connection -3. Run the agent with a query about available Fabric data -4. Display the agent's response -5. Clean up resources by deleting the agent diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/FoundryAgents_Step21_WebSearch.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/FoundryAgents_Step21_WebSearch.csproj deleted file mode 100644 index 4d17fe06bb..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/FoundryAgents_Step21_WebSearch.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);CA1812;CS8321 - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/Program.cs deleted file mode 100644 index c116a975e1..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/Program.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to use the Responses API Web Search Tool with AI Agents. - -using Azure.AI.Projects; -using Azure.AI.Projects.Agents; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using OpenAI.Responses; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -const string AgentInstructions = "You are a helpful assistant that can search the web to find current information and answer questions accurately."; -const string AgentName = "WebSearchAgent"; - -// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Option 1 - Using HostedWebSearchTool (MEAI + AgentFramework) -AIAgent agent = await CreateAgentWithMEAIAsync(); - -// Option 2 - Using PromptAgentDefinition with the Responses API native type -// AIAgent agent = await CreateAgentWithNativeSDKAsync(); - -AgentResponse response = await agent.RunAsync("What's the weather today in Seattle?"); - -// Get the text response -Console.WriteLine($"Response: {response.Text}"); - -// Getting any annotations/citations generated by the web search tool -foreach (AIAnnotation annotation in response.Messages.SelectMany(m => m.Contents).SelectMany(c => c.Annotations ?? [])) -{ - Console.WriteLine($"Annotation: {annotation}"); - if (annotation.RawRepresentation is UriCitationMessageAnnotation urlCitation) - { - Console.WriteLine($$""" - Title: {{urlCitation.Title}} - URL: {{urlCitation.Uri}} - """); - } -} - -// Cleanup by agent name removes the agent version created. -await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); - -// Creates the agent using the HostedWebSearchTool MEAI abstraction that maps to the built-in Responses API web search tool. -async Task CreateAgentWithMEAIAsync() - => await aiProjectClient.CreateAIAgentAsync( - name: AgentName, - model: deploymentName, - instructions: AgentInstructions, - tools: [new HostedWebSearchTool()]); - -// Creates the agent using the PromptAgentDefinition with the Responses API native ResponseTool.CreateWebSearchTool(). -async Task CreateAgentWithNativeSDKAsync() - => await aiProjectClient.CreateAIAgentAsync( - AgentName, - new AgentVersionCreationOptions( - new PromptAgentDefinition(model: deploymentName) - { - Instructions = AgentInstructions, - Tools = { ResponseTool.CreateWebSearchTool() } - })); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/README.md deleted file mode 100644 index 8da390878c..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Using Web Search with AI Agents - -This sample demonstrates how to use the Responses API web search tool with AI agents. The web search tool allows agents to search the web for current information to answer questions accurately. - -## What this sample demonstrates - -- Creating agents with web search capabilities -- Using HostedWebSearchTool (MEAI abstraction) -- Using native SDK web search tools (ResponseTool.CreateWebSearchTool) -- Extracting text responses and URL citations from agent responses -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure authentication configured for `DefaultAzureCredential` (for example, Azure CLI logged in with `az login`, environment variables, managed identity, or IDE sign-in) - -**Note**: This sample authenticates using `DefaultAzureCredential` from the Azure Identity library, which will try several credential sources (including Azure CLI, environment variables, managed identity, and IDE sign-in). Ensure at least one supported credential source is available. For more information, see the [Azure Identity documentation](https://learn.microsoft.com/dotnet/api/overview/azure/identity-readme). - -**Note**: The web search tool uses the built-in web search capability from the OpenAI Responses API. - -Set the following environment variables: - -```powershell -$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step21_WebSearch -``` - -## Expected behavior - -The sample will: - -1. Create an agent with web search capabilities using HostedWebSearchTool (MEAI abstraction) - - Alternative: Using native SDK web search tools (commented out in code) - - Alternative: Retrieving an existing agent by name (commented out in code) -2. Run the agent with a query: "What's the weather today in Seattle?" -3. The agent will use the web search tool to find current information -4. Display the text response from the agent -5. Display any URL citations from web search results -6. Clean up resources by deleting the agent diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/FoundryAgents_Step22_MemorySearch.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/FoundryAgents_Step22_MemorySearch.csproj deleted file mode 100644 index d83a9d9202..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/FoundryAgents_Step22_MemorySearch.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);CA1812 - - - - - - - - - - - - - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/README.md deleted file mode 100644 index 9e6d79d579..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# Using Memory Search with AI Agents - -This sample demonstrates how to use the Memory Search tool with AI agents. The Memory Search tool enables agents to recall information from previous conversations, supporting user profile persistence and chat summaries across sessions. - -## What this sample demonstrates - -- Creating an agent with Memory Search tool capabilities -- Configuring memory scope for user isolation -- Having conversations where the agent remembers past information -- Inspecting memory search results from agent responses -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) -- **A pre-created Memory Store** (see below) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -### Creating a Memory Store - -Memory stores must be created before running this sample. The .NET SDK currently only supports **using** existing memory stores with agents. To create a memory store, use one of these methods: - -**Option 1: Azure Portal** -1. Navigate to your Azure AI Foundry project -2. Go to the Memory section -3. Create a new memory store with your desired settings - -**Option 2: Python SDK** -```python -from azure.ai.projects import AIProjectClient -from azure.ai.projects.models import MemoryStoreDefaultDefinition, MemoryStoreDefaultOptions -from azure.identity import DefaultAzureCredential - -project_client = AIProjectClient( - endpoint="https://your-endpoint.openai.azure.com/", - credential=DefaultAzureCredential() -) - -memory_store = await project_client.memory_stores.create( - name="my-memory-store", - description="Memory store for Agent Framework conversations", - definition=MemoryStoreDefaultDefinition( - chat_model=os.environ["AZURE_AI_CHAT_MODEL_DEPLOYMENT_NAME"], - embedding_model=os.environ["AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME"], - options=MemoryStoreDefaultOptions( - user_profile_enabled=True, - chat_summary_enabled=True - ) - ) -) -``` - -## Environment Variables - -Set the following environment variables: - -```powershell -$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -$env:AZURE_AI_MEMORY_STORE_NAME="your-memory-store-name" # Required - name of pre-created memory store -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step22_MemorySearch -``` - -## Expected behavior - -The sample will: - -1. Create an agent with Memory Search tool configured -2. Send a message with personal information ("My name is Alice and I love programming in C#") -3. Wait for memory indexing -4. Ask the agent to recall the previously shared information -5. Display memory search results if available in the response -6. Clean up by deleting the agent (note: memory store persists) - -## Important notes - -- **Memory Store Lifecycle**: Memory stores are long-lived resources and are NOT deleted when the agent is deleted. Clean them up separately via Azure Portal or Python SDK. -- **Scope**: The `scope` parameter isolates memories per user/context. Use unique identifiers for different users. -- **Update Delay**: The `UpdateDelay` parameter controls how quickly new memories are indexed. diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/README.md b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/README.md deleted file mode 100644 index 8651108987..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step23_LocalMCP/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Using Local MCP Client with Azure Foundry Agents - -This sample demonstrates how to use a local MCP (Model Context Protocol) client with Azure Foundry Agents. Unlike the hosted MCP approach where Azure Foundry invokes the MCP server on the service side, this sample connects to the MCP server directly from the client via HTTP (Streamable HTTP transport) and passes the resolved tools to the agent. - -## What this sample demonstrates - -- Connecting to an MCP server locally using `HttpClientTransport` -- Discovering available tools from the MCP server client-side -- Passing locally-resolved MCP tools to a Foundry agent -- Using the Microsoft Learn MCP endpoint for documentation search -- Managing agent lifecycle (creation and deletion) - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -## Run the sample - -Navigate to the FoundryAgents sample directory and run: - -```powershell -cd dotnet/samples/02-agents/FoundryAgents -dotnet run --project .\FoundryAgents_Step23_LocalMCP -``` - -## Expected behavior - -The sample will: - -1. Connect to the Microsoft Learn MCP server via HTTP and list available tools -2. Create an agent with the locally-resolved MCP tools -3. Ask two questions about Microsoft documentation -4. The agent will use the MCP tools (invoked locally) to search Microsoft Learn documentation -5. Display the agent's responses with information from the documentation -6. Clean up resources by deleting the agent diff --git a/dotnet/samples/02-agents/FoundryAgents/README.md b/dotnet/samples/02-agents/FoundryAgents/README.md deleted file mode 100644 index 426a8cdad5..0000000000 --- a/dotnet/samples/02-agents/FoundryAgents/README.md +++ /dev/null @@ -1,121 +0,0 @@ -# Getting started with Foundry Agents - -The getting started with Foundry Agents samples demonstrate the fundamental concepts and functionalities -of Azure Foundry Agents and can be used with Azure Foundry as the AI provider. - -These samples showcase how to work with agents managed through Azure Foundry, including agent creation, -versioning, multi-turn conversations, and advanced features like code interpretation and computer use. - -## Classic vs New Foundry Agents - -> [!NOTE] -> Recently, Azure Foundry introduced a new and improved experience for creating and managing AI agents, which is the target of these samples. - -For more information about the previous classic agents and for what's new in Foundry Agents, see the [Foundry Agents migration documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/migrate?view=foundry). - -For a sample demonstrating how to use classic Foundry Agents, see the following: [Agent with Azure AI Persistent](../AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md). - -## Agent Versioning and Static Definitions - -One of the key architectural changes in the new Foundry Agents compared to the classic experience is how agent definitions are handled. In the new architecture, agents have **versions** and their definitions are established at creation time. This means that the agent's configuration—including instructions, tools, and options—is fixed when the agent version is created. - -> [!IMPORTANT] -> Agent versions are static and strictly adhere to their original definition. Any attempt to provide or override tools, instructions, or options during an agent run or request will be ignored by the agent, as the API does not support runtime configuration changes. All agent behavior must be defined at agent creation time. - -This design ensures consistency and predictability in agent behavior across all interactions with a specific agent version. - -The Agent Framework intentionally ignores unsupported runtime parameters rather than throwing exceptions. This abstraction-first approach ensures that code written against the unified agent abstraction remains portable across providers (OpenAI, Azure OpenAI, Foundry Agents). It removes the need for provider-specific conditional logic. Teams can adopt Foundry Agents without rewriting existing orchestration code. Configurations that work with other providers will gracefully degrade, rather than fail, when the underlying API does not support them. - -## Getting started with Foundry Agents prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure Foundry service endpoint and project configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: These samples use Azure Foundry Agents. For more information, see [Azure AI Foundry documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/). - -**Note**: These samples use Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -## Samples - -|Sample|Description| -|---|---| -|[Basics](./FoundryAgents_Step01.1_Basics/)|This sample demonstrates how to create and manage AI agents with versioning| -|[Running a simple agent](./FoundryAgents_Step01.2_Running/)|This sample demonstrates how to create and run a basic Foundry agent| -|[Multi-turn conversation](./FoundryAgents_Step02_MultiturnConversation/)|This sample demonstrates how to implement a multi-turn conversation with a Foundry agent| -|[Using function tools](./FoundryAgents_Step03_UsingFunctionTools/)|This sample demonstrates how to use function tools with a Foundry agent| -|[Using function tools with approvals](./FoundryAgents_Step04_UsingFunctionToolsWithApprovals/)|This sample demonstrates how to use function tools where approvals require human in the loop approvals before execution| -|[Structured output](./FoundryAgents_Step05_StructuredOutput/)|This sample demonstrates how to use structured output with a Foundry agent| -|[Persisted conversations](./FoundryAgents_Step06_PersistedConversations/)|This sample demonstrates how to persist conversations and reload them later| -|[Observability](./FoundryAgents_Step07_Observability/)|This sample demonstrates how to add telemetry to a Foundry agent| -|[Dependency injection](./FoundryAgents_Step08_DependencyInjection/)|This sample demonstrates how to add and resolve a Foundry agent with a dependency injection container| -|[Using MCP client as tools](./FoundryAgents_Step09_UsingMcpClientAsTools/)|This sample demonstrates how to use MCP clients as tools with a Foundry agent| -|[Using images](./FoundryAgents_Step10_UsingImages/)|This sample demonstrates how to use image multi-modality with a Foundry agent| -|[Exposing as a function tool](./FoundryAgents_Step11_AsFunctionTool/)|This sample demonstrates how to expose a Foundry agent as a function tool| -|[Using middleware](./FoundryAgents_Step12_Middleware/)|This sample demonstrates how to use middleware with a Foundry agent| -|[Using plugins](./FoundryAgents_Step13_Plugins/)|This sample demonstrates how to use plugins with a Foundry agent| -|[Code interpreter](./FoundryAgents_Step14_CodeInterpreter/)|This sample demonstrates how to use the code interpreter tool with a Foundry agent| -|[Computer use](./FoundryAgents_Step15_ComputerUse/)|This sample demonstrates how to use computer use capabilities with a Foundry agent| -|[File search](./FoundryAgents_Step16_FileSearch/)|This sample demonstrates how to use the file search tool with a Foundry agent| -|[OpenAPI tools](./FoundryAgents_Step17_OpenAPITools/)|This sample demonstrates how to use OpenAPI tools with a Foundry agent| -|[Bing Custom Search](./FoundryAgents_Step18_BingCustomSearch/)|This sample demonstrates how to use Bing Custom Search tool with a Foundry agent| -|[SharePoint grounding](./FoundryAgents_Step19_SharePoint/)|This sample demonstrates how to use the SharePoint grounding tool with a Foundry agent| -|[Microsoft Fabric](./FoundryAgents_Step20_MicrosoftFabric/)|This sample demonstrates how to use Microsoft Fabric tool with a Foundry agent| -|[Web search](./FoundryAgents_Step21_WebSearch/)|This sample demonstrates how to use the Responses API web search tool with a Foundry agent| -|[Memory search](./FoundryAgents_Step22_MemorySearch/)|This sample demonstrates how to use memory search tool with a Foundry agent| -|[Local MCP](./FoundryAgents_Step23_LocalMCP/)|This sample demonstrates how to use a local MCP client with a Foundry agent| - -## Evaluation Samples - -Evaluation is critical for building trustworthy and high-quality AI applications. The evaluation samples demonstrate how to assess agent safety, quality, and performance using Azure AI Foundry's evaluation capabilities. - -|Sample|Description| -|---|---| -|[Red Team Evaluation](./FoundryAgents_Evaluations_Step01_RedTeaming/)|This sample demonstrates how to use Azure AI Foundry's Red Teaming service to assess model safety against adversarial attacks| -|[Self-Reflection with Groundedness](./FoundryAgents_Evaluations_Step02_SelfReflection/)|This sample demonstrates the self-reflection pattern where agents iteratively improve responses based on groundedness evaluation| - -For details on safety evaluation, see the [Red Team Evaluation README](./FoundryAgents_Evaluations_Step01_RedTeaming/README.md). - -## Running the samples from the console - -To run the samples, navigate to the desired sample directory, e.g. - -```powershell -cd FoundryAgents_Step01.2_Running -``` - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini -``` - -If the variables are not set, you will be prompted for the values when running the samples. - -Execute the following command to build the sample: - -```powershell -dotnet build -``` - -Execute the following command to run the sample: - -```powershell -dotnet run --no-build -``` - -Or just build and run in one step: - -```powershell -dotnet run -``` - -## Running the samples from Visual Studio - -Open the solution in Visual Studio and set the desired sample project as the startup project. Then, run the project using the built-in debugger or by pressing `F5`. - -You will be prompted for any required environment variables if they are not already set. - diff --git a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs index e91ed4d15a..bedf87b8c5 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs +++ b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs @@ -5,9 +5,11 @@ // The sample first shows how to use MCP tools with auto approval, and then how to set up a tool that requires approval before it can be invoked and how to approve such a tool. using Azure.AI.Projects; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using OpenAI.Responses; var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); var model = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4.1-mini"; @@ -23,26 +25,22 @@ var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCre // Create an MCP tool definition that the agent can use. // In this case we allow the tool to always be called without approval. -var mcpTool = new HostedMcpServerTool( - serverName: "microsoft_learn", - serverAddress: "https://learn.microsoft.com/api/mcp") -{ - AllowedTools = ["microsoft_docs_search"], - ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire -}; +var mcpTool = ResponseTool.CreateMcpTool( + serverLabel: "microsoft_learn", + serverUri: new Uri("https://learn.microsoft.com/api/mcp"), + toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)); // Create a server side agent with the mcp tool, and expose it as an AIAgent. -AIAgent agent = await aiProjectClient.CreateAIAgentAsync( - model: model, - options: new() - { - Name = "MicrosoftLearnAgent", - ChatOptions = new() +AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( + "MicrosoftLearnAgent", + new AgentVersionCreationOptions( + new PromptAgentDefinition(model: model) { Instructions = "You answer questions by searching the Microsoft Learn content only.", - Tools = [mcpTool] - }, - }); + Tools = { mcpTool } + })); + +AIAgent agent = aiProjectClient.AsAIAgent(agentVersion); // You can then invoke the agent like any other AIAgent. AgentSession session = await agent.CreateSessionAsync(); @@ -56,26 +54,23 @@ aiProjectClient.Agents.DeleteAgent(agent.Name); // Create an MCP tool definition that the agent can use. // In this case we require approval before the tool can be called. -var mcpToolWithApproval = new HostedMcpServerTool( - serverName: "microsoft_learn", - serverAddress: "https://learn.microsoft.com/api/mcp") -{ - AllowedTools = ["microsoft_docs_search"], - ApprovalMode = HostedMcpServerToolApprovalMode.AlwaysRequire -}; +var mcpToolWithApproval = ResponseTool.CreateMcpTool( + serverLabel: "microsoft_learn", + serverUri: new Uri("https://learn.microsoft.com/api/mcp"), + allowedTools: new McpToolFilter() { ToolNames = { "microsoft_docs_search" } }, + toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.AlwaysRequireApproval)); // Create an agent with the MCP tool that requires approval. -AIAgent agentWithRequiredApproval = await aiProjectClient.CreateAIAgentAsync( - model: model, - options: new() - { - Name = "MicrosoftLearnAgentWithApproval", - ChatOptions = new() +AgentVersion agentVersionWithApproval = await aiProjectClient.Agents.CreateAgentVersionAsync( + "MicrosoftLearnAgentWithApproval", + new AgentVersionCreationOptions( + new PromptAgentDefinition(model: model) { Instructions = "You answer questions by searching the Microsoft Learn content only.", - Tools = [mcpToolWithApproval] - }, - }); + Tools = { mcpToolWithApproval } + })); + +AIAgent agentWithRequiredApproval = aiProjectClient.AsAIAgent(agentVersionWithApproval); // You can then invoke the agent like any other AIAgent. // For simplicity, we are assuming here that only mcp tool approvals are pending. diff --git a/dotnet/samples/02-agents/README.md b/dotnet/samples/02-agents/README.md index b901645f88..5ff0db416d 100644 --- a/dotnet/samples/02-agents/README.md +++ b/dotnet/samples/02-agents/README.md @@ -1,22 +1,21 @@ -# Getting started +# Getting started -The getting started samples demonstrate the fundamental concepts and functionalities -of the agent framework. +The getting started samples demonstrate the fundamental concepts and functionality of the agent framework. ## Samples -|Sample|Description| -|---|---| -|[Agents](./Agents/README.md)|Step by step instructions for getting started with agents| -|[Foundry Agents](./FoundryAgents/README.md)|Getting started with Azure Foundry Agents| -|[Agent Providers](./AgentProviders/README.md)|Getting started with creating agents using various providers| -|[Agents With Retrieval Augmented Generation (RAG)](./AgentWithRAG/README.md)|Adding Retrieval Augmented Generation (RAG) capabilities to your agents.| -|[Agents With Memory](./AgentWithMemory/README.md)|Adding Memory capabilities to your agents.| -|[Agent Open Telemetry](./AgentOpenTelemetry/README.md)|Getting started with OpenTelemetry for agents| -|[Agent With OpenAI exchange types](./AgentWithOpenAI/README.md)|Using OpenAI exchange types with agents| -|[Agent With Anthropic](./AgentWithAnthropic/README.md)|Getting started with agents using Anthropic Claude| -|[Model Context Protocol](./ModelContextProtocol/README.md)|Getting started with Model Context Protocol| -|[Agent Skills](./AgentSkills/README.md)|Getting started with Agent Skills| -|[Declarative Agents](./DeclarativeAgents)|Loading and executing AI agents from YAML configuration files| │ -|[AG-UI](./AGUI/README.md)|Getting started with AG-UI (Agent UI Protocol) servers and clients| │ -|[Dev UI](./DevUI/README.md)|Interactive web interface for testing and debugging AI agents during development| \ No newline at end of file +| Sample | Description | +| --- | --- | +| [Agents](./Agents/README.md) | Step-by-step instructions for getting started with agents | +| [Agents with Foundry](./AgentsWithFoundry/README.md) | Foundry agent samples using `FoundryAgent` and `AIProjectClient.AsAIAgent(...)` | +| [Agent Providers](./AgentProviders/README.md) | Getting started with creating agents using various providers | +| [Agents With Retrieval Augmented Generation (RAG)](./AgentWithRAG/README.md) | Adding Retrieval Augmented Generation (RAG) capabilities to your agents | +| [Agents With Memory](./AgentWithMemory/README.md) | Adding memory capabilities to your agents | +| [Agent Open Telemetry](./AgentOpenTelemetry/README.md) | Getting started with OpenTelemetry for agents | +| [Agent With OpenAI exchange types](./AgentWithOpenAI/README.md) | Using OpenAI exchange types with agents | +| [Agent With Anthropic](./AgentWithAnthropic/README.md) | Getting started with agents using Anthropic Claude | +| [Model Context Protocol](./ModelContextProtocol/README.md) | Getting started with Model Context Protocol | +| [Agent Skills](./AgentSkills/README.md) | Getting started with Agent Skills | +| [Declarative Agents](./DeclarativeAgents) | Loading and executing AI agents from YAML configuration files | +| [AG-UI](./AGUI/README.md) | Getting started with AG-UI (Agent UI Protocol) servers and clients | +| [Dev UI](./DevUI/README.md) | Interactive web interface for testing and debugging AI agents during development | diff --git a/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs b/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs index 589eca2bbc..5b1f59ac62 100644 --- a/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs +++ b/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using Azure.AI.Projects; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -68,15 +70,19 @@ public static class Program /// The target language for translation /// The to create the agent with. /// The model to use for the agent - /// A ChatClientAgent configured for the specified language - private static async Task CreateTranslationAgentAsync( + /// A FoundryAgent configured for the specified language + private static async Task CreateTranslationAgentAsync( string targetLanguage, AIProjectClient aiProjectClient, string model) { - return await aiProjectClient.CreateAIAgentAsync( - name: $"{targetLanguage} Translator", - model: model, - instructions: $"You are a translation assistant that translates the provided text to {targetLanguage}."); + AgentVersion agentVersion = await aiProjectClient.Agents.CreateAgentVersionAsync( + $"{targetLanguage} Translator", + new AgentVersionCreationOptions( + new PromptAgentDefinition(model: model) + { + Instructions = $"You are a translation assistant that translates the provided text to {targetLanguage}.", + })); + return aiProjectClient.AsAIAgent(agentVersion); } } diff --git a/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs b/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs index 5936aaf82f..7852a8730f 100644 --- a/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs @@ -8,6 +8,7 @@ using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Shared.Foundry; @@ -49,7 +50,7 @@ internal sealed class Program string workflowInput = GetWorkflowInput(args); - AIAgent agent = aiProjectClient.AsAIAgent(agentVersion); + FoundryAgent agent = aiProjectClient.AsAIAgent(agentVersion); AgentSession session = await agent.CreateSessionAsync(); diff --git a/dotnet/samples/03-workflows/README.md b/dotnet/samples/03-workflows/README.md index 2b8d375654..1ab52106ec 100644 --- a/dotnet/samples/03-workflows/README.md +++ b/dotnet/samples/03-workflows/README.md @@ -1,6 +1,6 @@ -# Workflow Getting Started Samples +# Workflow Getting Started Samples -The getting started with workflow samples demonstrate the fundamental concepts and functionalities of workflows in Agent Framework. +The workflow samples demonstrate the fundamental concepts and functionality of workflows in Agent Framework. ## Samples Overview @@ -20,15 +20,13 @@ Please begin with the [Start Here](./_StartHere) samples in order. These three s | [Mixed Workflow with Agents and Executors](./_StartHere/06_MixedWorkflowAgentsAndExecutors) | Shows how to mix agents and executors with adapter pattern for type conversion and protocol handling | | [Writer-Critic Workflow](./_StartHere/07_WriterCriticWorkflow) | Demonstrates iterative refinement with quality gates, max iteration safety, multiple message handlers, and conditional routing for feedback loops | -Once completed, please proceed to other samples listed below. - -> Note that you don't need to follow a strict order after the foundational samples. However, some samples build upon concepts from previous ones, so it's beneficial to be aware of the dependencies. +Once completed, please proceed to the other samples listed below. ### Agents | Sample | Concepts | |--------|----------| -| [Foundry Agents in Workflows](./Agents/FoundryAgent) | Demonstrates using Azure Foundry Agents within a workflow | +| [Foundry Agents in Workflows](./Agents/FoundryAgent) | Demonstrates using Azure Foundry agents in a workflow through `ChatClientAgent` | | [Custom Agent Executors](./Agents/CustomAgentExecutors) | Shows how to create a custom agent executor for more complex scenarios | | [Workflow as an Agent](./Agents/WorkflowAsAnAgent) | Illustrates how to encapsulate a workflow as an agent | | [Group Chat with Tool Approval](./Agents/GroupChatToolApproval) | Shows multi-agent group chat with tool approval requests and human-in-the-loop interaction | @@ -58,25 +56,3 @@ Once completed, please proceed to other samples listed below. | [Edge Conditions](./ConditionalEdges/01_EdgeCondition) | Introduces conditional edges for dynamic routing based on executor outputs | | [Switch-Case Routing](./ConditionalEdges/02_SwitchCase) | Extends conditional edges with switch-case routing for multiple paths | | [Multi-Selection Routing](./ConditionalEdges/03_MultiSelection) | Demonstrates multi-selection routing where one executor can trigger multiple downstream executors | - -> These 3 samples build upon each other. It's recommended to explore them in sequence to fully grasp the concepts. - -### Declarative Workflows - -| Sample | Concepts | -|--------|----------| -| [Declarative](./Declarative) | Demonstrates execution of declartive workflows. | - -### Checkpointing - -| Sample | Concepts | -|--------|----------| -| [Checkpoint and Resume](./Checkpoint/CheckpointAndResume) | Introduces checkpoints for saving and restoring workflow state for time travel purposes | -| [Checkpoint and Rehydrate](./Checkpoint/CheckpointAndRehydrate) | Demonstrates hydrating a new workflow instance from a saved checkpoint | -| [Checkpoint with Human-in-the-Loop](./Checkpoint/CheckpointWithHumanInTheLoop) | Combines checkpointing with human-in-the-loop interactions | - -### Human-in-the-Loop - -| Sample | Concepts | -|--------|----------| -| [Basic Human-in-the-Loop](./HumanInTheLoop/HumanInTheLoopBasic) | Introduces human-in-the-loop interaction using input ports and external requests | diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs index 1149f9a293..13c01be156 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs @@ -2,6 +2,7 @@ using A2A; using Azure.AI.Projects; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -19,8 +20,8 @@ internal static class HostAgentFactory // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); - AIAgent agent = await aiProjectClient - .GetAIAgentAsync(agentName, tools: tools); + AgentRecord agentRecord = await aiProjectClient.Agents.GetAgentAsync(agentName); + AIAgent agent = aiProjectClient.AsAIAgent(agentRecord, tools: tools); AgentCard agentCard = agentType.ToUpperInvariant() switch { diff --git a/dotnet/samples/AGENTS.md b/dotnet/samples/AGENTS.md index 1578b39a26..f515f531eb 100644 --- a/dotnet/samples/AGENTS.md +++ b/dotnet/samples/AGENTS.md @@ -28,7 +28,7 @@ dotnet/samples/ │ ├── AGUI/ # AG-UI protocol samples │ ├── DeclarativeAgents/ # Declarative agent definitions │ ├── DevUI/ # DevUI samples -│ ├── FoundryAgents/ # Azure AI Foundry agent samples +│ ├── AgentsWithFoundry/ # Azure AI Foundry samples (FoundryAgent + AsAIAgent extensions) │ └── ModelContextProtocol/ # MCP server/client patterns ├── 03-workflows/ # Workflow patterns │ ├── _StartHere/ # Introductory workflow samples diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs index 32bb08674b..ec788233ed 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs @@ -44,7 +44,7 @@ internal sealed class AzureAIProjectChatClient : DelegatingChatClient { this._agentClient = aiProjectClient; this._agentReference = Throw.IfNull(agentReference); - this._metadata = new ChatClientMetadata("azure.ai.agents", defaultModelId: defaultModelId); + this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId); this._chatOptions = chatOptions; } diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs index b129f4b1f2..479ab894ae 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs @@ -14,6 +14,7 @@ using Azure.AI.Projects.Agents; using Microsoft.Agents.AI; using Microsoft.Agents.AI.AzureAI; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; using OpenAI; @@ -42,7 +43,7 @@ public static partial class AzureAIProjectChatClientExtensions /// When instantiating a by using an , minimal information will be available about the agent in the instance level, and any logic that relies /// on to retrieve information about the agent like will receive as the result. /// - public static ChatClientAgent AsAIAgent( + public static FoundryAgent AsAIAgent( this AIProjectClient aiProjectClient, AgentReference agentReference, IList? tools = null, @@ -53,7 +54,7 @@ public static partial class AzureAIProjectChatClientExtensions Throw.IfNull(agentReference); ThrowIfInvalidAgentName(agentReference.Name); - return AsChatClientAgent( + var innerAgent = AsChatClientAgent( aiProjectClient, agentReference, new ChatClientAgentOptions() @@ -64,6 +65,8 @@ public static partial class AzureAIProjectChatClientExtensions }, clientFactory, services); + + return new FoundryAgent(aiProjectClient, innerAgent); } /// @@ -79,7 +82,8 @@ public static partial class AzureAIProjectChatClientExtensions /// Thrown when or is . /// Thrown when is empty or whitespace, or when the agent with the specified name was not found. /// The agent with the specified name was not found. - public static async Task GetAIAgentAsync( + [Obsolete("Use native AIProjectClient agent APIs and AsAIAgent(AgentRecord/AgentVersion) instead.")] + public static async Task GetAIAgentAsync( this AIProjectClient aiProjectClient, string name, IList? tools = null, @@ -110,7 +114,7 @@ public static partial class AzureAIProjectChatClientExtensions /// An optional to use for resolving services required by the instances being invoked. /// A instance that can be used to perform operations based on the latest version of the Azure AI Agent. /// Thrown when or is . - public static ChatClientAgent AsAIAgent( + public static FoundryAgent AsAIAgent( this AIProjectClient aiProjectClient, AgentRecord agentRecord, IList? tools = null, @@ -122,13 +126,15 @@ public static partial class AzureAIProjectChatClientExtensions var allowDeclarativeMode = tools is not { Count: > 0 }; - return AsChatClientAgent( + var innerAgent = AsChatClientAgent( aiProjectClient, agentRecord, tools, clientFactory, !allowDeclarativeMode, services); + + return new FoundryAgent(aiProjectClient, innerAgent); } /// @@ -141,7 +147,7 @@ public static partial class AzureAIProjectChatClientExtensions /// An optional to use for resolving services required by the instances being invoked. /// A instance that can be used to perform operations based on the provided version of the Azure AI Agent. /// Thrown when or is . - public static ChatClientAgent AsAIAgent( + public static FoundryAgent AsAIAgent( this AIProjectClient aiProjectClient, AgentVersion agentVersion, IList? tools = null, @@ -153,13 +159,15 @@ public static partial class AzureAIProjectChatClientExtensions var allowDeclarativeMode = tools is not { Count: > 0 }; - return AsChatClientAgent( + var innerAgent = AsChatClientAgent( aiProjectClient, agentVersion, tools, clientFactory, !allowDeclarativeMode, services); + + return new FoundryAgent(aiProjectClient, innerAgent); } /// @@ -172,7 +180,8 @@ public static partial class AzureAIProjectChatClientExtensions /// A to cancel the operation if needed. /// A instance that can be used to perform operations on the newly created agent. /// Thrown when or is . - public static async Task GetAIAgentAsync( + [Obsolete("Use native AIProjectClient agent APIs and AsAIAgent(AgentRecord/AgentVersion) instead.")] + public static async Task GetAIAgentAsync( this AIProjectClient aiProjectClient, ChatClientAgentOptions options, Func? clientFactory = null, @@ -194,12 +203,9 @@ public static partial class AzureAIProjectChatClientExtensions var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: !options.UseProvidedChatClientAsIs); - return AsChatClientAgent( + return new FoundryAgent( aiProjectClient, - agentVersion, - agentOptions, - clientFactory, - services); + AsChatClientAgent(aiProjectClient, agentVersion, agentOptions, clientFactory, services)); } /// @@ -218,7 +224,8 @@ public static partial class AzureAIProjectChatClientExtensions /// Thrown when , , or is . /// Thrown when or is empty or whitespace. /// When using prompt agent definitions with tools the parameter needs to be provided. - public static Task CreateAIAgentAsync( + [Obsolete("Use native AIProjectClient.Agents APIs instead.")] + public static Task CreateAIAgentAsync( this AIProjectClient aiProjectClient, string name, string model, @@ -256,7 +263,8 @@ public static partial class AzureAIProjectChatClientExtensions /// A instance that can be used to perform operations on the newly created agent. /// Thrown when or is . /// Thrown when is empty or whitespace, or when the agent name is not provided in the options. - public static async Task CreateAIAgentAsync( + [Obsolete("Use native AIProjectClient.Agents APIs instead.")] + public static async Task CreateAIAgentAsync( this AIProjectClient aiProjectClient, string model, ChatClientAgentOptions options, @@ -267,7 +275,6 @@ public static partial class AzureAIProjectChatClientExtensions Throw.IfNull(aiProjectClient); Throw.IfNull(options); Throw.IfNullOrWhitespace(model); - const bool RequireInvocableTools = true; if (string.IsNullOrWhiteSpace(options.Name)) { @@ -276,43 +283,13 @@ public static partial class AzureAIProjectChatClientExtensions ThrowIfInvalidAgentName(options.Name); - PromptAgentDefinition agentDefinition = new(model) - { - Instructions = options.ChatOptions?.Instructions, - Temperature = options.ChatOptions?.Temperature, - TopP = options.ChatOptions?.TopP, - TextOptions = new() { TextFormat = ToOpenAIResponseTextFormat(options.ChatOptions?.ResponseFormat, options.ChatOptions) } - }; + AgentVersion agentVersion = await CreateAgentVersionFromOptionsAsync(aiProjectClient, model, options, cancellationToken).ConfigureAwait(false); - // Map reasoning options from the abstraction-level ChatOptions.Reasoning, - // falling back to extracting from the raw representation factory for breaking glass scenarios. - if (options.ChatOptions?.Reasoning is { } reasoning) - { - agentDefinition.ReasoningOptions = ToResponseReasoningOptions(reasoning); - } - else if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is CreateResponseOptions respCreationOptions) - { - agentDefinition.ReasoningOptions = respCreationOptions.ReasoningOptions; - } + var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: true); - ApplyToolsToAgentDefinition(agentDefinition, options.ChatOptions?.Tools); - - AgentVersionCreationOptions? creationOptions = new(agentDefinition); - if (!string.IsNullOrWhiteSpace(options.Description)) - { - creationOptions.Description = options.Description; - } - - AgentVersion agentVersion = await CreateAgentVersionWithProtocolAsync(aiProjectClient, options.Name, creationOptions, cancellationToken).ConfigureAwait(false); - - var agentOptions = CreateChatClientAgentOptions(agentVersion, options, RequireInvocableTools); - - return AsChatClientAgent( + return new FoundryAgent( aiProjectClient, - agentVersion, - agentOptions, - clientFactory, - services); + AsChatClientAgent(aiProjectClient, agentVersion, agentOptions, clientFactory, services)); } /// @@ -330,7 +307,8 @@ public static partial class AzureAIProjectChatClientExtensions /// When using this extension method with a the tools are only declarative and not invocable. /// Invocation of any in-process tools will need to be handled manually. /// - public static Task CreateAIAgentAsync( + [Obsolete("Use native AIProjectClient.Agents APIs instead.")] + public static Task CreateAIAgentAsync( this AIProjectClient aiProjectClient, string name, AgentVersionCreationOptions creationOptions, @@ -351,6 +329,75 @@ public static partial class AzureAIProjectChatClientExtensions cancellationToken); } + /// + /// Creates a non-versioned backed by the project's Responses API using the specified model and instructions. + /// + /// The to use for Responses API calls. Cannot be . + /// The model deployment name to use for the agent. Cannot be or whitespace. + /// The instructions that guide the agent's behavior. Cannot be or whitespace. + /// Optional name for the agent. + /// Optional human-readable description for the agent. + /// Optional collection of tools that the agent can invoke during conversations. + /// Provides a way to customize the creation of the underlying used by the agent. + /// Optional logger factory for creating loggers used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A backed by the project's Responses API. + /// Thrown when is . + /// Thrown when or is empty or whitespace. + public static FoundryAgent AsAIAgent( + this AIProjectClient aiProjectClient, + string model, + string instructions, + string? name = null, + string? description = null, + IList? tools = null, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(aiProjectClient); + Throw.IfNullOrWhitespace(model); + Throw.IfNullOrWhitespace(instructions); + + ChatClientAgentOptions options = new() + { + Name = name, + Description = description, + ChatOptions = new ChatOptions + { + ModelId = model, + Instructions = instructions, + Tools = tools, + }, + }; + + return new FoundryAgent(aiProjectClient, CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services)); + } + + /// + /// Creates a non-versioned backed by the project's Responses API using the specified options. + /// + /// The to use for Responses API calls. Cannot be . + /// Configuration options that control the agent's behavior. is required. + /// Provides a way to customize the creation of the underlying used by the agent. + /// Optional logger factory for creating loggers used by the agent. + /// An optional to use for resolving services required by the instances being invoked. + /// A backed by the project's Responses API. + /// Thrown when or is . + /// Thrown when does not specify . + public static FoundryAgent AsAIAgent( + this AIProjectClient aiProjectClient, + ChatClientAgentOptions options, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(options); + + return new FoundryAgent(aiProjectClient, CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services)); + } + #region Private private static readonly ModelReaderWriterOptions s_modelWriterOptionsWire = new("W"); @@ -358,7 +405,7 @@ public static partial class AzureAIProjectChatClientExtensions /// /// Asynchronously retrieves an agent record by name using the protocol method to inject user-agent headers. /// - private static async Task GetAgentRecordByNameAsync(AIProjectClient aiProjectClient, string agentName, CancellationToken cancellationToken) + internal static async Task GetAgentRecordByNameAsync(AIProjectClient aiProjectClient, string agentName, CancellationToken cancellationToken) { ClientResult protocolResponse = await aiProjectClient.Agents.GetAgentAsync(agentName, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false); var rawResponse = protocolResponse.GetRawResponse(); @@ -369,7 +416,7 @@ public static partial class AzureAIProjectChatClientExtensions /// /// Asynchronously creates an agent version using the protocol method to inject user-agent headers. /// - private static async Task CreateAgentVersionWithProtocolAsync(AIProjectClient aiProjectClient, string agentName, AgentVersionCreationOptions creationOptions, CancellationToken cancellationToken) + internal static async Task CreateAgentVersionWithProtocolAsync(AIProjectClient aiProjectClient, string agentName, AgentVersionCreationOptions creationOptions, CancellationToken cancellationToken) { BinaryData serializedOptions = ModelReaderWriter.Write(creationOptions, s_modelWriterOptionsWire, AzureAIProjectsAgentsContext.Default); BinaryContent content = BinaryContent.Create(serializedOptions); @@ -379,7 +426,7 @@ public static partial class AzureAIProjectChatClientExtensions return result ?? throw new InvalidOperationException($"Failed to create agent version for agent '{agentName}'."); } - private static async Task CreateAIAgentAsync( + private static async Task CreateAIAgentAsync( this AIProjectClient aiProjectClient, string name, IList? tools, @@ -397,17 +444,61 @@ public static partial class AzureAIProjectChatClientExtensions AgentVersion agentVersion = await CreateAgentVersionWithProtocolAsync(aiProjectClient, name, creationOptions, cancellationToken).ConfigureAwait(false); - return AsChatClientAgent( - aiProjectClient, - agentVersion, - tools, - clientFactory, - !allowDeclarativeMode, - services); + return new FoundryAgent(aiProjectClient, AsChatClientAgent(aiProjectClient, agentVersion, tools, clientFactory, !allowDeclarativeMode, services)); } - /// This method creates an with the specified ChatClientAgentOptions. - private static ChatClientAgent AsChatClientAgent( + /// + /// Creates an agent version with optional tool application, using the protocol method to inject user-agent headers. + /// + internal static async Task CreateAgentVersionWithProtocolAsync(AIProjectClient aiProjectClient, string agentName, AgentVersionCreationOptions creationOptions, IList? tools, CancellationToken cancellationToken) + { + if (tools is { Count: > 0 }) + { + ApplyToolsToAgentDefinition(creationOptions.Definition, tools); + } + + return await CreateAgentVersionWithProtocolAsync(aiProjectClient, agentName, creationOptions, cancellationToken).ConfigureAwait(false); + } + + /// + /// Creates an agent version from , mapping options to a . + /// + internal static async Task CreateAgentVersionFromOptionsAsync( + AIProjectClient aiProjectClient, + string model, + ChatClientAgentOptions options, + CancellationToken cancellationToken) + { + PromptAgentDefinition agentDefinition = new(model) + { + Instructions = options.ChatOptions?.Instructions, + Temperature = options.ChatOptions?.Temperature, + TopP = options.ChatOptions?.TopP, + TextOptions = new() { TextFormat = ToOpenAIResponseTextFormat(options.ChatOptions?.ResponseFormat, options.ChatOptions) } + }; + + if (options.ChatOptions?.Reasoning is { } reasoning) + { + agentDefinition.ReasoningOptions = ToResponseReasoningOptions(reasoning); + } + else if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is CreateResponseOptions respCreationOptions) + { + agentDefinition.ReasoningOptions = respCreationOptions.ReasoningOptions; + } + + ApplyToolsToAgentDefinition(agentDefinition, options.ChatOptions?.Tools); + + AgentVersionCreationOptions creationOptions = new(agentDefinition); + if (!string.IsNullOrWhiteSpace(options.Description)) + { + creationOptions.Description = options.Description; + } + + return await CreateAgentVersionWithProtocolAsync(aiProjectClient, options.Name!, creationOptions, cancellationToken).ConfigureAwait(false); + } + + /// Creates a with the specified options. + internal static ChatClientAgent CreateChatClientAgent( AIProjectClient aiProjectClient, AgentVersion agentVersion, ChatClientAgentOptions agentOptions, @@ -424,6 +515,37 @@ public static partial class AzureAIProjectChatClientExtensions return new ChatClientAgent(chatClient, agentOptions, services: services); } + internal static ChatClientAgent CreateResponsesChatClientAgent( + AIProjectClient aiProjectClient, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + ILoggerFactory? loggerFactory, + IServiceProvider? services) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(agentOptions); + Throw.IfNull(agentOptions.ChatOptions); + Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId); + + IChatClient chatClient = new AzureAIProjectResponsesChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services); + } + + /// This method creates an with the specified ChatClientAgentOptions. + private static ChatClientAgent AsChatClientAgent( + AIProjectClient aiProjectClient, + AgentVersion agentVersion, + ChatClientAgentOptions agentOptions, + Func? clientFactory, + IServiceProvider? services) + => CreateChatClientAgent(aiProjectClient, agentVersion, agentOptions, clientFactory, services); + /// This method creates an with the specified ChatClientAgentOptions. private static ChatClientAgent AsChatClientAgent( AIProjectClient aiProjectClient, @@ -503,7 +625,7 @@ public static partial class AzureAIProjectChatClientExtensions /// This method rebuilds the agent options from the agent definition returned by the version and combine with the in-proc tools when provided /// this ensures that all required tools are provided and the definition of the agent options are consistent with the agent definition coming from the server. /// - private static ChatClientAgentOptions CreateChatClientAgentOptions(AgentVersion agentVersion, ChatOptions? chatOptions, bool requireInvocableTools) + internal static ChatClientAgentOptions CreateChatClientAgentOptions(AgentVersion agentVersion, ChatOptions? chatOptions, bool requireInvocableTools) { var agentDefinition = agentVersion.Definition; @@ -591,7 +713,7 @@ public static partial class AzureAIProjectChatClientExtensions /// Specifies whether the returned options must include invocable tools. Set to to require /// invocable tools; otherwise, . /// A instance configured according to the specified parameters. - private static ChatClientAgentOptions CreateChatClientAgentOptions(AgentVersion agentVersion, ChatClientAgentOptions? options, bool requireInvocableTools) + internal static ChatClientAgentOptions CreateChatClientAgentOptions(AgentVersion agentVersion, ChatClientAgentOptions? options, bool requireInvocableTools) { var agentOptions = CreateChatClientAgentOptions(agentVersion, options?.ChatOptions, requireInvocableTools); if (options is not null) @@ -768,7 +890,7 @@ public static partial class AzureAIProjectChatClientExtensions private static Regex AgentNameValidationRegex() => new("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$"); #endif - private static string ThrowIfInvalidAgentName(string? name) + internal static string ThrowIfInvalidAgentName(string? name) { Throw.IfNullOrWhitespace(name); if (!AgentNameValidationRegex().IsMatch(name)) diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectResponsesChatClient.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectResponsesChatClient.cs new file mode 100644 index 0000000000..48bb20d766 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectResponsesChatClient.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Projects; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.AzureAI; + +#pragma warning disable OPENAI001 +internal sealed class AzureAIProjectResponsesChatClient : DelegatingChatClient +{ + private readonly ChatClientMetadata _metadata; + private readonly AIProjectClient _aiProjectClient; + + internal AzureAIProjectResponsesChatClient(AIProjectClient aiProjectClient, string defaultModelId) + : base(Throw.IfNull(aiProjectClient) + .GetProjectOpenAIClient() + .GetProjectResponsesClientForModel(Throw.IfNullOrWhitespace(defaultModelId)) + .AsIChatClient()) + { + this._aiProjectClient = aiProjectClient; + this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId); + } + + public override object? GetService(Type serviceType, object? serviceKey = null) + { + return (serviceKey is null && serviceType == typeof(ChatClientMetadata)) + ? this._metadata + : (serviceKey is null && serviceType == typeof(AIProjectClient)) + ? this._aiProjectClient + : base.GetService(serviceType, serviceKey); + } +} +#pragma warning restore OPENAI001 diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAITool.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAITool.cs new file mode 100644 index 0000000000..80ed48e1df --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAITool.cs @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Azure.AI.Projects.Agents; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using OpenAI.Responses; + +#pragma warning disable OPENAI001 + +namespace Microsoft.Agents.AI.AzureAI; + +/// +/// Provides factory methods for creating instances from Microsoft Foundry and OpenAI response tools. +/// +/// +/// +/// This class wraps (Azure.AI.Projects.OpenAI) and (OpenAI SDK) factory methods, +/// returning directly — eliminating the need for manual casting and .AsAITool() calls. +/// +/// +/// Instead of writing: +/// ((ResponseTool)AgentTool.CreateOpenApiTool(definition)).AsAITool() +/// You can write: +/// FoundryAITool.CreateOpenApiTool(definition) +/// +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public static class FoundryAITool +{ + /// + /// Converts an existing into an . + /// + /// The response tool to convert. + /// An wrapping the provided response tool. + public static AITool FromResponseTool(ResponseTool responseTool) => responseTool.AsAITool(); + + // --- Azure.AI.Projects.OpenAI AgentTool factories --- + + /// + /// Creates an for OpenAPI tool invocations. + /// + /// The OpenAPI function definition specifying the API endpoint, schema, and authentication. + /// An that calls the specified OpenAPI endpoint. + public static AITool CreateOpenApiTool(OpenApiFunctionDefinition definition) + => ((ResponseTool)AgentTool.CreateOpenApiTool(definition)).AsAITool(); + + /// + /// Creates an for Bing Grounding search. + /// + /// The Bing Grounding search configuration options. + /// An for Bing Grounding search. + public static AITool CreateBingGroundingTool(BingGroundingSearchToolOptions options) + => ((ResponseTool)AgentTool.CreateBingGroundingTool(options)).AsAITool(); + + /// + /// Creates an for Bing Custom Search. + /// + /// The Bing Custom Search configuration parameters. + /// An for Bing Custom Search. + public static AITool CreateBingCustomSearchTool(BingCustomSearchToolOptions parameters) + => ((ResponseTool)AgentTool.CreateBingCustomSearchTool(parameters)).AsAITool(); + + /// + /// Creates an for Microsoft Fabric data agent. + /// + /// The Fabric data agent configuration options. + /// An for Microsoft Fabric. + public static AITool CreateMicrosoftFabricTool(FabricDataAgentToolOptions options) + => ((ResponseTool)AgentTool.CreateMicrosoftFabricTool(options)).AsAITool(); + + /// + /// Creates an for SharePoint grounding. + /// + /// The SharePoint grounding configuration options. + /// An for SharePoint grounding. + public static AITool CreateSharepointTool(SharePointGroundingToolOptions options) + => ((ResponseTool)AgentTool.CreateSharepointTool(options)).AsAITool(); + + /// + /// Creates an for Azure AI Search. + /// + /// Optional Azure AI Search configuration options. + /// An for Azure AI Search. + public static AITool CreateAzureAISearchTool(AzureAISearchToolOptions? options = null) + => ((ResponseTool)AgentTool.CreateAzureAISearchTool(options)).AsAITool(); + + /// + /// Creates an for browser automation. + /// + /// The browser automation configuration parameters. + /// An for browser automation. + public static AITool CreateBrowserAutomationTool(BrowserAutomationToolOptions parameters) + => ((ResponseTool)AgentTool.CreateBrowserAutomationTool(parameters)).AsAITool(); + + /// + /// Creates an for structured output capture. + /// + /// The structured output definition. + /// An for structured output capture. + public static AITool CreateStructuredOutputsTool(StructuredOutputDefinition outputs) + => ((ResponseTool)AgentTool.CreateStructuredOutputsTool(outputs)).AsAITool(); + + /// + /// Creates an for Agent-to-Agent (A2A) communication. + /// + /// The base URI for the A2A agent. + /// Optional path to the agent card. + /// An for A2A communication. + public static AITool CreateA2ATool(Uri baseUri, string? agentCardPath = null) + => AgentTool.CreateA2ATool(baseUri, agentCardPath).AsAITool(); + + // --- OpenAI SDK ResponseTool factories --- + + /// + /// Creates an for computer use (screen interaction). + /// + /// The computer tool environment type. + /// The display width in pixels. + /// The display height in pixels. + /// An for computer use. + [Experimental("OPENAICUA001")] + public static AITool CreateComputerTool(ComputerToolEnvironment environment, int displayWidth, int displayHeight) + => ResponseTool.CreateComputerTool(environment, displayWidth, displayHeight).AsAITool(); + + /// + /// Creates an for function tool invocations. + /// + /// The name of the function. + /// The function parameters schema as JSON. + /// Whether strict mode is enabled for parameter validation. + /// Optional description of the function. + /// An for function invocations. + public static AITool CreateFunctionTool(string functionName, BinaryData functionParameters, bool? strictModeEnabled, string? functionDescription = null) + => ResponseTool.CreateFunctionTool(functionName, functionParameters, strictModeEnabled, functionDescription).AsAITool(); + + /// + /// Creates an for file search over vector stores. + /// + /// The IDs of vector stores to search. + /// Optional maximum number of results to return. + /// Optional ranking options for search results. + /// Optional filters for search results. + /// An for file search. + public static AITool CreateFileSearchTool(IEnumerable vectorStoreIds, int? maxResultCount = null, FileSearchToolRankingOptions? rankingOptions = null, BinaryData? filters = null) + => ResponseTool.CreateFileSearchTool(vectorStoreIds, maxResultCount, rankingOptions, filters).AsAITool(); + + /// + /// Creates an for web search. + /// + /// Optional user location for search context. + /// Optional search context size. + /// Optional search filters. + /// An for web search. + public static AITool CreateWebSearchTool(WebSearchToolLocation? userLocation = null, WebSearchToolContextSize? searchContextSize = null, WebSearchToolFilters? filters = null) + => ResponseTool.CreateWebSearchTool(userLocation, searchContextSize, filters).AsAITool(); + + /// + /// Creates an for MCP (Model Context Protocol) server tools. + /// + /// The label for the MCP server. + /// The URI of the MCP server. + /// Optional authorization token. + /// Optional server description. + /// Optional custom headers. + /// Optional filter for allowed tools. + /// Optional tool call approval policy. + /// An for MCP server tools. + public static AITool CreateMcpTool(string serverLabel, Uri serverUri, string? authorizationToken = null, string? serverDescription = null, IDictionary? headers = null, McpToolFilter? allowedTools = null, McpToolCallApprovalPolicy? toolCallApprovalPolicy = null) + => ResponseTool.CreateMcpTool(serverLabel, serverUri, authorizationToken, serverDescription, headers, allowedTools, toolCallApprovalPolicy).AsAITool(); + + /// + /// Creates an for MCP (Model Context Protocol) server tools using a connector ID. + /// + /// The label for the MCP server. + /// The connector ID for the MCP server. + /// Optional authorization token. + /// Optional server description. + /// Optional custom headers. + /// Optional filter for allowed tools. + /// Optional tool call approval policy. + /// An for MCP server tools. + public static AITool CreateMcpTool(string serverLabel, McpToolConnectorId connectorId, string? authorizationToken = null, string? serverDescription = null, IDictionary? headers = null, McpToolFilter? allowedTools = null, McpToolCallApprovalPolicy? toolCallApprovalPolicy = null) + => ResponseTool.CreateMcpTool(serverLabel, connectorId, authorizationToken, serverDescription, headers, allowedTools, toolCallApprovalPolicy).AsAITool(); + + /// + /// Creates an for code interpreter. + /// + /// The container configuration for the code interpreter. + /// An for code interpreter. + public static AITool CreateCodeInterpreterTool(CodeInterpreterToolContainer container) + => ResponseTool.CreateCodeInterpreterTool(container).AsAITool(); + + /// + /// Creates an for image generation. + /// + /// The model to use for image generation. + /// Optional image quality setting. + /// Optional image size setting. + /// Optional output file format. + /// Optional output compression factor. + /// Optional moderation level. + /// Optional background setting. + /// Optional input fidelity setting. + /// Optional input image mask. + /// Optional partial image count. + /// An for image generation. + public static AITool CreateImageGenerationTool(string model, ImageGenerationToolQuality? quality = null, ImageGenerationToolSize? size = null, ImageGenerationToolOutputFileFormat? outputFileFormat = null, int? outputCompressionFactor = null, ImageGenerationToolModerationLevel? moderationLevel = null, ImageGenerationToolBackground? background = null, ImageGenerationToolInputFidelity? inputFidelity = null, ImageGenerationToolInputImageMask? inputImageMask = null, int? partialImageCount = null) + => ResponseTool.CreateImageGenerationTool(model, quality, size, outputFileFormat, outputCompressionFactor, moderationLevel, background, inputFidelity, inputImageMask, partialImageCount).AsAITool(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAgent.cs new file mode 100644 index 0000000000..66fa93e39f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/FoundryAgent.cs @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel; +using System.Diagnostics.CodeAnalysis; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.AzureAI; + +/// +/// Provides an that uses Microsoft Foundry for AI agent capabilities. +/// +/// +/// +/// connects to a pre-configured server-side agent in Microsoft Foundry, +/// wrapping it as an for use with Agent Framework. Unlike the direct +/// AIProjectClient.AsAIAgent(model, instructions) approach (which creates a local agent +/// backed by the Responses API without any server-side agent definition), +/// works with agents that are managed and versioned in the Foundry service. +/// +/// +/// This class provides convenient access to Foundry-specific features such as server-side +/// conversation management via . +/// +/// +/// Instances can be created directly via public constructors or through +/// AsAIAgent extension methods on . +/// +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public sealed class FoundryAgent : DelegatingAIAgent +{ + private readonly AIProjectClient _aiProjectClient; + + /// + /// Initializes a new instance of the class using the direct Responses API path. + /// + /// The Microsoft Foundry project endpoint. + /// The authentication credential. + /// The model deployment name. + /// The instructions that guide the agent's behavior. + /// Optional configuration options for the . + /// Optional name for the agent. + /// Optional description for the agent. + /// Optional tools to use when interacting with the agent. + /// Provides a way to customize the creation of the underlying . + /// Optional logger factory for creating loggers used by the agent. + /// Optional service provider for resolving dependencies required by AI functions. + public FoundryAgent( + Uri projectEndpoint, + AuthenticationTokenProvider credential, + string model, + string instructions, + AIProjectClientOptions? clientOptions = null, + string? name = null, + string? description = null, + IList? tools = null, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null, + IServiceProvider? services = null) + : base(CreateInnerAgent( + CreateProjectClient(projectEndpoint, credential, clientOptions), + model, instructions, name, description, tools, clientFactory, loggerFactory, services, + out var aiProjectClient)) + { + this._aiProjectClient = aiProjectClient; + } + + /// + /// Initializes a new instance of the class from an agent-specific endpoint. + /// + /// The agent-specific endpoint URI (must contain the agent name in the path). + /// The authentication credential. + /// Optional configuration options for the . + /// Optional tools to use when interacting with the agent. + /// Provides a way to customize the creation of the underlying . + /// Optional service provider for resolving dependencies required by AI functions. + public FoundryAgent( + Uri agentEndpoint, + AuthenticationTokenProvider credential, + AIProjectClientOptions? clientOptions = null, + IList? tools = null, + Func? clientFactory = null, + IServiceProvider? services = null) + : base(CreateInnerAgentFromEndpoint( + CreateProjectClient(agentEndpoint, credential, clientOptions), + agentEndpoint, tools, clientFactory, services, + out var aiProjectClient)) + { + this._aiProjectClient = aiProjectClient; + } + + /// + /// Internal constructor used by AsAIAgent extension methods that already have an and a configured . + /// + internal FoundryAgent(AIProjectClient aiProjectClient, ChatClientAgent innerAgent) + : base(Throw.IfNull(innerAgent)) + { + this._aiProjectClient = Throw.IfNull(aiProjectClient); + } + + #region Convenience methods + + /// + /// Creates a server-side conversation session that appears in the Foundry Project UI. + /// + /// A token to monitor for cancellation requests. + /// A linked to the newly created server-side conversation. + public async Task CreateConversationSessionAsync(CancellationToken cancellationToken = default) + { + var conversationsClient = this._aiProjectClient + .GetProjectOpenAIClient() + .GetProjectConversationsClient(); + + var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value; + + return (ChatClientAgentSession)await ((ChatClientAgent)this.InnerAgent).CreateSessionAsync(conversation.Id, cancellationToken).ConfigureAwait(false); + } + + #endregion + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + if (serviceKey is null && serviceType == typeof(AIProjectClient)) + { + return this._aiProjectClient; + } + + return base.GetService(serviceType, serviceKey); + } + + #region Private helpers + + private static ChatClientAgent CreateInnerAgent( + AIProjectClient aiProjectClient, + string model, string instructions, + string? name, string? description, + IList? tools, + Func? clientFactory, + ILoggerFactory? loggerFactory, + IServiceProvider? services, + out AIProjectClient outClient) + { + Throw.IfNullOrWhitespace(model); + Throw.IfNullOrWhitespace(instructions); + + outClient = aiProjectClient; + + ChatClientAgentOptions options = new() + { + Name = name, + Description = description, + ChatOptions = new ChatOptions + { + ModelId = model, + Instructions = instructions, + Tools = tools, + }, + }; + + return AzureAIProjectChatClientExtensions.CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services); + } + + private static ChatClientAgent CreateInnerAgentFromEndpoint( + AIProjectClient aiProjectClient, + Uri agentEndpoint, + IList? tools, + Func? clientFactory, + IServiceProvider? services, + out AIProjectClient outClient) + { + outClient = aiProjectClient; + + AgentReference agentReference = agentEndpoint.Segments[^1].TrimEnd('/'); + + ChatClientAgentOptions agentOptions = new() + { + Name = agentReference.Name, + ChatOptions = new() { Tools = tools }, + }; + + IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, services: services); + } + + private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null) + { + Throw.IfNull(endpoint); + Throw.IfNull(credential); + + clientOptions ??= new AIProjectClientOptions(); + clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, System.ClientModel.Primitives.PipelinePosition.PerCall); + return new AIProjectClient(endpoint, credential, clientOptions); + } + + #endregion +} diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs index 722d316330..2705611b57 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs @@ -7,6 +7,9 @@ namespace Microsoft.Agents.AI; internal static class RequestOptionsExtensions { + /// Gets the singleton that adds a MEAI user-agent header. + internal static PipelinePolicy UserAgentPolicy => MeaiUserAgentPolicy.Instance; + /// Creates a configured for use with Foundry Agents. public static RequestOptions ToRequestOptions(this CancellationToken cancellationToken, bool streaming) { diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs index 6f9f37518e..93b343b2de 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs @@ -19,7 +19,7 @@ using OpenAI.Responses; namespace Microsoft.Agents.AI.FoundryMemory; /// -/// Provides an Azure AI Foundry Memory backed that persists conversation messages as memories +/// Provides a Microsoft Foundry Memory backed that persists conversation messages as memories /// and retrieves related memories to augment the agent invocation context. /// /// @@ -49,7 +49,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider /// Initializes a new instance of the class. /// /// The Azure AI Project client configured for your Foundry project. - /// The name of the memory store in Azure AI Foundry. + /// The name of the memory store in Microsoft Foundry. /// A delegate that initializes the provider state on the first invocation, providing the scope for memory storage and retrieval. /// Provider options. /// Optional logger factory. @@ -87,17 +87,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; private static Func ValidateStateInitializer(Func stateInitializer) => - session => - { - State state = stateInitializer(session); - - if (state is null) - { - throw new InvalidOperationException("State initializer must return a non-null state."); - } - - return state; - }; + session => stateInitializer(session) ?? throw new InvalidOperationException("State initializer must return a non-null state."); /// protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) @@ -332,7 +322,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider /// Waits for all pending memory update operations to complete. /// /// - /// Memory extraction in Azure AI Foundry is asynchronous. This method polls the latest pending update + /// Memory extraction in Microsoft Foundry is asynchronous. This method polls the latest pending update /// and returns when it has completed, failed, or been superseded. Since updates are processed in order, /// completion of the latest update implies all prior updates have also been processed. /// diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs index 717df1d12b..6646c482a3 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.FoundryMemory; /// Allows scoping of memories for the . /// /// -/// Azure AI Foundry memories are scoped by a single string identifier that you control. +/// Microsoft Foundry memories are scoped by a single string identifier that you control. /// Common patterns include using a user ID, team ID, or other unique identifier /// to partition memories across different contexts. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs index bfc7bd36ff..6db870f8ec 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs @@ -175,7 +175,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj AIProjectClient client = this.GetAgentClient(); - agent = client.AsAIAgent(agentVersion, tools: null, clientFactory: null, services: null); + agent = client.AsAIAgent(agentVersion); FunctionInvokingChatClient? functionInvokingClient = agent.GetService(); if (functionInvokingClient is not null) diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs index 870dda648c..0f2b123fd9 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunStreamingTests.cs @@ -7,6 +7,8 @@ using Microsoft.Agents.AI; namespace AzureAI.IntegrationTests; +#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture +[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] public class AIProjectClientAgentRunStreamingPreviousResponseTests() : RunStreamingTests(() => new()) { public override Task RunWithNoMessageDoesNotFailAsync() @@ -16,7 +18,8 @@ public class AIProjectClientAgentRunStreamingPreviousResponseTests() : RunStream } } -public class AIProjectClientAgentRunStreamingConversationTests() : RunTests(() => new()) +[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] +public class AIProjectClientAgentRunStreamingConversationTests() : RunStreamingTests(() => new()) { public override Func> AgentRunOptionsFactory => async () => { diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs index af4cee82e6..ad10ec5b7a 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentRunTests.cs @@ -7,6 +7,8 @@ using Microsoft.Agents.AI; namespace AzureAI.IntegrationTests; +#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture +[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] public class AIProjectClientAgentRunPreviousResponseTests() : RunTests(() => new()) { public override Task RunWithNoMessageDoesNotFailAsync() @@ -16,6 +18,7 @@ public class AIProjectClientAgentRunPreviousResponseTests() : RunTests(() => new()) { public override Func> AgentRunOptionsFactory => async () => diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs index 9db48f3832..b3782a6601 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Threading.Tasks; using AgentConformance.IntegrationTests; using AgentConformance.IntegrationTests.Support; @@ -8,6 +9,8 @@ using Microsoft.Extensions.AI; namespace AzureAI.IntegrationTests; +#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture +[Obsolete("Use FoundryVersionedAgentStructuredOutputRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRunTests>(() => new AIProjectClientStructuredOutputFixture()) { private const string NotSupported = "AIProjectClient does not support specifying structured output type at invocation time."; @@ -87,6 +90,7 @@ public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRu /// /// Represents a fixture for testing AIProjectClient with structured output of type provided at agent initialization. /// +[Obsolete("Use FoundryVersionedAgentStructuredOutputFixture instead.")] public class AIProjectClientStructuredOutputFixture : AIProjectClientFixture { public override async ValueTask InitializeAsync() diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs index 3b0c1c27b4..0e507e44c1 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunStreamingTests.cs @@ -1,10 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Threading.Tasks; using AgentConformance.IntegrationTests; namespace AzureAI.IntegrationTests; +#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture +[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] public class AIProjectClientChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests(() => new()) { public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs index 1e47d0a970..a0ee72ebd4 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientChatClientAgentRunTests.cs @@ -1,10 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Threading.Tasks; using AgentConformance.IntegrationTests; namespace AzureAI.IntegrationTests; +#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture +[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")] public class AIProjectClientChatClientAgentRunTests() : ChatClientAgentRunTests(() => new()) { public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs index 425197853e..bc5f38acf2 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Tests intentionally exercise obsolete extension methods + using System; using System.IO; using System.Threading.Tasks; @@ -7,6 +9,7 @@ using AgentConformance.IntegrationTests.Support; using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; using Microsoft.Extensions.AI; using OpenAI.Files; using OpenAI.Responses; @@ -14,6 +17,7 @@ using Shared.IntegrationTests; namespace AzureAI.IntegrationTests; +[Obsolete("Use FoundryVersionedAgentCreateTests instead. These tests exercise obsolete AIProjectClient extension methods.")] public class AIProjectClientCreateTests { private readonly AIProjectClient _client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential()); @@ -51,7 +55,7 @@ public class AIProjectClientCreateTests Assert.NotNull(agent); Assert.Equal(AgentName, agent.Name); Assert.Equal(AgentDescription, agent.Description); - Assert.Equal(AgentInstructions, agent.Instructions); + Assert.Equal(AgentInstructions, agent.GetService()!.Instructions); var agentRecord = await this._client.Agents.GetAgentAsync(agent.Name); Assert.NotNull(agentRecord); @@ -275,7 +279,7 @@ public class AIProjectClientCreateTests try { // Step 2: Wrap the agent version using AsAIAgent extension. - ChatClientAgent agent = this._client.AsAIAgent(agentVersion); + FoundryAgent agent = this._client.AsAIAgent(agentVersion); // Assert the agent was created correctly and retains version metadata. Assert.NotNull(agent); @@ -327,7 +331,7 @@ public class AIProjectClientCreateTests static string GetWeather(string location) => $"The weather in {location} is sunny with a high of 23C."; var weatherFunction = AIFunctionFactory.Create(GetWeather); - ChatClientAgent agent = createMechanism switch + FoundryAgent agent = createMechanism switch { "CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync( model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs index 42892b99b3..112c76571b 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Tests intentionally exercise obsolete extension methods + using System; using System.Collections.Generic; using System.Linq; @@ -9,18 +11,20 @@ using AgentConformance.IntegrationTests.Support; using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; using Microsoft.Extensions.AI; using OpenAI.Responses; using Shared.IntegrationTests; namespace AzureAI.IntegrationTests; +[Obsolete("Use FoundryVersionedAgentFixture instead. These tests exercise obsolete AIProjectClient extension methods.")] public class AIProjectClientFixture : IChatClientAgentFixture { - private ChatClientAgent _agent = null!; + private FoundryAgent _agent = null!; private AIProjectClient _client = null!; - public IChatClient ChatClient => this._agent.ChatClient; + public IChatClient ChatClient => this._agent.GetService()!.ChatClient; public AIAgent Agent => this._agent; @@ -115,14 +119,14 @@ public class AIProjectClientFixture : IChatClientAgentFixture string instructions = "You are a helpful assistant.", IList? aiTools = null) { - return await this._client.CreateAIAgentAsync(GenerateUniqueAgentName(name), model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), instructions: instructions, tools: aiTools); + return (await this._client.CreateAIAgentAsync(GenerateUniqueAgentName(name), model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), instructions: instructions, tools: aiTools)).GetService()!; } public async Task CreateChatClientAgentAsync(ChatClientAgentOptions options) { options.Name ??= GenerateUniqueAgentName("HelpfulAssistant"); - return await this._client.CreateAIAgentAsync(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), options); + return (await this._client.CreateAIAgentAsync(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), options)).GetService()!; } public static string GenerateUniqueAgentName(string baseName) => @@ -170,12 +174,13 @@ public class AIProjectClientFixture : IChatClientAgentFixture public virtual async ValueTask InitializeAsync() { this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential()); - this._agent = await this.CreateChatClientAgentAsync(); + this._agent = await this._client.CreateAIAgentAsync(GenerateUniqueAgentName("HelpfulAssistant"), model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), instructions: "You are a helpful assistant."); } public async Task InitializeAsync(ChatClientAgentOptions options) { this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential()); - this._agent = await this.CreateChatClientAgentAsync(options); + options.Name ??= GenerateUniqueAgentName("HelpfulAssistant"); + this._agent = await this._client.CreateAIAgentAsync(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), options); } } diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs new file mode 100644 index 0000000000..5895ceb8b9 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunStreamingTests.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace AzureAI.IntegrationTests; + +public class ResponsesAgentChatClientRunStreamingTests() : ChatClientAgentRunStreamingTests(() => new()) +{ + public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() + { + Assert.Skip("No messages is not supported"); + return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync(); + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunTests.cs new file mode 100644 index 0000000000..d80b25deb2 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentChatClientRunTests.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace AzureAI.IntegrationTests; + +public class ResponsesAgentChatClientRunTests() : ChatClientAgentRunTests(() => new()) +{ + public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() + { + Assert.Skip("No messages is not supported"); + return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync(); + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentExtensionCreateTests.cs b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentExtensionCreateTests.cs new file mode 100644 index 0000000000..cd8dc6cb03 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentExtensionCreateTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests.Support; +using Azure.AI.Projects; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; +using Microsoft.Extensions.AI; +using Shared.IntegrationTests; + +namespace AzureAI.IntegrationTests; + +/// +/// Integration tests for non-versioned creation via extension methods. +/// +public class ResponsesAgentExtensionCreateTests +{ + private static Uri Endpoint => new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)); + + private static string Model => TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName); + + private readonly AIProjectClient _client = new(Endpoint, TestAzureCliCredentials.CreateAzureCliCredential()); + + [Fact] + public async Task AsAIAgent_WithModelAndInstructions_CreatesChatClientAgentAndRunsAsync() + { + // Arrange + const string AgentName = "ResponsesAgentExtensionSimple"; + const string AgentDescription = "Integration test agent created from AIProjectClient.AsAIAgent(model, instructions)."; + const string VerificationToken = "integration-extension-ok"; + + FoundryAgent agent = this._client.AsAIAgent( + model: Model, + instructions: $"You are a helpful assistant. When asked for verification, reply with exactly '{VerificationToken}'.", + name: AgentName, + description: AgentDescription); + + AgentSession session = await agent.CreateSessionAsync(); + + try + { + // Act + AgentResponse response = await agent.RunAsync("Return the verification token.", session); + + // Assert + Assert.NotNull(agent); + Assert.Equal(AgentName, agent.Name); + Assert.Equal(AgentDescription, agent.Description); + Assert.Same(this._client, agent.GetService()); + Assert.NotNull(agent.GetService()); + Assert.Contains(VerificationToken, response.Text, StringComparison.OrdinalIgnoreCase); + } + finally + { + await DeleteSessionAsync(this._client, session); + } + } + + [Fact] + public async Task AsAIAgent_WithOptions_CreatesChatClientAgentAndRunsAsync() + { + // Arrange + const string VerificationToken = "integration-options-ok"; + ChatClientAgentOptions options = new() + { + Name = "ResponsesAgentExtensionOptions", + Description = "Integration test agent created from AIProjectClient.AsAIAgent(options).", + ChatOptions = new ChatOptions + { + ModelId = Model, + Instructions = $"You are a helpful assistant. When asked for verification, reply with exactly '{VerificationToken}'.", + }, + }; + + FoundryAgent agent = this._client.AsAIAgent(options); + ChatClientAgentSession session = await agent.CreateConversationSessionAsync(); + + try + { + // Act + AgentResponse response = await agent.RunAsync("Return the verification token.", session); + + // Assert + Assert.StartsWith("conv_", session.ConversationId, StringComparison.OrdinalIgnoreCase); + Assert.Equal(options.Name, agent.Name); + Assert.Equal(options.Description, agent.Description); + Assert.Same(this._client, agent.GetService()); + Assert.Contains(VerificationToken, response.Text, StringComparison.OrdinalIgnoreCase); + } + finally + { + await DeleteSessionAsync(this._client, session); + } + } + + private static async Task DeleteSessionAsync(AIProjectClient client, AgentSession session) + { + ChatClientAgentSession typedSession = (ChatClientAgentSession)session; + + if (typedSession.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true) + { + await client.GetProjectOpenAIClient().GetProjectConversationsClient().DeleteConversationAsync(typedSession.ConversationId); + } + else if (typedSession.ConversationId?.StartsWith("resp_", StringComparison.OrdinalIgnoreCase) == true) + { + await DeleteResponseChainAsync(client, typedSession.ConversationId); + } + } + + private static async Task DeleteResponseChainAsync(AIProjectClient client, string lastResponseId) + { + var responsesClient = client.GetProjectOpenAIClient().GetProjectResponsesClient(); + var response = await responsesClient.GetResponseAsync(lastResponseId); + await responsesClient.DeleteResponseAsync(lastResponseId); + + if (response.Value.PreviousResponseId is not null) + { + await DeleteResponseChainAsync(client, response.Value.PreviousResponseId); + } + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentFixture.cs b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentFixture.cs new file mode 100644 index 0000000000..ec678a00d9 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentFixture.cs @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; +using AgentConformance.IntegrationTests.Support; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.AzureAI; +using Microsoft.Extensions.AI; +using OpenAI.Responses; +using Shared.IntegrationTests; + +namespace AzureAI.IntegrationTests; + +/// +/// Integration test fixture that creates non-versioned Responses agents via the direct AIProjectClient.AsAIAgent(...) path. +/// +public class ResponsesAgentFixture : IChatClientAgentFixture +{ + private FoundryAgent _agent = null!; + private AIProjectClient _client = null!; + + public IChatClient ChatClient => this._agent.GetService()!.ChatClient; + + public AIAgent Agent => this._agent; + + public async Task CreateConversationAsync() + { + var response = await this._client.GetProjectOpenAIClient().GetProjectConversationsClient().CreateProjectConversationAsync(); + return response.Value.Id; + } + + public async Task> GetChatHistoryAsync(AIAgent agent, AgentSession session) + { + ChatClientAgentSession chatClientSession = (ChatClientAgentSession)session; + + if (chatClientSession.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true) + { + return await this.GetChatHistoryFromConversationAsync(chatClientSession.ConversationId); + } + + if (chatClientSession.ConversationId?.StartsWith("resp_", StringComparison.OrdinalIgnoreCase) == true) + { + return await this.GetChatHistoryFromResponsesChainAsync(chatClientSession.ConversationId); + } + + ChatHistoryProvider? chatHistoryProvider = agent.GetService(); + + if (chatHistoryProvider is null) + { + return []; + } + + return (await chatHistoryProvider.InvokingAsync(new(agent, session, []))).ToList(); + } + + private async Task> GetChatHistoryFromResponsesChainAsync(string conversationId) + { + var openAIResponseClient = this._client.GetProjectOpenAIClient().GetProjectResponsesClient(); + var inputItems = await openAIResponseClient.GetResponseInputItemsAsync(conversationId).ToListAsync(); + var response = await openAIResponseClient.GetResponseAsync(conversationId); + ResponseItem responseItem = response.Value.OutputItems.FirstOrDefault()!; + + var previousMessages = inputItems + .Select(ConvertToChatMessage) + .Where(x => x.Text != "You are a helpful assistant.") + .Reverse(); + + ChatMessage responseMessage = ConvertToChatMessage(responseItem); + + return [.. previousMessages, responseMessage]; + } + + private static ChatMessage ConvertToChatMessage(ResponseItem item) + { + if (item is MessageResponseItem messageResponseItem) + { + ChatRole role = messageResponseItem.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant; + return new ChatMessage(role, messageResponseItem.Content.FirstOrDefault()?.Text); + } + + throw new NotSupportedException("This test currently only supports text messages"); + } + + private async Task> GetChatHistoryFromConversationAsync(string conversationId) + { + List messages = []; + await foreach (AgentResponseItem item in this._client.GetProjectOpenAIClient().GetProjectConversationsClient().GetProjectConversationItemsAsync(conversationId, order: "asc")) + { + var openAIItem = item.AsResponseResultItem(); + if (openAIItem is MessageResponseItem messageItem) + { + messages.Add(new ChatMessage + { + Role = new ChatRole(messageItem.Role.ToString()), + Contents = messageItem.Content + .Where(c => c.Kind is ResponseContentPartKind.OutputText or ResponseContentPartKind.InputText) + .Select(c => new TextContent(c.Text)) + .ToList() + }); + } + } + + return messages; + } + + public Task CreateChatClientAgentAsync( + string name = "HelpfulAssistant", + string instructions = "You are a helpful assistant.", + IList? aiTools = null) + { + return Task.FromResult(this._client.AsAIAgent( + model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), + instructions: instructions, + name: name, + tools: aiTools).GetService()!); + } + + public Task CreateChatClientAgentAsync(ChatClientAgentOptions options) + { + return Task.FromResult(this._client.AsAIAgent(options).GetService()!); + } + + // Non-versioned Responses agents have no server-side agent to delete. + public Task DeleteAgentAsync(ChatClientAgent agent) => Task.CompletedTask; + + public async Task DeleteSessionAsync(AgentSession session) + { + ChatClientAgentSession typedSession = (ChatClientAgentSession)session; + + if (typedSession.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true) + { + await this._client.GetProjectOpenAIClient().GetProjectConversationsClient().DeleteConversationAsync(typedSession.ConversationId); + } + else if (typedSession.ConversationId?.StartsWith("resp_", StringComparison.OrdinalIgnoreCase) == true) + { + await this.DeleteResponseChainAsync(typedSession.ConversationId!); + } + } + + private async Task DeleteResponseChainAsync(string lastResponseId) + { + var response = await this._client.GetProjectOpenAIClient().GetProjectResponsesClient().GetResponseAsync(lastResponseId); + await this._client.GetProjectOpenAIClient().GetProjectResponsesClient().DeleteResponseAsync(lastResponseId); + + if (response.Value.PreviousResponseId is not null) + { + await this.DeleteResponseChainAsync(response.Value.PreviousResponseId); + } + } + + // Non-versioned Responses agents have no server-side agent to clean up on dispose. + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + return default; + } + + public virtual ValueTask InitializeAsync() + { + this._client = new AIProjectClient( + new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), + TestAzureCliCredentials.CreateAzureCliCredential()); + + this._agent = this._client.AsAIAgent( + model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), + instructions: "You are a helpful assistant.", + name: "HelpfulAssistant"); + + return default; + } + + public ValueTask InitializeAsync(ChatClientAgentOptions options) + { + this._client = new AIProjectClient( + new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), + TestAzureCliCredentials.CreateAzureCliCredential()); + + this._agent = this._client.AsAIAgent(options); + + return default; + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunStreamingTests.cs b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunStreamingTests.cs new file mode 100644 index 0000000000..5f21c316c4 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunStreamingTests.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; +using Microsoft.Agents.AI; + +namespace AzureAI.IntegrationTests; + +public class ResponsesAgentRunStreamingPreviousResponseTests() : RunStreamingTests(() => new()) +{ + public override Task RunWithNoMessageDoesNotFailAsync() + { + Assert.Skip("No messages is not supported"); + return base.RunWithNoMessageDoesNotFailAsync(); + } +} + +public class ResponsesAgentRunStreamingConversationTests() : RunStreamingTests(() => new()) +{ + public override Func> AgentRunOptionsFactory => async () => + { + var conversationId = await this.Fixture.CreateConversationAsync(); + return new ChatClientAgentRunOptions(new() { ConversationId = conversationId }); + }; + + public override Task RunWithNoMessageDoesNotFailAsync() + { + Assert.Skip("No messages is not supported"); + return base.RunWithNoMessageDoesNotFailAsync(); + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunTests.cs new file mode 100644 index 0000000000..71460f7737 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentRunTests.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; +using Microsoft.Agents.AI; + +namespace AzureAI.IntegrationTests; + +public class ResponsesAgentRunPreviousResponseTests() : RunTests(() => new()) +{ + public override Task RunWithNoMessageDoesNotFailAsync() + { + Assert.Skip("No messages is not supported"); + return base.RunWithNoMessageDoesNotFailAsync(); + } +} + +public class ResponsesAgentRunConversationTests() : RunTests(() => new()) +{ + public override Func> AgentRunOptionsFactory => async () => + { + var conversationId = await this.Fixture.CreateConversationAsync(); + return new ChatClientAgentRunOptions(new() { ConversationId = conversationId }); + }; + + public override Task RunWithNoMessageDoesNotFailAsync() + { + Assert.Skip("No messages is not supported"); + return base.RunWithNoMessageDoesNotFailAsync(); + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs new file mode 100644 index 0000000000..19ebdb4e28 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/ResponsesAgentStructuredOutputRunTests.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; +using AgentConformance.IntegrationTests.Support; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Shared.IntegrationTests; + +namespace AzureAI.IntegrationTests; + +public class ResponsesAgentStructuredOutputRunTests() : StructuredOutputRunTests>(() => new()) +{ + private const string NotSupported = "The direct Responses AsAIAgent path does not support specifying structured output type at invocation time."; + + /// + /// Verifies that response format provided at agent initialization is used when invoking RunAsync. + /// + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + public async Task RunWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync() + { + // Arrange + AIAgent agent = this.Fixture.Agent; + AgentSession session = await agent.CreateSessionAsync(); + await using var cleanup = new SessionCleanup(session, this.Fixture); + + // Act + AgentResponse response = await agent.RunAsync(new ChatMessage(ChatRole.User, "Provide information about the capital of France."), session); + + // Assert + Assert.NotNull(response); + Assert.Single(response.Messages); + Assert.Contains("Paris", response.Text); + Assert.True(TryDeserialize(response.Text, AgentAbstractionsJsonUtilities.DefaultOptions, out CityInfo cityInfo)); + Assert.Equal("Paris", cityInfo.Name); + } + + /// + /// Verifies that generic RunAsync works when structured output is configured at agent initialization. + /// + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + public async Task RunGenericWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync() + { + // Arrange + AIAgent agent = this.Fixture.Agent; + AgentSession session = await agent.CreateSessionAsync(); + await using var cleanup = new SessionCleanup(session, this.Fixture); + + // Act + AgentResponse response = await agent.RunAsync( + new ChatMessage(ChatRole.User, "Provide information about the capital of France."), + session); + + // Assert + Assert.NotNull(response); + Assert.Single(response.Messages); + Assert.Contains("Paris", response.Text); + + Assert.NotNull(response.Result); + Assert.Equal("Paris", response.Result.Name); + } + + public override Task RunWithGenericTypeReturnsExpectedResultAsync() + { + Assert.Skip(NotSupported); + return base.RunWithGenericTypeReturnsExpectedResultAsync(); + } + + public override Task RunWithResponseFormatReturnsExpectedResultAsync() + { + Assert.Skip(NotSupported); + return base.RunWithResponseFormatReturnsExpectedResultAsync(); + } + + public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync() + { + Assert.Skip(NotSupported); + return base.RunWithPrimitiveTypeReturnsExpectedResultAsync(); + } +} + +/// +/// Fixture for testing the direct Responses path with structured output of type provided at agent initialization. +/// +public class ResponsesAgentStructuredOutputFixture : ResponsesAgentFixture +{ + public override ValueTask InitializeAsync() + { + ChatClientAgentOptions agentOptions = new() + { + ChatOptions = new ChatOptions() + { + ModelId = TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), + ResponseFormat = ChatResponseFormat.ForJsonSchema(AgentAbstractionsJsonUtilities.DefaultOptions) + }, + }; + + return this.InitializeAsync(agentOptions); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs index 261faaded8..5c954d30e8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs @@ -21,11 +21,173 @@ using OpenAI.Responses; namespace Microsoft.Agents.AI.AzureAI.UnitTests; +#pragma warning disable CS0618 /// /// Unit tests for the class. /// +[Obsolete("Includes coverage for obsolete AIProjectClient compatibility extension methods.")] public sealed class AzureAIProjectChatClientExtensionsTests { + #region AsAIAgent(AIProjectClient, model, instructions) Tests + + /// + /// Verify that the non-versioned AsAIAgent overload throws ArgumentNullException when AIProjectClient is null. + /// + [Fact] + public void AsAIAgent_WithModelAndInstructions_WithNullClient_ThrowsArgumentNullException() + { + // Arrange + AIProjectClient? client = null; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + client!.AsAIAgent("gpt-4o-mini", "You are helpful.")); + + Assert.Equal("aiProjectClient", exception.ParamName); + } + + /// + /// Verify that the non-versioned AsAIAgent overload creates a valid ChatClientAgent. + /// + [Fact] + public void AsAIAgent_WithModelAndInstructions_CreatesChatClientAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + List tools = + [ + AIFunctionFactory.Create(() => "test", "test_function", "A test function") + ]; + + // Act + FoundryAgent agent = client.AsAIAgent( + "gpt-4o-mini", + "You are helpful.", + name: "test-agent", + description: "A test agent", + tools: tools); + + // Assert + Assert.NotNull(agent); + Assert.Equal("test-agent", agent.Name); + Assert.Equal("A test agent", agent.Description); + Assert.Same(client, agent.GetService()); + Assert.NotNull(agent.GetService()); + } + + /// + /// Verify that the non-versioned AsAIAgent overload applies the clientFactory. + /// + [Fact] + public void AsAIAgent_WithModelAndInstructions_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + TestChatClient? testChatClient = null; + + // Act + FoundryAgent agent = client.AsAIAgent( + "gpt-4o-mini", + "You are helpful.", + clientFactory: innerClient => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + TestChatClient? retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that the options-based non-versioned AsAIAgent overload creates a valid ChatClientAgent. + /// + [Fact] + public void AsAIAgent_WithOptions_CreatesChatClientAgent() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ChatClientAgentOptions options = new() + { + Name = "options-agent", + Description = "Agent from options", + ChatOptions = new ChatOptions + { + ModelId = "gpt-4o-mini", + Instructions = "You are helpful.", + }, + }; + + // Act + FoundryAgent agent = client.AsAIAgent(options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("options-agent", agent.Name); + Assert.Equal("Agent from options", agent.Description); + Assert.Same(client, agent.GetService()); + } + + /// + /// Verify that the non-versioned AsAIAgent overload adds the MEAI user-agent header to Responses API requests. + /// + [Fact] + public async Task AsAIAgent_WithModelAndInstructions_UserAgentHeaderAddedToResponsesRequestsAsync() + { + // Arrange + bool userAgentFound = false; + using HttpHandlerAssert httpHandler = new(request => + { + if (request.Headers.TryGetValues("User-Agent", out IEnumerable? values)) + { + foreach (string value in values) + { + if (value.Contains("MEAI")) + { + userAgentFound = true; + } + } + } + + if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + TestDataUtil.GetOpenAIDefaultResponseJson(), + Encoding.UTF8, + "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + }); + +#pragma warning disable CA5399 + using HttpClient httpClient = new(httpHandler); +#pragma warning restore CA5399 + + AIProjectClient aiProjectClient = new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + FoundryAgent agent = aiProjectClient.AsAIAgent( + "gpt-4o-mini", + "You are helpful."); + + // Act + AgentSession session = await agent.CreateSessionAsync(); + await agent.RunAsync("Hello", session); + + // Assert + Assert.True(userAgentFound, "MEAI user-agent header was not found in any request"); + } + + #endregion + #region AsAIAgent(AIProjectClient, AgentRecord) Tests /// @@ -199,7 +361,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -217,7 +379,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } #endregion @@ -409,7 +571,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); var chatClient = agent.GetService(); Assert.NotNull(chatClient); var agentVersion = chatClient.GetService(); @@ -458,7 +620,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -481,7 +643,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); Assert.Equal("test-agent", agent.Name); - Assert.Equal("Test instructions", agent.Instructions); + Assert.Equal("Test instructions", agent.GetService()!.Instructions); } /// @@ -570,7 +732,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -592,7 +754,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -612,7 +774,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -638,7 +800,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); var agentVersion = agent.GetService(); Assert.NotNull(agentVersion); if (agentVersion.Definition is PromptAgentDefinition promptDef) @@ -677,7 +839,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); var agentVersion = agent.GetService(); Assert.NotNull(agentVersion); if (agentVersion.Definition is PromptAgentDefinition promptDef) @@ -721,7 +883,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); var agentVersion = agent.GetService(); Assert.NotNull(agentVersion); Assert.IsType(agentVersion.Definition); @@ -783,7 +945,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); var chatClient = agent.GetService(); Assert.NotNull(chatClient); var agentVersion = chatClient.GetService(); @@ -815,7 +977,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); var agentVersion = agent.GetService(); Assert.NotNull(agentVersion); if (agentVersion.Definition is PromptAgentDefinition promptDef) @@ -843,7 +1005,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -864,7 +1026,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } #endregion @@ -912,7 +1074,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); var agentVersion = agent.GetService(); Assert.NotNull(agentVersion); if (agentVersion.Definition is PromptAgentDefinition promptDef) @@ -952,7 +1114,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -979,7 +1141,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -1011,7 +1173,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } #endregion @@ -1064,7 +1226,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); Assert.Equal("test-agent", agent.Name); - Assert.Equal("Custom instructions", agent.Instructions); + Assert.Equal("Custom instructions", agent.GetService()!.Instructions); Assert.Equal("Custom description", agent.Description); } @@ -1353,7 +1515,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Assert Assert.NotNull(agent); Assert.Equal(AgentName, agent.Name); - Assert.Equal(Instructions, agent.Instructions); + Assert.Equal(Instructions, agent.GetService()!.Instructions); var wrappedClient = agent.GetService(); Assert.NotNull(wrappedClient); } @@ -1957,11 +2119,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -1983,11 +2145,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2011,11 +2173,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2044,11 +2206,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2077,11 +2239,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } #endregion @@ -2107,11 +2269,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2133,11 +2295,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2159,11 +2321,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } #endregion @@ -2186,7 +2348,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); @@ -2208,7 +2370,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); @@ -2303,11 +2465,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } #endregion @@ -2330,7 +2492,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); @@ -2352,7 +2514,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); @@ -2374,7 +2536,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); @@ -2400,7 +2562,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - should not throw even without tools when UseProvidedChatClientAsIs is true - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); @@ -2432,7 +2594,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - UseProvidedChatClientAsIs is true, but provided AIFunctions should still be matched and preserved - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); @@ -2463,11 +2625,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); // Verify the agent ID is generated from server-returned name ("agent_abc123") and "latest" Assert.Equal("agent_abc123:latest", agent.Id); } @@ -2525,11 +2687,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); // Verify the agent ID is generated from server-returned name ("agent_abc123") and "latest" Assert.Equal("agent_abc123:latest", agent.Id); } @@ -2679,11 +2841,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2707,11 +2869,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); + FoundryAgent agent = await testClient.Client.CreateAIAgentAsync("test-model", options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2745,11 +2907,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2783,11 +2945,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests }; // Act - ChatClientAgent agent = await client.GetAIAgentAsync(options); + FoundryAgent agent = await client.GetAIAgentAsync(options); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } /// @@ -2841,11 +3003,11 @@ public sealed class AzureAIProjectChatClientExtensionsTests AgentVersion agentVersion = ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson(agentDefinition: definition)))!; // Act - no tools provided, but requireInvocableTools is false when no tools param is passed - ChatClientAgent agent = client.AsAIAgent(agentVersion); + FoundryAgent agent = client.AsAIAgent(agentVersion); // Assert Assert.NotNull(agent); - Assert.IsType(agent); + Assert.IsType(agent); } #endregion @@ -3135,17 +3297,16 @@ public sealed class AzureAIProjectChatClientExtensionsTests /// private sealed class MockPipelineResponse : PipelineResponse { - private readonly int _status; private readonly MockPipelineResponseHeaders _headers; public MockPipelineResponse(int status, BinaryData? content = null) { - this._status = status; + this.Status = status; this.Content = content ?? BinaryData.Empty; this._headers = new MockPipelineResponseHeaders(); } - public override int Status => this._status; + public override int Status { get; } public override string ReasonPhrase => "OK"; @@ -3206,9 +3367,10 @@ public sealed class AzureAIProjectChatClientExtensionsTests /// /// Helper method to access internal ChatOptions property via reflection. /// - private static ChatOptions? GetAgentChatOptions(ChatClientAgent agent) + private static ChatOptions? GetAgentChatOptions(AIAgent agent) { - if (agent is null) + ChatClientAgent? chatClientAgent = agent as ChatClientAgent ?? agent.GetService(); + if (chatClientAgent is null) { return null; } @@ -3219,7 +3381,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - return chatOptionsProperty?.GetValue(agent) as ChatOptions; + return chatOptionsProperty?.GetValue(chatClientAgent) as ChatOptions; } /// @@ -3260,6 +3422,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests } } } +#pragma warning restore CS0618 /// /// Provides test data for invalid agent name validation tests. diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs index 5c61e0b457..8582ccc2b6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientTests.cs @@ -10,6 +10,8 @@ using Azure.AI.Projects; namespace Microsoft.Agents.AI.AzureAI.UnitTests; +#pragma warning disable CS0618 +[Obsolete("Uses obsolete AIProjectClient.GetAIAgentAsync compatibility extensions while validating chat-client behavior.")] public class AzureAIProjectChatClientTests { /// @@ -43,9 +45,12 @@ public class AzureAIProjectChatClientTests using var httpClient = new HttpClient(httpHandler); #pragma warning restore CA5399 - var client = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + AIProjectClient projectClient = new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - var agent = await client.GetAIAgentAsync( + var agent = await projectClient.GetAIAgentAsync( new ChatClientAgentOptions { Name = "test-agent", @@ -92,9 +97,12 @@ public class AzureAIProjectChatClientTests using var httpClient = new HttpClient(httpHandler); #pragma warning restore CA5399 - var client = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + AIProjectClient projectClient = new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - var agent = await client.GetAIAgentAsync( + var agent = await projectClient.GetAIAgentAsync( new ChatClientAgentOptions { Name = "test-agent", @@ -141,9 +149,12 @@ public class AzureAIProjectChatClientTests using var httpClient = new HttpClient(httpHandler); #pragma warning restore CA5399 - var client = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + AIProjectClient projectClient = new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - var agent = await client.GetAIAgentAsync( + var agent = await projectClient.GetAIAgentAsync( new ChatClientAgentOptions { Name = "test-agent", @@ -190,9 +201,12 @@ public class AzureAIProjectChatClientTests using var httpClient = new HttpClient(httpHandler); #pragma warning restore CA5399 - var client = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + AIProjectClient projectClient = new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - var agent = await client.GetAIAgentAsync( + var agent = await projectClient.GetAIAgentAsync( new ChatClientAgentOptions { Name = "test-agent", @@ -208,3 +222,4 @@ public class AzureAIProjectChatClientTests Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId); } } +#pragma warning restore CS0618 diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FoundryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FoundryAgentTests.cs new file mode 100644 index 0000000000..1b77e8f57e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/FoundryAgentTests.cs @@ -0,0 +1,316 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Azure.AI.Projects; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.AzureAI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class FoundryAgentTests +{ + private static readonly Uri s_testEndpoint = new("https://test.services.ai.azure.com/api/projects/test-project"); + + #region Constructor validation tests + + [Fact] + public void Constructor_WithNullEndpoint_ThrowsArgumentNullException() + { + ArgumentNullException exception = Assert.Throws(() => + new FoundryAgent( + projectEndpoint: null!, + credential: new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test instructions")); + + Assert.Equal("endpoint", exception.ParamName); + } + + [Fact] + public void Constructor_WithNullCredential_ThrowsArgumentNullException() + { + ArgumentNullException exception = Assert.Throws(() => + new FoundryAgent( + projectEndpoint: s_testEndpoint, + credential: null!, + model: "gpt-4o-mini", + instructions: "Test instructions")); + + Assert.Equal("credential", exception.ParamName); + } + + [Fact] + public void Constructor_WithNullModel_ThrowsArgumentException() + { + Assert.ThrowsAny(() => + new FoundryAgent( + projectEndpoint: s_testEndpoint, + credential: new FakeAuthenticationTokenProvider(), + model: null!, + instructions: "Test instructions")); + } + + [Fact] + public void Constructor_WithEmptyModel_ThrowsArgumentException() + { + Assert.ThrowsAny(() => + new FoundryAgent( + projectEndpoint: s_testEndpoint, + credential: new FakeAuthenticationTokenProvider(), + model: string.Empty, + instructions: "Test instructions")); + } + + [Fact] + public void Constructor_WithNullInstructions_ThrowsArgumentException() + { + Assert.ThrowsAny(() => + new FoundryAgent( + projectEndpoint: s_testEndpoint, + credential: new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: null!)); + } + + [Fact] + public void Constructor_WithValidParams_CreatesAgent() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "You are a helpful assistant.", + name: "test-agent", + description: "A test agent"); + + Assert.NotNull(agent); + Assert.Equal("test-agent", agent.Name); + Assert.Equal("A test agent", agent.Description); + } + + #endregion + + #region Property tests + + [Fact] + public void Name_ReturnsConfiguredName() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test", + name: "my-agent"); + + Assert.Equal("my-agent", agent.Name); + } + + [Fact] + public void Description_ReturnsConfiguredDescription() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test", + description: "Agent description"); + + Assert.Equal("Agent description", agent.Description); + } + + [Fact] + public void GetService_ReturnsAIProjectClient() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test"); + + AIProjectClient? client = agent.GetService(); + + Assert.NotNull(client); + } + + [Fact] + public void GetService_ReturnsChatClientAgent() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test"); + + ChatClientAgent? innerAgent = agent.GetService(); + + Assert.NotNull(innerAgent); + } + + [Fact] + public void GetService_ReturnsIChatClient() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test"); + + IChatClient? chatClient = agent.GetService(); + + Assert.NotNull(chatClient); + } + + [Fact] + public void GetService_ReturnsChatClientMetadata() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test"); + + ChatClientMetadata? metadata = agent.GetService(); + + Assert.NotNull(metadata); + Assert.Equal("microsoft.foundry", metadata.ProviderName); + } + + [Fact] + public void GetService_ReturnsNullForUnknownType() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test"); + + Assert.Null(agent.GetService()); + } + + #endregion + + #region Functional tests + + [Fact] + public async Task RunAsync_SendsRequestToResponsesAPIAsync() + { + bool requestTriggered = false; + using HttpHandlerAssert httpHandler = new(request => + { + if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) + { + requestTriggered = true; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + TestDataUtil.GetOpenAIDefaultResponseJson(), + Encoding.UTF8, + "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + }); + +#pragma warning disable CA5399 + using HttpClient httpClient = new(httpHandler); +#pragma warning restore CA5399 + + AIProjectClientOptions clientOptions = new() + { + Transport = new HttpClientPipelineTransport(httpClient) + }; + + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "You are a helpful assistant.", + clientOptions: clientOptions); + + AgentSession session = await agent.CreateSessionAsync(); + await agent.RunAsync("Hello", session); + + Assert.True(requestTriggered); + } + + [Fact] + public void Constructor_WithChatClientFactory_AppliesFactory() + { + bool factoryCalled = false; + + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test", + clientFactory: client => + { + factoryCalled = true; + return client; + }); + + Assert.True(factoryCalled); + Assert.NotNull(agent); + } + + [Fact] + public async Task Constructor_UserAgentHeaderAddedToRequestsAsync() + { + bool userAgentFound = false; + using HttpHandlerAssert httpHandler = new(request => + { + if (request.Headers.TryGetValues("User-Agent", out System.Collections.Generic.IEnumerable? values)) + { + foreach (string value in values) + { + if (value.StartsWith("MEAI/", StringComparison.OrdinalIgnoreCase)) + { + userAgentFound = true; + } + } + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + TestDataUtil.GetOpenAIDefaultResponseJson(), + Encoding.UTF8, + "application/json") + }; + }); + +#pragma warning disable CA5399 + using HttpClient httpClient = new(httpHandler); +#pragma warning restore CA5399 + + AIProjectClientOptions clientOptions = new() + { + Transport = new HttpClientPipelineTransport(httpClient) + }; + + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test", + clientOptions: clientOptions); + + AgentSession session = await agent.CreateSessionAsync(); + await agent.RunAsync("Hello", session); + + Assert.True(userAgentFound, "Expected MEAI user-agent header to be present in requests."); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs index 0a33c03ccd..9cd2ecea46 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs @@ -63,8 +63,8 @@ internal static class TestDataUtil json = ApplyInstructions(json, instructions); json = ApplyDescription(json, description); // Remove the version and id fields to simulate hosted agents without version - json = json.Replace("\"version\": \"1\",", "\"version\": \"\","); - json = json.Replace("\"id\": \"agent_abc123:1\",", "\"id\": \"\","); + json = json.Replace("\"version\": \"1\",", "\"version\": \"\",") + .Replace("\"id\": \"agent_abc123:1\",", "\"id\": \"\","); return json; } @@ -79,8 +79,8 @@ internal static class TestDataUtil json = ApplyInstructions(json, instructions); json = ApplyDescription(json, description); // Remove the version and id fields to simulate hosted agents without version - json = json.Replace("\"version\": \"1\",", "\"version\": \"\","); - json = json.Replace("\"id\": \"agent_abc123:1\",", "\"id\": \"\","); + json = json.Replace("\"version\": \"1\",", "\"version\": \"\",") + .Replace("\"id\": \"agent_abc123:1\",", "\"id\": \"\","); return json; } diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs index 9b3c95c5c2..d6092f5231 100644 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Tests intentionally exercise obsolete extension methods + using System; using System.Threading.Tasks; using Azure.AI.Projects; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs index 4181dad409..09d83966aa 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs @@ -34,7 +34,9 @@ public class MessageMergerTests response.Messages[0].Role.Should().Be(ChatRole.Assistant); response.Messages[0].AuthorName.Should().Be(TestAuthorName1); response.AgentId.Should().Be(TestAgentId1); - response.CreatedAt.Should().NotBe(creationTime); + response.CreatedAt.Should().HaveValue(); + response.CreatedAt.Value.Should().BeOnOrAfter(creationTime); + response.CreatedAt.Value.Should().BeCloseTo(creationTime, precision: TimeSpan.FromSeconds(5)); response.Messages[0].CreatedAt.Should().Be(creationTime); response.Messages[0].Contents.Should().HaveCount(1); response.FinishReason.Should().BeNull(); From 4527dee64b87446a2d399a901bfb4dddd049e1f2 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:48:08 +0100 Subject: [PATCH 31/31] Python: fix: update PyRIT repository link from Azure/PyRIT to microsoft/PyRIT (#4960) * Initial plan * fix: update PyRIT repository link from Azure/PyRIT to microsoft/PyRIT Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/830b8ccf-a79c-49b6-90c9-3bb3e740bc06 Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --- python/samples/05-end-to-end/evaluation/red_teaming/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/samples/05-end-to-end/evaluation/red_teaming/README.md b/python/samples/05-end-to-end/evaluation/red_teaming/README.md index 39fda91ae4..9a9efaafe6 100644 --- a/python/samples/05-end-to-end/evaluation/red_teaming/README.md +++ b/python/samples/05-end-to-end/evaluation/red_teaming/README.md @@ -174,7 +174,7 @@ Overall Attack Success Rate: 7.2% - [Azure AI Evaluation SDK](https://learn.microsoft.com/azure/ai-foundry/how-to/develop/evaluate-sdk) - [Risk and Safety Evaluations](https://learn.microsoft.com/azure/ai-foundry/concepts/evaluation-metrics-built-in#risk-and-safety-evaluators) - [Azure AI Red Teaming Notebook](https://github.com/Azure-Samples/azureai-samples/blob/main/scenarios/evaluate/AI_RedTeaming/AI_RedTeaming.ipynb) -- [PyRIT - Python Risk Identification Toolkit](https://github.com/Azure/PyRIT) +- [PyRIT - Python Risk Identification Toolkit](https://github.com/microsoft/PyRIT) ## Troubleshooting