Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."])]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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)
Expand Down
66 changes: 66 additions & 0 deletions python/packages/foundry_hosting/tests/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
Loading