diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py index 2853b769a7..48733b6a2d 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py @@ -13,6 +13,7 @@ import logging import sys from collections.abc import Awaitable, Callable +from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict, overload from agent_framework import ( @@ -140,6 +141,16 @@ _AZURE_SEARCH_RESOURCE_SCOPE = "https://search.azure.com/.default" +def _normalize_role(role: Any) -> Any: + """Return the plain string role for a Knowledge Base role value. + + The SDK role can be a ``str``-subclass enum. Agent Framework types ``Message.role`` as a plain + ``str``, and durable session-state serialization rejects scalar subclasses, so unwrap the enum + before it reaches a ``Message``. + """ + return role.value if isinstance(role, Enum) else role + + def _installed_search_documents_version() -> str: """Return the installed ``azure-search-documents`` version (for diagnostics).""" try: @@ -1081,7 +1092,7 @@ def _parse_messages_from_kb_response(retrieval_result: KnowledgeBaseRetrievalRes if annotations: for c in contents: c.annotations = annotations - result_messages.append(Message(role=kb_msg.role or "assistant", contents=contents)) + result_messages.append(Message(role=_normalize_role(kb_msg.role) or "assistant", contents=contents)) if not result_messages: return [Message(role="assistant", contents=["No results found from Knowledge Base."])] diff --git a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py index 3441dc954a..ccf0a6fafe 100644 --- a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py +++ b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py @@ -1797,6 +1797,30 @@ def test_converts_all_messages(self) -> None: assert result[1].role == "assistant" assert result[1].text == "answer" + def test_role_enum_is_normalized_to_plain_string(self) -> None: + from enum import Enum + + from azure.search.documents.knowledgebases.models import ( + KnowledgeBaseMessage, + KnowledgeBaseMessageTextContent, + KnowledgeBaseRetrievalResponse, + ) + + class _Role(str, Enum): + USER = "user" + + # Search SDK versions may type the role as a str-subclass enum. Agent Framework types + # Message.role as a plain str, and durable session-state serialization rejects scalar + # subclasses, so the role must be unwrapped when the message is created. + response = KnowledgeBaseRetrievalResponse( + response=[KnowledgeBaseMessage(role=_Role.USER, content=[KnowledgeBaseMessageTextContent(text="q")])], + references=None, + ) + result = AzureAISearchContextProvider._parse_messages_from_kb_response(response) + assert len(result) == 1 + assert type(result[0].role) is str + assert result[0].role == "user" + def test_none_response_returns_default(self) -> None: from azure.search.documents.knowledgebases.models import KnowledgeBaseRetrievalResponse diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index 49882462ce..ce00d68343 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -12,6 +12,7 @@ from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress from dataclasses import asdict, dataclass, is_dataclass +from enum import Enum from pathlib import Path from typing import Literal, Protocol, cast @@ -133,6 +134,16 @@ _HOSTED_RESPONSES_HISTORY_SOURCE_ID = "_foundry_responses_history" +def _normalize_role(role: Any) -> str: + """Return the plain string role for an Azure Responses SDK role value. + + ``MessageRole`` is a ``str``-subclass enum. Agent Framework types ``Message.role`` as a plain + ``str``, and durable session-state serialization rejects scalar subclasses, so unwrap the enum + before it reaches a ``Message``. + """ + return role.value if isinstance(role, Enum) else role + + # region Approval Storage class ApprovalStorage(Protocol): """Storage for saving function approval requests.""" @@ -1225,14 +1236,16 @@ async def _item_to_message(item: Item, *, approval_storage: ApprovalStorage | No """ if item.type == "message": msg = cast(ItemMessage, item) + role = _normalize_role(msg.role) if isinstance(msg.content, str): - return Message(role=msg.role, contents=[Content.from_text(msg.content)]) - return Message(role=msg.role, contents=[_convert_message_content(part) for part in msg.content]) + return Message(role=role, contents=[Content.from_text(msg.content)]) + return Message(role=role, contents=[_convert_message_content(part) for part in msg.content]) if item.type == "output_message": output_msg = cast(ItemOutputMessage, item) return Message( - role=output_msg.role, contents=[_convert_output_message_content(part) for part in output_msg.content] + role=_normalize_role(output_msg.role), + contents=[_convert_output_message_content(part) for part in output_msg.content], ) if item.type == "function_call": @@ -1513,12 +1526,15 @@ async def _output_item_to_message(item: OutputItem, *, approval_storage: Approva if item.type == "output_message": output_msg = cast(OutputItemOutputMessage, item) return Message( - role=output_msg.role, contents=[_convert_output_message_content(part) for part in output_msg.content] + role=_normalize_role(output_msg.role), + contents=[_convert_output_message_content(part) for part in output_msg.content], ) if item.type == "message": msg = cast(OutputItemMessage, item) - return Message(role=msg.role, contents=[_convert_message_content(part) for part in msg.content]) + return Message( + role=_normalize_role(msg.role), contents=[_convert_message_content(part) for part in msg.content] + ) if item.type == "function_call": fc = cast(OutputItemFunctionToolCall, item) diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index cc1d863304..834a35c60d 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -1581,6 +1581,35 @@ async def test_message(self) -> None: assert len(msg.contents) == 1 assert msg.contents[0].text == "hi" + async def test_role_enum_is_normalized_to_plain_string(self) -> None: + from azure.ai.agentserver.responses.models import ( + MessageContentInputTextContent, + MessageRole, + OutputItemMessage, + OutputItemOutputMessage, + OutputMessageContentOutputTextContent, + ) + + # The SDK role is a str-subclass enum. Agent Framework types Message.role as a plain str, and + # durable session-state serialization rejects scalar subclasses, so it must be unwrapped here. + output_message = OutputItemOutputMessage({ + "type": "output_message", + "role": MessageRole.ASSISTANT, + "content": [OutputMessageContentOutputTextContent({"type": "output_text", "text": "hello"})], + "status": "completed", + "id": "msg-1", + }) + message = OutputItemMessage({ + "type": "message", + "role": MessageRole.USER, + "content": [MessageContentInputTextContent({"type": "input_text", "text": "hi"})], + }) + + for item, expected in ((output_message, "assistant"), (message, "user")): + msg = await _output_item_to_message(item) + assert type(msg.role) is str + assert msg.role == expected + async def test_function_call(self) -> None: from azure.ai.agentserver.responses.models import OutputItemFunctionToolCall @@ -2018,6 +2047,43 @@ async def test_message_with_string_content(self) -> None: assert msg.contents[0].type == "text" assert msg.contents[0].text == "hello" + async def test_message_role_enum_is_normalized_to_plain_string(self) -> None: + from azure.ai.agentserver.responses.models import ItemMessage, MessageContentInputTextContent, MessageRole + + # The SDK role is a str-subclass enum. Agent Framework types Message.role as a plain str, and + # durable session-state serialization rejects scalar subclasses, so it must be unwrapped here. + string_item = ItemMessage({"type": "message", "role": MessageRole.USER, "content": "hello"}) + content_item = ItemMessage({ + "type": "message", + "role": MessageRole.USER, + "content": [MessageContentInputTextContent({"type": "input_text", "text": "hi"})], + }) + + for item in (string_item, content_item): + msg = await _item_to_message(item) + assert msg is not None + assert type(msg.role) is str + assert msg.role == "user" + + async def test_normalized_role_survives_durable_session_state_serialization(self, tmp_path: Path) -> None: + from agent_framework import AgentSession, FileSessionStore + from azure.ai.agentserver.responses.models import ItemMessage, MessageRole + + # Persist through a real session store: durable state is validated on save, and an enum role + # fails there with "unsupported serialized type 'MessageRole'". AgentSession.to_dict() alone + # would not catch it, because it skips that validation. + msg = await _item_to_message(ItemMessage({"type": "message", "role": MessageRole.USER, "content": "hello"})) + assert msg is not None + session = AgentSession(session_id="enum-role") + session.state["messages"] = [msg] + + store = FileSessionStore(tmp_path) + await store.set("enum-role", session) + restored = await store.get("enum-role") + + assert restored is not None + assert type(restored.state["messages"][0].role) is str + async def test_message_with_input_text_content(self) -> None: from azure.ai.agentserver.responses.models import ItemMessage, MessageContentInputTextContent